This document describes the technical design of SDEP activities.
Reference links:
The generic patterns behind the choices below are in Architecture and API.
For activity:
| Aspect | Description |
|---|---|
| Owner | Platform (STR) |
| Writers | STR only |
| Write shape | One POST /activities/bulk |
| Payload | JSON |
| Readers | CA (activities within own areas), STA and AMA (all activities) |
| Concurrency | No version token: a single writer per activityId |
| Lifecycle | finished or cancelled, set by the caller |
| Delete | None: resubmit with status cancelled |
More things are specific to activities:
- CA v1 has its own frozen response schemas
- Timestamps must be UTC from STR v2 on (offset
Zor+00:00; no other offsets, no date-only values); v1 accepts what it always accepted - A resubmitted
activityIdis a correction: it versions the previous record instead of creating a duplicate, which makes the bulk write safe to retry
This is an overview, not the contract. The contract is the OpenAPI document of the version you call; the persisted columns are in Internal data model. The tables below name the fields and say what they are for.
Submitted by the platform (POST /activities/bulk).
| Field | Description |
|---|---|
activityId |
Functional ID identifying the activity (optionally supplied and versioned, else auto-generated [1]) |
activityName |
Display name (optional) |
status |
finished (default) or cancelled |
areaId |
Functional ID referencing the area the rental address is in |
url |
References the advertisement online |
address |
Rental address (the Address composite, shared with listings) |
registrationNumber |
Registration number of the rental address |
numberOfGuests |
Number of guests; must match the length of countryOfGuests |
countryOfGuests |
One ISO 3166-1 alpha-3 code per guest, or N/A |
temporal |
The rental period (startDatetime, endDatetime); UTC from v2 on |
[1] activityId is unique per platform, not globally, and only within one version (createdAt). A resubmitted ID is a correction, see characteristics.
Returned by every GET /activities, and embedded in every OK item of the bulk response.
It comprises the request fields, enriched by SDEP:
| Added field | Description |
|---|---|
areaName |
Copied from the referenced area |
competentAuthorityId |
The authority owning that area |
competentAuthorityName |
Display name of that authority |
platformId |
The submitting platform (not accepted on the request) |
platformName |
Display name of that platform |
createdAt |
The version timestamp, managed by SDEP |
CA v1 serves a frozen variant of this schema, see Frozen v1 schemas.
Dotted titles, as for listings:
Activity .Request | .Response | .BulkRequest | .BulkResultItem | .BulkResponse | .ListResponse | .CountResponse
Activity.Statusbacks thestatusfield (app/enums.py), titled that way in the OpenAPI documentActivity.BulkRequest.activitiesusesSkipValidationper item, so one invalid item is NOK without failing the batch- An OK item embeds
Activity.Response, a NOK item embedserrors - The HTTP status of the bulk result is 201 all OK, 200 partial, 422 all failed (
app/api/common/bulk_json.py)
The write and the read, step by step. The four-step validation shape behind the write is in Validation; the generic rationale is in Bulk. The read serves steps 4, 6 and 8 of the sequence.
A. Inputs:
- From JWT (verified by the auth dependency):
clientId←client_idclaimplatformName←client_nameclaim
- From JSON payload:
activities: array of 1-1000 activity items; each item carries:activityId(optional functional id, alphanumeric with hyphens, length <= 64)activityName(optional, length <= 64)status(optional enum, defaults tofinished; may also becancelled)areaId(required functional id, must reference an existing area)url(length <= 2048; STR v1: <= 128)address(composite:thoroughfare,locatorDesignatorNumber(optional),locatorDesignatorLetter(optional),locatorDesignatorAddition(optional),postCode,postName,fullAddress)registrationNumber(length <= 32)numberOfGuests(1-1024)countryOfGuests(array, 1-1024 elements; each ISO 3166-1 alpha-3 orN/A, uppercase; length must equalnumberOfGuests)temporal(composite:startDatetime,endDatetime)
B. Steps:
-
Per-item Pydantic validation (
TypeAdapter(ActivityRequest)):- Invalid items are marked NOK with their errors; valid items continue
- The original client-supplied
activityId(orNone) is preserved for the response - For valid items, a missing
activityIdis auto-generated (UUIDv4)
-
Resolve or version the
Platformonce per batch:- No row exists for
clientId: create a new Platform- Technical id
id: autogenerated (int) - Functional id
platformId: auto-generated (UUIDv4) - Name
platformName: ← JWT - Reference
clientId: ← JWT - Timestamp
createdAt: autogenerated (now())
- Technical id
clientIdexists andplatformNameunchanged: reuse as isclientIdexists andplatformNamechanged: mark the current Platform as ended (endedAt = now()) and insert a new version- Technical id
id: autogenerated (int) - Functional id
platformId: same as is - Name
platformName: ← JWT - Reference
clientId: same as is - Timestamp
createdAt: autogenerated (now())
- Technical id
- Only ended rows exist for
clientId: reject as deactivated
- No row exists for
-
Intra-batch deduplication on
activityId(last-wins): when a batch contains multiple valid items with the sameactivityId, only the last occurrence proceeds; earlier occurrences are marked NOK with a "superseded by later item in batch at index N" error. -
Referential integrity check (single query): resolve
areaId→ technicalid(and owning CA) for all referenced areas viaget_area_ca_map. Items pointing at unknown areas are marked NOK. -
Resolve or version each
Activity(row-lockedFOR UPDATEon(activityId, platformId)whenactivityIdis supplied):activityIdwas auto-generated in step 1: defer to step 6 (no versioning lookup; brand-new functional id)activityIdis supplied and no active row exists for(activityId, platformId): defer to step 6 (insert using the supplied functional id)activityIdis supplied and an active row exists for(activityId, platformId): mark the current Activity as ended (endedAt = now()); the new version is inserted in step 6activityIdis supplied and only ended rows exist for this platform: reject as deactivated
-
Bulk insert all remaining valid items in a single multi-row INSERT, using one
batch_created_at(=now()at INSERT time) for the whole batch. Each newactivityrow:- Technical id
id: autogenerated (int) - Functional id
activityId: ← validated request (supplied, or auto-generated UUIDv4 from step 1) - Functional columns from the payload (
activityName,status,url, address fields,registrationNumber,numberOfGuests,countryOfGuests, temporal fields) - Platform reference
platform_id(FK): ← technicalidof the Platform row from step 2 - Area reference
area_id(FK): ← technicalidresolved in step 4 - Timestamp
createdAt: ←batch_created_at - End timestamp
endedAt:NULL
- Technical id
-
Commit at the API transaction boundary (CRUD layer only flushes); on any exception the whole batch rolls back.
-
Return a per-item OK/NOK response preserving the original request order. HTTP status:
201if all items succeeded,200on partial success,422if all items failed.
Net effect:
- 1x new
platformrow inserted only when the platform is new or its name changed; the previous version is marked ended in the latter case - N new
activityrows (one per valid item), each with FKsactivity.platform_id → platform.idandactivity.area_id → area.id - Optionally M old
activityrows marked ended when a suppliedactivityIdhad an active version for this platform
A. Inputs:
- From JWT: the audience role plus
sdep_read; for CA alsoclientId, which fixes the owner scope - From the query string:
offset,limit, and the filters that the domain declares, see Filtering
B. Steps:
-
The router checks the roles and fixes the scope, passed as the
clientargument of the read handler. The query string cannot change it.Step Actor Domain Roles Fixed scope 4. CA CA v1, v2 sdep_ca,sdep_readOwn areas ( clientis the CA, fromclientId)6. AMA AMA v1 sdep_ama,sdep_readNone ( client=None): all platforms and authorities8. STA STA v1, v2 sdep_sta,sdep_readNone ( client=None): all platforms and authorities -
Parse pagination and filters. An invalid value (non-UTC timestamp, bad functional ID) is HTTP 400, the query does not run. CA v1 and v2 declare the same filters, and
limitdefaults to 1000, the maximum. -
One query on the read-only session: current versions only (
endedAt IS NULL), joined withareaandcompetent_authority. Scope and filters are plainWHEREclauses, combined with AND. -
Order newest first (
createdAtdesc, then technicaliddesc), then applyoffsetandlimit. -
Return
Activity.ListResponse, oneActivity.Responseper activity; CA v1 returns its frozen v1 schemas.GET /activities/countruns the same query without order and paging, and returnsActivity.CountResponse.
Net effect:
- No row is written
- Only the current version of an activity is returned, never its history; a
cancelledactivity is current too, and is returned
POST /activities/bulk runs the four-step flow every bulk write in SDEP follows. The step
number says when a check runs:
- Syntax and semantics per item (Pydantic): field formats, the guest cardinality, start before end (
value_error) - Referential integrity (one query per batch):
areaIdexists (not_found_error), and from STR v2 the area must be regulated for activities (regulation_error) - Versioning under lock:
SELECT ... FOR UPDATEon the current version, then mark it ended and insert the new one. A fully endedactivityIdis refused as deactivated - Feedback: per-item OK/NOK in the original order, a NOK item never fails the batch
Two things are specific to activities:
- Intra-batch duplicates are last-wins: of repeated
activityIds in one batch only the last is processed, the earlier ones are NOK with "superseded by later item in batch at index N" - No version token: the platform is the only writer, so there is nothing to collide with. Listings need one, see Concurrency
The generic rationale behind the flow is in Bulk.
Activities are the oldest resource in SDEP, and the pattern every later resource copies. Listings mirror this set one-for-one, see Listing (technical).
| Layer | File | What it holds |
|---|---|---|
| Models | app/models/activity.py |
The Activity ORM class, its unique constraint and its check constraints |
| Models | app/models/temporal.py |
The Temporal composite (the rental period) |
| Enums | app/enums.py |
ActivityStatus, titled Activity.Status |
| Schemas | app/schemas/activity.py |
Activity.Request and Activity.Response, plus the filters |
| Schemas | app/schemas/activity_bulk.py |
The bulk request and response shapes |
| Schemas | app/schemas/activity_v1.py |
The frozen STR v1 bulk and CA v1 response schemas, see below |
| CRUD | app/crud/activity.py |
The queries: scoped read, count, locked current version, mark-ended plus insert |
| Services | app/services/activity.py |
The read service (list and count) |
| Services | app/services/activity_bulk.py |
POST /activities/bulk, the four-step validation flow |
| API | app/api/common/activity_handlers.py |
The one read handler used by CA, STA and AMA |
| API | app/api/common/activity_examples.py |
The OpenAPI examples and field text |
| API | app/api/domains/<domain>/routers/ |
One router per audience and version |
The endpoint contract per domain and version is in API.
| Endpoint | Domain | Router | Service |
|---|---|---|---|
POST /activities/bulk |
STR v1 | str/routers/activities_bulk_v1.py |
activity_bulk.py |
POST /activities/bulk |
STR v2 | str/routers/activities_bulk_v2.py |
activity_bulk.py |
GET /activities, GET /activities/count |
CA v1 | ca/routers/activities_v1.py |
activity.py, via activity_handlers.py |
GET /activities, GET /activities/count |
CA v2 | ca/routers/activities_v2.py |
activity.py, via activity_handlers.py |
GET /activities, GET /activities/count |
STA v1 | sta/routers/activities_v1.py |
activity.py, via activity_handlers.py |
GET /activities, GET /activities/count |
STA v2 | sta/routers/activities_v1.py |
activity.py, via activity_handlers.py |
GET /activities, GET /activities/count |
AMA v1 | ama/routers/activities_v1.py |
activity.py, via activity_handlers.py |
The read handler takes the scope as its client argument: a Client scopes the read to
that competent authority, None makes it unscoped (STA and AMA). The argument is
keyword-only with no default, so an unscoped read has to be written out.
STR v2 added the UTC-only timestamps, the regulation check, and the wider url (2048) and fullAddress (328).
STR v1 and CA v1 are stable, so their request and response bodies may not change. The
frozen schemas live in their own module, app/schemas/activity_v1.py:
- STR v1 request:
urlmax 128 andfullAddressmax 318 (the base schemas allow 2048 and 328); the STR v1 bulk router passes it asitem_model - STR v1 bulk response: no documented field maximums; it only shapes the OpenAPI, the endpoint returns the same JSON as v2
- CA v1 response: no documented field maximums; the shared read handler selects it through its
list_modelanditem_modelarguments
Every other version uses the base schemas. This is the general mechanism for keeping a stable contract stable while the resource evolves: freeze the schema, not the handler. The module is deleted together with v1.
| Shared part | Where it comes from |
|---|---|
The Address composite |
app/models/address.py, also used by listings |
| The bulk HTTP status mapping | app/api/common/bulk_json.py |
| The platform resolve and re-version | app/services/platform.py (ensure_platform), also used by listings |
| The area lookup and regulation check | get_area_ca_map() plus Regulation.covers(), see Area |
The StringArray column type |
app/models/types.py, for countryOfGuests |
What is registered outside the activity files:
- Keycloak roles
sdep_strfor the writes,sdep_ca,sdep_staandsdep_amafor the reads, plussdep_readandsdep_write, inkeycloak/roles.yamland theRoleenum - Domain sub-app
/api/ama/v1, with the same read handler and filters as STA v1 (createdAtFrom,createdAtTo,areaId,platformId,competentAuthorityId), see the API surface; a new sub-app follows the steps in Adding a version - Audit action rules for the activity endpoints, in
app/security/audit.py - The bulk item schemas
ActivityRequestandActivityRequestV2inBULK_ITEM_SCHEMAS, so the OpenAPI document keeps them as components, see OpenAPI document - The
Activityclass inapp/models/__init__.pyand the module inapp/crud/__init__.py - The
activitytable, in the initial Alembic migration - A test client per role, in
keycloak/machine-clients.yamlandscripts/generate-keycloak-machine-clients.py, with the credentials exported by the rootMakefile - End-to-end tests
tests/test_*_activities*.py, run by themake test-<role>targets andscripts/run-tests.sh - The
activityrows inpostgres/clean-testrun.sql(test cleanup) andpostgres/count-app.sql