Skip to content

Commit 3118aaf

Browse files
authored
Merge pull request #620 from minvws/feat/1170-merge-nvi-beheer-db
Feat/1170 merge nvi beheer db
2 parents b56e4e9 + 8f086cb commit 3118aaf

9 files changed

Lines changed: 345 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 ColumnElement, and_, delete, exists, func, select
4+
from sqlalchemy import ColumnElement, and_, delete, exists, func, 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

@@ -65,7 +78,7 @@ def _filters(
6578
ura_number: str | None = None,
6679
source: str | None = None,
6780
) -> list[ColumnElement[bool]]:
68-
filters: list[ColumnElement[bool]] = []
81+
filters: list[ColumnElement[bool]] = [_organization_not_deleted()]
6982

7083
if ura_number is not None:
7184
filters.append(ReferralEntity.ura_number == ura_number)

‎docker/Dockerfile‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,7 @@ COPY --chown=${APP_USER}:${APP_GROUP} app.conf.example /src/
101101
COPY --chown=${APP_USER}:${APP_GROUP} static /src/static
102102
COPY --chown=${APP_USER}:${APP_GROUP} sql /src/sql
103103
COPY --chown=${APP_USER}:${APP_GROUP} tools/migrate_db.sh /src/tools/migrate_db.sh
104+
COPY --chown=${APP_USER}:${APP_GROUP} tools/migrate_beheer_data.py /src/tools/migrate_beheer_data.py
104105

105106
# version.json is generated from build args so CI workspace artifacts never leak in
106107
ARG RELEASE_VERSION=dev

‎docker/init.sh‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ fi
1414
echo "Migrating"
1515

1616
DSN=$(grep dsn $APP_PATH | sed -r 's/dsn=postgresql\+psycopg/postgresql/' ) tools/./migrate_db.sh
17+
DSN=$(grep dsn $APP_PATH | sed -r 's/dsn=postgresql\+psycopg/postgresql/' ) python tools/migrate_beheer_data.py
1718

1819
echo "Start main process"
1920
python -m app.main

‎sql/026-admin-schema.sql‎

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
CREATE SCHEMA IF NOT EXISTS admin;
2+
3+
CREATE TABLE admin.organizations (
4+
id uuid DEFAULT gen_random_uuid() NOT NULL,
5+
name varchar NOT NULL,
6+
external_id varchar NOT NULL,
7+
created_at timestamp DEFAULT now() NOT NULL,
8+
modified_at timestamp,
9+
deleted_at timestamp,
10+
CONSTRAINT organizations_pkey PRIMARY KEY (id)
11+
);
12+
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);
14+
15+
CREATE TABLE admin.clients (
16+
id uuid DEFAULT gen_random_uuid() NOT NULL,
17+
organization_id uuid NOT NULL,
18+
name varchar(100) NOT NULL,
19+
description varchar(255),
20+
created_at timestamp DEFAULT now() NOT NULL,
21+
modified_at timestamp,
22+
deleted_at timestamp,
23+
CONSTRAINT clients_pkey PRIMARY KEY (id),
24+
CONSTRAINT clients_organization_id_fkey FOREIGN KEY (organization_id) REFERENCES admin.organizations (id)
25+
);
26+
27+
CREATE TABLE admin.certificates (
28+
id uuid DEFAULT gen_random_uuid() NOT NULL,
29+
organization_id uuid NOT NULL,
30+
organization_identifier varchar(100),
31+
domain varchar(100),
32+
created_at timestamp DEFAULT now() NOT NULL,
33+
modified_at timestamp,
34+
deleted_at timestamp,
35+
CONSTRAINT certificates_pkey PRIMARY KEY (id),
36+
CONSTRAINT fk_certificates_organizations FOREIGN KEY (organization_id) REFERENCES admin.organizations (id)
37+
);
38+
CREATE UNIQUE INDEX uq_organization_id_organization_identifier_domain ON admin.certificates (organization_id, organization_identifier, domain) WHERE deleted_at IS NULL;
39+
40+
CREATE TABLE admin.sources (
41+
id uuid DEFAULT gen_random_uuid() NOT NULL,
42+
organization_id uuid NOT NULL,
43+
source_id varchar(100) NOT NULL,
44+
name varchar(150) NOT NULL,
45+
created_at timestamp DEFAULT now() NOT NULL,
46+
modified_at timestamp,
47+
deleted_at timestamp,
48+
CONSTRAINT sources_pkey PRIMARY KEY (id),
49+
CONSTRAINT fk_sources_organizations FOREIGN KEY (organization_id) REFERENCES admin.organizations (id)
50+
);
51+
CREATE UNIQUE INDEX uq_sources_source_id_active ON admin.sources (organization_id, source_id) WHERE deleted_at IS NULL;
52+
53+
CREATE TABLE admin.scopes (
54+
id integer GENERATED ALWAYS AS IDENTITY,
55+
name varchar(50),
56+
created_at timestamp DEFAULT now() NOT NULL,
57+
CONSTRAINT scopes_pkey PRIMARY KEY (id),
58+
CONSTRAINT scopes_name_key UNIQUE (name)
59+
);
60+
61+
INSERT INTO admin.scopes (name) VALUES
62+
('CREATE'),
63+
('DELETE'),
64+
('READ'),
65+
('LOCALIZE');
66+
67+
CREATE TABLE admin.organizations_scopes (
68+
organization_id uuid NOT NULL,
69+
scope_id integer NOT NULL,
70+
created_at timestamp DEFAULT now() NOT NULL,
71+
CONSTRAINT pk_organizations_scopes PRIMARY KEY (organization_id, scope_id),
72+
CONSTRAINT fk_organization_scopes_organization FOREIGN KEY (organization_id) REFERENCES admin.organizations (id) ON DELETE CASCADE,
73+
CONSTRAINT fk_organization_scopes_scopes FOREIGN KEY (scope_id) REFERENCES admin.scopes (id)
74+
);
75+
76+
CREATE TABLE admin.clients_scopes (
77+
client_id uuid NOT NULL,
78+
organization_id uuid NOT NULL,
79+
scope_id integer NOT NULL,
80+
created_at timestamp DEFAULT now() NOT NULL,
81+
CONSTRAINT pk_clients_scopes PRIMARY KEY (client_id, organization_id, scope_id),
82+
CONSTRAINT fk_clients_scopes_clients FOREIGN KEY (client_id) REFERENCES admin.clients (id) ON DELETE CASCADE,
83+
CONSTRAINT fk_clients_scopes_scopes FOREIGN KEY (organization_id, scope_id) REFERENCES admin.organizations_scopes (organization_id, scope_id)
84+
);
85+
86+
CREATE TABLE admin.clients_certificates (
87+
client_id uuid NOT NULL,
88+
certificate_id uuid NOT NULL,
89+
created_at timestamp DEFAULT now() NOT NULL,
90+
CONSTRAINT pk_clients_certificates PRIMARY KEY (client_id, certificate_id),
91+
CONSTRAINT fk_clients_certificates_clients FOREIGN KEY (client_id) REFERENCES admin.clients (id) ON DELETE CASCADE,
92+
CONSTRAINT fk_clients_certificates_certificates FOREIGN KEY (certificate_id) REFERENCES admin.certificates (id) ON DELETE CASCADE
93+
);
94+
95+
CREATE TABLE admin.clients_sources (
96+
client_id uuid NOT NULL,
97+
source_id uuid NOT NULL,
98+
CONSTRAINT pk_sources_clients PRIMARY KEY (source_id, client_id),
99+
CONSTRAINT fk_clients_sources_clients FOREIGN KEY (client_id) REFERENCES admin.clients (id) ON DELETE CASCADE,
100+
CONSTRAINT fk_clients_sources_sources FOREIGN KEY (source_id) REFERENCES admin.sources (id) ON DELETE CASCADE
101+
);

‎tests/db/repository/test_referral_repository.py‎

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
from app.config import ConfigDatabase
99
from app.db.db import Database
1010
from app.db.models.key_info import KeyInfoEntity
11+
from app.db.models.organization import OrganizationEntity
1112
from app.db.models.referral import ReferralEntity
1213
from app.db.repository.referral_repository import ReferralRepository
1314

@@ -388,3 +389,57 @@ def test_exists_should_return_false_if_record_is_not_there(
388389
)
389390

390391
assert exist is False
392+
393+
394+
NOW = datetime(2026, 1, 1, tzinfo=UTC)
395+
396+
397+
@pytest.mark.parametrize(
398+
"organizations,visible",
399+
[
400+
pytest.param([None], True, id="active"),
401+
pytest.param([NOW], False, id="deleted"),
402+
pytest.param([NOW, None], True, id="deleted-then-recreated"),
403+
pytest.param([], True, id="no-organization"),
404+
],
405+
)
406+
def test_referral_visibility_depends_on_organization(
407+
referral_repository: ReferralRepository,
408+
mock_referral_entity: ReferralEntity,
409+
mock_key_info: KeyInfoEntity,
410+
organizations: list[datetime | None],
411+
visible: bool,
412+
) -> None:
413+
with referral_repository.db_session:
414+
for deleted_at in organizations:
415+
referral_repository.db_session.add(
416+
OrganizationEntity(id=uuid4(), external_id=mock_referral_entity.ura_number, deleted_at=deleted_at)
417+
)
418+
mock_referral_entity.key_info = mock_key_info
419+
referral_repository.add_one(mock_referral_entity)
420+
421+
by_id = referral_repository.find_by_id(mock_referral_entity.id)
422+
many = referral_repository.find_many(ura_number=mock_referral_entity.ura_number)
423+
count = referral_repository.count(ura_number=mock_referral_entity.ura_number)
424+
425+
assert (by_id is not None) is visible
426+
assert len(many) == count == (1 if visible else 0)
427+
428+
429+
def test_deleted_organization_hides_only_its_own_referrals(
430+
referral_repository: ReferralRepository,
431+
mock_key_info: KeyInfoEntity,
432+
) -> None:
433+
with referral_repository.db_session:
434+
referral_repository.db_session.add(OrganizationEntity(id=uuid4(), external_id="00000001", deleted_at=NOW))
435+
referral_repository.db_session.add(OrganizationEntity(id=uuid4(), external_id="00000002", deleted_at=None))
436+
for ura in ("00000001", "00000002"):
437+
referral_repository.add_one(
438+
ReferralEntity(ura_number=ura, pseudonym="some-pseudonym", source="Some-Device", key_info=mock_key_info)
439+
)
440+
441+
actual = referral_repository.find_many(pseudonym="some-pseudonym")
442+
count = referral_repository.count(pseudonym="some-pseudonym")
443+
444+
assert [r.ura_number for r in actual] == ["00000002"]
445+
assert count == 1

‎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)