Skip to content

Commit 132ad82

Browse files
committed
Hide referrals of soft-deleted organizations
1 parent 03b5b2e commit 132ad82

6 files changed

Lines changed: 114 additions & 3 deletions

File tree

‎app/db/db.py‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,8 @@ def __init__(self, config_database: ConfigDatabase):
3030
)
3131
with self.engine.connect() as conn:
3232
conn.execute(text("PRAGMA foreign_keys=ON"))
33+
# Postgres schemas are emulated with attached databases
34+
conn.execute(text("ATTACH DATABASE ':memory:' AS admin"))
3335
else:
3436
self.engine = create_engine(
3537
dsn,

‎app/db/models/organization.py‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
from datetime import datetime
2+
from uuid import UUID
3+
4+
from sqlalchemy import TIMESTAMP, String
5+
from sqlalchemy.orm import Mapped, mapped_column
6+
from sqlalchemy.types import Uuid
7+
8+
from app.db.models.base import Base
9+
10+
11+
class OrganizationEntity(Base):
12+
"""
13+
Organizations managed by the nvi-beheer-api. NVI only reads this table.
14+
"""
15+
16+
__tablename__ = "organizations"
17+
__table_args__ = ({"schema": "admin"},)
18+
19+
id: Mapped[UUID] = mapped_column("id", Uuid, primary_key=True)
20+
external_id: Mapped[str] = mapped_column("external_id", String)
21+
deleted_at: Mapped[datetime | None] = mapped_column("deleted_at", TIMESTAMP)

‎app/db/repository/referral_repository.py‎

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,27 @@
11
from collections.abc import Sequence
22
from uuid import UUID
33

4-
from sqlalchemy import and_, delete, exists, select
4+
from sqlalchemy import ColumnElement, and_, delete, exists, not_, or_, select
55
from sqlalchemy.exc import SQLAlchemyError
66

77
from app.db.decorator import repository
8+
from app.db.models.organization import OrganizationEntity
89
from app.db.models.referral import ReferralEntity
910
from app.db.repository.respository_base import RepositoryBase
1011

1112

13+
def _organization_not_deleted() -> ColumnElement[bool]:
14+
"""
15+
Hide referrals whose organization is soft-deleted, unless an active organization with the same
16+
URA number exists (deleted and recreated). Referrals without any organization stay visible.
17+
"""
18+
same_ura = OrganizationEntity.external_id == ReferralEntity.ura_number
19+
return or_(
20+
exists().where(same_ura, OrganizationEntity.deleted_at.is_(None)),
21+
not_(exists().where(same_ura)),
22+
)
23+
24+
1225
@repository(ReferralEntity)
1326
class ReferralRepository(RepositoryBase):
1427
def find_one(self, pseudonym: str, ura_number: str, source: str) -> ReferralEntity | None:
@@ -21,7 +34,7 @@ def find_one(self, pseudonym: str, ura_number: str, source: str) -> ReferralEnti
2134
return result
2235

2336
def find_by_id(self, id: UUID) -> ReferralEntity | None:
24-
stmt = select(ReferralEntity).where(ReferralEntity.id == id)
37+
stmt = select(ReferralEntity).where(ReferralEntity.id == id, _organization_not_deleted())
2538
results = self.db_session.execute(stmt).scalars().first()
2639
return results
2740

@@ -31,7 +44,7 @@ def find_many(
3144
ura_number: str | None = None,
3245
source: str | None = None,
3346
) -> Sequence[ReferralEntity]:
34-
stmt = select(ReferralEntity)
47+
stmt = select(ReferralEntity).where(_organization_not_deleted())
3548

3649
if ura_number is not None:
3750
stmt = stmt.where(ReferralEntity.ura_number == ura_number)

‎sql/026-admin-schema.sql‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ CREATE TABLE admin.organizations (
1010
CONSTRAINT organizations_pkey PRIMARY KEY (id)
1111
);
1212
CREATE UNIQUE INDEX uq_organizations_external_id_active ON admin.organizations (external_id) WHERE deleted_at IS NULL;
13+
CREATE INDEX ix_organizations_external_id ON admin.organizations (external_id);
1314

1415
CREATE TABLE admin.clients (
1516
id uuid DEFAULT gen_random_uuid() NOT NULL,

‎tests/db/repository/test_referral_repository.py‎

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
from datetime import UTC, datetime
12
from uuid import uuid4
23

34
import pytest
@@ -7,6 +8,7 @@
78
from app.config import ConfigDatabase
89
from app.db.db import Database
910
from app.db.models.key_info import KeyInfoEntity
11+
from app.db.models.organization import OrganizationEntity
1012
from app.db.models.referral import ReferralEntity
1113
from app.db.repository.referral_repository import ReferralRepository
1214

@@ -323,3 +325,54 @@ def test_exists_should_return_false_if_record_is_not_there(
323325
)
324326

325327
assert exist is False
328+
329+
330+
NOW = datetime(2026, 1, 1, tzinfo=UTC)
331+
332+
333+
@pytest.mark.parametrize(
334+
"organizations,visible",
335+
[
336+
pytest.param([None], True, id="active"),
337+
pytest.param([NOW], False, id="deleted"),
338+
pytest.param([NOW, None], True, id="deleted-then-recreated"),
339+
pytest.param([], True, id="no-organization"),
340+
],
341+
)
342+
def test_referral_visibility_depends_on_organization(
343+
referral_repository: ReferralRepository,
344+
mock_referral_entity: ReferralEntity,
345+
mock_key_info: KeyInfoEntity,
346+
organizations: list[datetime | None],
347+
visible: bool,
348+
) -> None:
349+
with referral_repository.db_session:
350+
for deleted_at in organizations:
351+
referral_repository.db_session.add(
352+
OrganizationEntity(id=uuid4(), external_id=mock_referral_entity.ura_number, deleted_at=deleted_at)
353+
)
354+
mock_referral_entity.key_info = mock_key_info
355+
referral_repository.add_one(mock_referral_entity)
356+
357+
by_id = referral_repository.find_by_id(mock_referral_entity.id)
358+
many = referral_repository.find_many(ura_number=mock_referral_entity.ura_number)
359+
360+
assert (by_id is not None) is visible
361+
assert len(many) == (1 if visible else 0)
362+
363+
364+
def test_deleted_organization_hides_only_its_own_referrals(
365+
referral_repository: ReferralRepository,
366+
mock_key_info: KeyInfoEntity,
367+
) -> None:
368+
with referral_repository.db_session:
369+
referral_repository.db_session.add(OrganizationEntity(id=uuid4(), external_id="00000001", deleted_at=NOW))
370+
referral_repository.db_session.add(OrganizationEntity(id=uuid4(), external_id="00000002", deleted_at=None))
371+
for ura in ("00000001", "00000002"):
372+
referral_repository.add_one(
373+
ReferralEntity(ura_number=ura, pseudonym="some-pseudonym", source="Some-Device", key_info=mock_key_info)
374+
)
375+
376+
actual = referral_repository.find_many(pseudonym="some-pseudonym")
377+
378+
assert [r.ura_number for r in actual] == ["00000002"]

‎tests/db/test_organization.py‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
from datetime import UTC, datetime
2+
from uuid import uuid4
3+
4+
from sqlalchemy import select, text
5+
from sqlalchemy.orm import Session
6+
7+
from app.db.db import Database
8+
from app.db.models.organization import OrganizationEntity
9+
10+
11+
def test_organizations_live_in_admin_schema(database: Database) -> None:
12+
with Session(database.engine) as session:
13+
session.add(OrganizationEntity(id=uuid4(), external_id="00000123", deleted_at=datetime.now(UTC)))
14+
session.commit()
15+
16+
actual = session.scalars(select(OrganizationEntity)).one()
17+
in_admin_schema = session.scalar(text("SELECT count(*) FROM admin.organizations"))
18+
19+
assert actual.external_id == "00000123"
20+
assert actual.deleted_at is not None
21+
assert in_admin_schema == 1

0 commit comments

Comments
 (0)