feat(records): implement native eAkte vertical
This commit is contained in:
@@ -0,0 +1,19 @@
|
||||
from govoplan_records.backend.db.models import (
|
||||
RecordChronologyEntry,
|
||||
RecordClassRevision,
|
||||
RecordFilePlanRevision,
|
||||
RecordIdentity,
|
||||
RecordItem,
|
||||
RecordRevision,
|
||||
RecordVolumeRevision,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"RecordChronologyEntry",
|
||||
"RecordClassRevision",
|
||||
"RecordFilePlanRevision",
|
||||
"RecordIdentity",
|
||||
"RecordItem",
|
||||
"RecordRevision",
|
||||
"RecordVolumeRevision",
|
||||
]
|
||||
@@ -0,0 +1,398 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
import uuid
|
||||
|
||||
from sqlalchemy import (
|
||||
BigInteger,
|
||||
Boolean,
|
||||
DateTime,
|
||||
ForeignKey,
|
||||
Index,
|
||||
Integer,
|
||||
JSON,
|
||||
String,
|
||||
Text,
|
||||
UniqueConstraint,
|
||||
)
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from govoplan_core.db.base import Base, TimestampMixin
|
||||
|
||||
|
||||
def new_uuid() -> str:
|
||||
return str(uuid.uuid4())
|
||||
|
||||
|
||||
class RecordFilePlanRevision(Base, TimestampMixin):
|
||||
__tablename__ = "record_file_plan_revisions"
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"tenant_id", "node_id", "revision", name="uq_record_file_plan_revision"
|
||||
),
|
||||
UniqueConstraint(
|
||||
"tenant_id", "idempotency_key", name="uq_record_file_plan_idempotency"
|
||||
),
|
||||
Index("ix_record_file_plan_current", "tenant_id", "node_id", "superseded_at"),
|
||||
Index("ix_record_file_plan_tree", "tenant_id", "parent_node_id", "code"),
|
||||
)
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
|
||||
node_id: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
|
||||
revision: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
previous_revision_id: Mapped[str | None] = mapped_column(
|
||||
ForeignKey("record_file_plan_revisions.id", ondelete="RESTRICT"),
|
||||
nullable=True,
|
||||
index=True,
|
||||
)
|
||||
parent_node_id: Mapped[str | None] = mapped_column(
|
||||
String(255), nullable=True, index=True
|
||||
)
|
||||
code: Mapped[str] = mapped_column(String(120), nullable=False, index=True)
|
||||
label: Mapped[str] = mapped_column(String(500), nullable=False)
|
||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
active: Mapped[bool] = mapped_column(
|
||||
Boolean, default=True, nullable=False, index=True
|
||||
)
|
||||
valid_from: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
valid_to: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
recorded_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, index=True
|
||||
)
|
||||
superseded_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True, index=True
|
||||
)
|
||||
institutional_context: Mapped[dict[str, Any]] = mapped_column(
|
||||
JSON, default=dict, nullable=False
|
||||
)
|
||||
changed_by: Mapped[str | None] = mapped_column(
|
||||
String(255), nullable=True, index=True
|
||||
)
|
||||
idempotency_key: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
request_sha256: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
|
||||
|
||||
class RecordClassRevision(Base, TimestampMixin):
|
||||
__tablename__ = "record_class_revisions"
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"tenant_id", "class_id", "revision", name="uq_record_class_revision"
|
||||
),
|
||||
UniqueConstraint(
|
||||
"tenant_id", "idempotency_key", name="uq_record_class_idempotency"
|
||||
),
|
||||
Index("ix_record_class_current", "tenant_id", "class_id", "superseded_at"),
|
||||
Index(
|
||||
"ix_record_class_catalog",
|
||||
"tenant_id",
|
||||
"file_plan_node_id",
|
||||
"active",
|
||||
"label",
|
||||
),
|
||||
)
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
|
||||
class_id: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
|
||||
revision: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
previous_revision_id: Mapped[str | None] = mapped_column(
|
||||
ForeignKey("record_class_revisions.id", ondelete="RESTRICT"),
|
||||
nullable=True,
|
||||
index=True,
|
||||
)
|
||||
file_plan_node_id: Mapped[str] = mapped_column(
|
||||
String(255), nullable=False, index=True
|
||||
)
|
||||
key: Mapped[str] = mapped_column(String(120), nullable=False, index=True)
|
||||
label: Mapped[str] = mapped_column(String(500), nullable=False)
|
||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
metadata_requirements: Mapped[list[str]] = mapped_column(
|
||||
JSON, default=list, nullable=False
|
||||
)
|
||||
allowed_source_types: Mapped[list[str]] = mapped_column(
|
||||
JSON, default=list, nullable=False
|
||||
)
|
||||
retention_period_days: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
closure_trigger: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
access_mode: Mapped[str] = mapped_column(
|
||||
String(30), default="tenant", nullable=False
|
||||
)
|
||||
active: Mapped[bool] = mapped_column(
|
||||
Boolean, default=True, nullable=False, index=True
|
||||
)
|
||||
valid_from: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
valid_to: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
recorded_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, index=True
|
||||
)
|
||||
superseded_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True, index=True
|
||||
)
|
||||
institutional_context: Mapped[dict[str, Any]] = mapped_column(
|
||||
JSON, default=dict, nullable=False
|
||||
)
|
||||
changed_by: Mapped[str | None] = mapped_column(
|
||||
String(255), nullable=True, index=True
|
||||
)
|
||||
idempotency_key: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
request_sha256: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
|
||||
|
||||
class RecordIdentity(Base, TimestampMixin):
|
||||
__tablename__ = "record_identities"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("tenant_id", "record_id", name="uq_record_identity_tenant_id"),
|
||||
UniqueConstraint(
|
||||
"tenant_id", "record_number", name="uq_record_identity_tenant_number"
|
||||
),
|
||||
Index("ix_record_identity_catalog", "tenant_id", "record_number"),
|
||||
)
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
|
||||
record_id: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
|
||||
record_number: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
|
||||
created_by: Mapped[str | None] = mapped_column(
|
||||
String(255), nullable=True, index=True
|
||||
)
|
||||
|
||||
|
||||
class RecordRevision(Base, TimestampMixin):
|
||||
__tablename__ = "record_revisions"
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"tenant_id", "record_id", "revision", name="uq_record_revision"
|
||||
),
|
||||
Index("ix_record_current", "tenant_id", "record_id", "superseded_at"),
|
||||
Index(
|
||||
"ix_record_catalog", "tenant_id", "state", "class_id", "file_plan_node_id"
|
||||
),
|
||||
)
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
|
||||
record_id: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
|
||||
identity_id: Mapped[str] = mapped_column(
|
||||
ForeignKey("record_identities.id", ondelete="RESTRICT"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
revision: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
previous_revision_id: Mapped[str | None] = mapped_column(
|
||||
ForeignKey("record_revisions.id", ondelete="RESTRICT"),
|
||||
nullable=True,
|
||||
index=True,
|
||||
)
|
||||
class_id: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
|
||||
file_plan_node_id: Mapped[str] = mapped_column(
|
||||
String(255), nullable=False, index=True
|
||||
)
|
||||
title: Mapped[str] = mapped_column(String(500), nullable=False)
|
||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
state: Mapped[str] = mapped_column(
|
||||
String(40), default="open", nullable=False, index=True
|
||||
)
|
||||
source_authority_mode: Mapped[str] = mapped_column(
|
||||
String(40), default="native_authoritative", nullable=False
|
||||
)
|
||||
access_mode: Mapped[str] = mapped_column(
|
||||
String(30), default="tenant", nullable=False
|
||||
)
|
||||
purpose: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
classification: Mapped[str | None] = mapped_column(
|
||||
String(120), nullable=True, index=True
|
||||
)
|
||||
responsible_unit_id: Mapped[str | None] = mapped_column(
|
||||
String(255), nullable=True, index=True
|
||||
)
|
||||
responsible_function_id: Mapped[str | None] = mapped_column(
|
||||
String(255), nullable=True, index=True
|
||||
)
|
||||
external_reference: Mapped[dict[str, Any]] = mapped_column(
|
||||
JSON, default=dict, nullable=False
|
||||
)
|
||||
institutional_context: Mapped[dict[str, Any]] = mapped_column(
|
||||
JSON, default=dict, nullable=False
|
||||
)
|
||||
search_text: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
valid_from: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
valid_to: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
recorded_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, index=True
|
||||
)
|
||||
superseded_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True, index=True
|
||||
)
|
||||
changed_by: Mapped[str | None] = mapped_column(
|
||||
String(255), nullable=True, index=True
|
||||
)
|
||||
snapshot: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
|
||||
|
||||
|
||||
class RecordVolumeRevision(Base, TimestampMixin):
|
||||
__tablename__ = "record_volume_revisions"
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"tenant_id", "volume_id", "revision", name="uq_record_volume_revision"
|
||||
),
|
||||
Index("ix_record_volume_current", "tenant_id", "volume_id", "superseded_at"),
|
||||
Index("ix_record_volume_order", "tenant_id", "record_id", "sequence"),
|
||||
)
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
|
||||
volume_id: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
|
||||
record_id: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
|
||||
revision: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
previous_revision_id: Mapped[str | None] = mapped_column(
|
||||
ForeignKey("record_volume_revisions.id", ondelete="RESTRICT"),
|
||||
nullable=True,
|
||||
index=True,
|
||||
)
|
||||
sequence: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
label: Mapped[str] = mapped_column(String(500), nullable=False)
|
||||
state: Mapped[str] = mapped_column(
|
||||
String(40), default="open", nullable=False, index=True
|
||||
)
|
||||
valid_from: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
valid_to: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
recorded_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, index=True
|
||||
)
|
||||
superseded_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True, index=True
|
||||
)
|
||||
changed_by: Mapped[str | None] = mapped_column(
|
||||
String(255), nullable=True, index=True
|
||||
)
|
||||
|
||||
|
||||
class RecordItem(Base, TimestampMixin):
|
||||
__tablename__ = "record_items"
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"tenant_id", "idempotency_key", name="uq_record_item_idempotency"
|
||||
),
|
||||
UniqueConstraint(
|
||||
"tenant_id", "record_id", "sequence", name="uq_record_item_sequence"
|
||||
),
|
||||
Index(
|
||||
"ix_record_item_source",
|
||||
"tenant_id",
|
||||
"source_module",
|
||||
"resource_type",
|
||||
"resource_id",
|
||||
),
|
||||
Index("ix_record_item_record", "tenant_id", "record_id", "sequence"),
|
||||
)
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
|
||||
record_id: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
|
||||
volume_id: Mapped[str | None] = mapped_column(
|
||||
String(255), nullable=True, index=True
|
||||
)
|
||||
sequence: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
source_module: Mapped[str] = mapped_column(String(100), nullable=False, index=True)
|
||||
resource_type: Mapped[str] = mapped_column(String(120), nullable=False, index=True)
|
||||
resource_id: Mapped[str] = mapped_column(String(500), nullable=False, index=True)
|
||||
source_revision: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
label: Mapped[str] = mapped_column(String(500), nullable=False)
|
||||
relationship: Mapped[str] = mapped_column(String(120), nullable=False)
|
||||
filing_reason: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
purpose: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
authority_mode: Mapped[str] = mapped_column(String(40), nullable=False)
|
||||
content_sha256: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
content_type: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
size_bytes: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
|
||||
source_valid_from: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
source_valid_to: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
source_recorded_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
launch_url: Mapped[str | None] = mapped_column(String(1500), nullable=True)
|
||||
filed_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, index=True
|
||||
)
|
||||
filed_by: Mapped[str | None] = mapped_column(String(255), nullable=True, index=True)
|
||||
actor_assignment_id: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
actor_delegation_id: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
institutional_context: Mapped[dict[str, Any]] = mapped_column(
|
||||
JSON, default=dict, nullable=False
|
||||
)
|
||||
source_metadata: Mapped[dict[str, Any]] = mapped_column(
|
||||
JSON, default=dict, nullable=False
|
||||
)
|
||||
filing_metadata: Mapped[dict[str, Any]] = mapped_column(
|
||||
JSON, default=dict, nullable=False
|
||||
)
|
||||
supersedes_item_id: Mapped[str | None] = mapped_column(
|
||||
String(36), nullable=True, index=True
|
||||
)
|
||||
idempotency_key: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
request_sha256: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
|
||||
|
||||
class RecordChronologyEntry(Base, TimestampMixin):
|
||||
__tablename__ = "record_chronology_entries"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("tenant_id", "event_id", name="uq_record_chronology_event"),
|
||||
UniqueConstraint(
|
||||
"tenant_id", "idempotency_key", name="uq_record_chronology_idempotency"
|
||||
),
|
||||
Index("ix_record_chronology_record", "tenant_id", "record_id", "occurred_at"),
|
||||
)
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
|
||||
record_id: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
|
||||
event_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
|
||||
event_type: Mapped[str] = mapped_column(String(120), nullable=False, index=True)
|
||||
record_revision: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
summary: Mapped[str] = mapped_column(String(500), nullable=False)
|
||||
occurred_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, index=True
|
||||
)
|
||||
actor_id: Mapped[str | None] = mapped_column(String(255), nullable=True, index=True)
|
||||
actor_assignment_id: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
actor_delegation_id: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
purpose: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
idempotency_key: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
request_sha256: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
institutional_context: Mapped[dict[str, Any]] = mapped_column(
|
||||
JSON, default=dict, nullable=False
|
||||
)
|
||||
payload: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"RecordChronologyEntry",
|
||||
"RecordClassRevision",
|
||||
"RecordFilePlanRevision",
|
||||
"RecordIdentity",
|
||||
"RecordItem",
|
||||
"RecordRevision",
|
||||
"RecordVolumeRevision",
|
||||
]
|
||||
@@ -1,8 +1,42 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from govoplan_core.core.access import CAPABILITY_AUTH_PERMISSION_EVALUATOR, CAPABILITY_AUTH_PRINCIPAL_RESOLVER
|
||||
from govoplan_core.core.modules import DocumentationLink, DocumentationTopic, ModuleManifest, PermissionDefinition, RoleTemplate
|
||||
from pathlib import Path
|
||||
|
||||
from govoplan_core.core.access import (
|
||||
CAPABILITY_AUTH_PERMISSION_EVALUATOR,
|
||||
CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
|
||||
)
|
||||
from govoplan_core.core.information_governance import (
|
||||
InformationGovernanceDimension,
|
||||
ModuleInformationGovernance,
|
||||
)
|
||||
from govoplan_core.core.module_guards import (
|
||||
drop_table_retirement_provider,
|
||||
persistent_table_uninstall_guard,
|
||||
)
|
||||
from govoplan_core.core.modules import (
|
||||
CapabilityDocumentation,
|
||||
DocumentationLink,
|
||||
DocumentationTopic,
|
||||
FrontendModule,
|
||||
FrontendRoute,
|
||||
MigrationSpec,
|
||||
ModuleContext,
|
||||
ModuleInterfaceProvider,
|
||||
ModuleManifest,
|
||||
NavItem,
|
||||
PermissionDefinition,
|
||||
RoleTemplate,
|
||||
)
|
||||
from govoplan_core.core.provider_governance import declared_module_architecture
|
||||
from govoplan_core.core.records import CAPABILITY_RECORDS_FILING
|
||||
from govoplan_core.core.search import SearchSourceProviderRegistration
|
||||
from govoplan_core.core.views import ViewSurface
|
||||
from govoplan_core.db.base import Base
|
||||
from govoplan_records.backend.db import models as record_models
|
||||
from govoplan_records.backend.search_source import create_records_search_source
|
||||
from govoplan_records.backend.service import SqlRecordRegistry
|
||||
|
||||
|
||||
MODULE_ID = "records"
|
||||
MODULE_NAME = "Records"
|
||||
@@ -12,11 +46,18 @@ WRITE_SCOPE = "records:workspace:write"
|
||||
ADMIN_SCOPE = "records:workspace:admin"
|
||||
OPTIONAL_DEPENDENCIES = (
|
||||
"files",
|
||||
"cases",
|
||||
"forms_runtime",
|
||||
"decisions",
|
||||
"campaigns",
|
||||
"postbox",
|
||||
"reporting",
|
||||
"dms",
|
||||
"docs",
|
||||
"policy",
|
||||
"audit",
|
||||
"transparency",
|
||||
"search",
|
||||
)
|
||||
|
||||
|
||||
@@ -34,108 +75,430 @@ def _permission(scope: str, label: str, description: str) -> PermissionDefinitio
|
||||
)
|
||||
|
||||
|
||||
def _router(context: ModuleContext):
|
||||
from govoplan_records.backend.router import create_router
|
||||
|
||||
return create_router(context.registry)
|
||||
|
||||
|
||||
def _records_registry(context: ModuleContext) -> SqlRecordRegistry:
|
||||
return SqlRecordRegistry(context.registry)
|
||||
|
||||
|
||||
def _tenant_summary(session, tenant_id: str) -> dict[str, int]:
|
||||
records = (
|
||||
session.query(record_models.RecordIdentity)
|
||||
.filter(record_models.RecordIdentity.tenant_id == tenant_id)
|
||||
.count()
|
||||
)
|
||||
open_records = (
|
||||
session.query(record_models.RecordRevision)
|
||||
.filter(
|
||||
record_models.RecordRevision.tenant_id == tenant_id,
|
||||
record_models.RecordRevision.superseded_at.is_(None),
|
||||
record_models.RecordRevision.state == "open",
|
||||
)
|
||||
.count()
|
||||
)
|
||||
return {"records": records, "open_records": open_records}
|
||||
|
||||
|
||||
PERMISSIONS = (
|
||||
_permission(READ_SCOPE, "View records workspace", "Read records, configuration, and workflow context."),
|
||||
_permission(WRITE_SCOPE, "Manage records workspace", "Create and update records and workflow state."),
|
||||
_permission(ADMIN_SCOPE, "Administer records workspace", "Configure records policies, templates, and tenant-level administration."),
|
||||
_permission(
|
||||
READ_SCOPE,
|
||||
"View records workspace",
|
||||
"Read currently authorized records, contents, chronology, and file-plan context.",
|
||||
),
|
||||
_permission(
|
||||
WRITE_SCOPE,
|
||||
"Manage records workspace",
|
||||
"Create and revise records, create volumes, and file exact source revisions.",
|
||||
),
|
||||
_permission(
|
||||
ADMIN_SCOPE,
|
||||
"Administer records workspace",
|
||||
"Version file-plan nodes and record classes and administer Records configuration.",
|
||||
),
|
||||
)
|
||||
|
||||
ROLE_TEMPLATES = (
|
||||
RoleTemplate(
|
||||
slug="records_manager",
|
||||
name="Records manager",
|
||||
description="Manage records and workflow state.",
|
||||
description="Create, revise, structure, and file content into records.",
|
||||
permissions=(READ_SCOPE, WRITE_SCOPE),
|
||||
),
|
||||
RoleTemplate(
|
||||
slug="records_viewer",
|
||||
name="Records viewer",
|
||||
description="Read records and workflow context.",
|
||||
description="Read records and their governed chronology.",
|
||||
permissions=(READ_SCOPE,),
|
||||
),
|
||||
RoleTemplate(
|
||||
slug="records_administrator",
|
||||
name="Records administrator",
|
||||
description="Configure file plans and record classes and manage records.",
|
||||
permissions=(READ_SCOPE, WRITE_SCOPE, ADMIN_SCOPE),
|
||||
),
|
||||
)
|
||||
|
||||
DOCUMENTATION = (
|
||||
DocumentationTopic(
|
||||
id=f"{MODULE_ID}.module-boundary",
|
||||
title=f"{MODULE_NAME} module boundary",
|
||||
summary="Records management for file plans, records classification, retention schedules, disposal holds, and archive handoff.",
|
||||
id="records.workspace",
|
||||
title="eAkte workspace",
|
||||
summary="Create and browse institutional records, their exact filed items, and chronology.",
|
||||
body=(
|
||||
"This repository is currently a platform module seed. It registers the domain boundary, "
|
||||
"permission surface, role templates, and documentation metadata before runtime APIs, "
|
||||
"database models, migrations, and WebUI routes are introduced."
|
||||
"Records owns the stable record identity, file-plan classification, immutable revisions, "
|
||||
"volumes, filing decisions, and chronology. Files and other source modules continue to own "
|
||||
"their content. Filing resolves and preserves an exact source revision only after the source "
|
||||
"module confirms current access. The titlebar temporal selection changes valid and recorded "
|
||||
"time while current authorization always remains in force."
|
||||
),
|
||||
layer="available",
|
||||
layer="configured",
|
||||
documentation_types=("admin", "user"),
|
||||
audience=("user", "operator", "module_admin", "product_owner"),
|
||||
audience=("user", "records_manager", "operator", "module_admin", "auditor"),
|
||||
order=100,
|
||||
related_modules=OPTIONAL_DEPENDENCIES,
|
||||
links=(
|
||||
DocumentationLink(
|
||||
label="Repository domain boundary",
|
||||
href="govoplan-records/docs/RECORDS_DOMAIN_BOUNDARY.md",
|
||||
kind="repository",
|
||||
),
|
||||
DocumentationLink(
|
||||
label="eAkte architecture",
|
||||
href="govoplan-records/docs/EAKTE_ARCHITECTURE.md",
|
||||
kind="repository",
|
||||
),
|
||||
),
|
||||
translations={
|
||||
"de": {
|
||||
"title": "eAkte-Arbeitsbereich",
|
||||
"summary": "Institutionelle Akten, exakt veraktete Objekte und die Chronologie anlegen und einsehen.",
|
||||
"body": (
|
||||
"Records verwaltet die stabile Aktenidentität, Aktenplanklassifikation, unveränderliche "
|
||||
"Revisionen, Bände, Veraktungsentscheidungen und die Chronologie. Dateien und andere "
|
||||
"Quellmodule bleiben Eigentümer ihrer Inhalte. Bei der Veraktung wird erst nach aktueller "
|
||||
"Zugriffsprüfung durch das Quellmodul eine exakte Quellrevision festgehalten. Die temporale "
|
||||
"Auswahl in der Titelleiste ändert Gültigkeits- und Erfassungszeit; die aktuelle Berechtigung "
|
||||
"gilt stets weiter."
|
||||
),
|
||||
}
|
||||
},
|
||||
metadata={
|
||||
"seed": True,
|
||||
"domain_objects": ['file plans', 'records classification', 'retention schedule application', 'disposal holds', 'archive handoff state', 'legal record identity'],
|
||||
"first_slice": "Define record class, file plan node, retention schedule, disposal hold, archive transfer, and source document links.",
|
||||
"help_contexts": [
|
||||
"records.workspace",
|
||||
"records.file-plan",
|
||||
"records.record-list",
|
||||
"records.record-detail",
|
||||
"records.record-items",
|
||||
"records.chronology",
|
||||
"records.action.create",
|
||||
"records.action.edit",
|
||||
"records.field.record-number",
|
||||
"records.field.state",
|
||||
"records.field.title",
|
||||
"records.field.class",
|
||||
"records.field.classification",
|
||||
"records.field.description",
|
||||
"records.field.change-reason",
|
||||
],
|
||||
},
|
||||
),
|
||||
DocumentationTopic(
|
||||
id=f"{MODULE_ID}.eakte-architecture",
|
||||
title="eAkte and digital record lifecycle",
|
||||
summary="Defines native and external record operation, filing, temporal and purpose-aware access, retention, holds, appraisal, transfer, and disposition.",
|
||||
id="records.filing",
|
||||
title="Exact record filing",
|
||||
summary="File immutable Files or Cases revisions through a provider-neutral capability.",
|
||||
body=(
|
||||
"Records owns the legal and institutional record identity, file plan, filing decisions, "
|
||||
"retention and disposition lifecycle, and transfer evidence. Files owns bytes, DMS owns "
|
||||
"document editing, Policy owns reusable rules, and external archives remain supported "
|
||||
"through explicit source-authority and provider profiles."
|
||||
"Every filing requires a record, purpose, filing reason, idempotency key, and exact source "
|
||||
"revision. Records stores source identity, authority mode, digest and content metadata where "
|
||||
"available, represented valid time, source recorded time, filing actor and capacity, and an "
|
||||
"immutable chronology entry. A repeated idempotency key replays only the identical request."
|
||||
),
|
||||
layer="available",
|
||||
layer="configured",
|
||||
documentation_types=("admin", "user"),
|
||||
audience=("user", "records_manager", "operator", "module_admin", "product_owner"),
|
||||
audience=("user", "records_manager", "operator", "module_admin", "auditor"),
|
||||
order=110,
|
||||
related_modules=OPTIONAL_DEPENDENCIES,
|
||||
related_modules=("files", "cases", "policy", "audit"),
|
||||
links=(
|
||||
DocumentationLink(
|
||||
label="eAkte architecture",
|
||||
href="govoplan-records/docs/EAKTE_ARCHITECTURE.md",
|
||||
label="Records domain boundary",
|
||||
href="govoplan-records/docs/RECORDS_DOMAIN_BOUNDARY.md",
|
||||
kind="repository",
|
||||
),
|
||||
),
|
||||
translations={
|
||||
"de": {
|
||||
"title": "Exakte Veraktung",
|
||||
"summary": "Unveränderliche Datei- oder Vorgangsrevisionen über eine anbieterneutrale Schnittstelle verakten.",
|
||||
"body": (
|
||||
"Jede Veraktung benötigt eine Akte, einen Zweck, eine Veraktungsbegründung, einen "
|
||||
"Idempotenzschlüssel und eine exakte Quellrevision. Records speichert Quellidentität, "
|
||||
"Autoritätsmodus, soweit verfügbar Prüfsumme und Inhaltsmetadaten, Gültigkeits- und "
|
||||
"Erfassungszeit der Quelle, handelnde Person und Funktion sowie einen unveränderlichen "
|
||||
"Chronologieeintrag. Ein wiederholter Idempotenzschlüssel gibt nur dieselbe Anfrage erneut aus."
|
||||
),
|
||||
}
|
||||
},
|
||||
metadata={
|
||||
"kind": "concept",
|
||||
"help_contexts": ["records.page", "records.record", "records.disposition"],
|
||||
"known_limit": "The architecture is accepted, but persistence and user-visible record workflows remain a scaffold.",
|
||||
"help_contexts": [
|
||||
"records.action.file",
|
||||
"records.field.source-module",
|
||||
"records.field.source-object",
|
||||
"records.field.source-revision",
|
||||
"records.field.purpose",
|
||||
"records.field.filing-reason",
|
||||
],
|
||||
},
|
||||
),
|
||||
DocumentationTopic(
|
||||
id="records.lifecycle-limitations",
|
||||
title="Records lifecycle limitations",
|
||||
summary="Identifies lifecycle controls intentionally deferred beyond the native kernel.",
|
||||
body=(
|
||||
"The current vertical supports planned and open records. Restricted object grants, closure, "
|
||||
"retention calculation, holds, appraisal, disposition, transfer, destruction, and external "
|
||||
"archive effects are separate governed work packages. No destructive effect is implied by "
|
||||
"enabling Records."
|
||||
),
|
||||
layer="configured",
|
||||
documentation_types=("admin", "user"),
|
||||
audience=("user", "records_manager", "operator", "module_admin", "auditor"),
|
||||
order=120,
|
||||
related_modules=("policy", "approvals", "audit", "dms"),
|
||||
translations={
|
||||
"de": {
|
||||
"title": "Grenzen des Aktenlebenszyklus",
|
||||
"summary": "Kennzeichnet bewusst nach dem nativen Kern umzusetzende Lebenszyklussteuerungen.",
|
||||
"body": (
|
||||
"Der aktuelle Stand unterstützt geplante und offene Akten. Objektbezogene Freigaben, "
|
||||
"Abschluss, Aufbewahrungsberechnung, Sperren, Bewertung, Aussonderung, Übergabe, Vernichtung "
|
||||
"und externe Archiveffekte sind getrennte gesteuerte Arbeitspakete. Die Aktivierung von "
|
||||
"Records löst keine vernichtende Wirkung aus."
|
||||
),
|
||||
}
|
||||
},
|
||||
metadata={"known_limit": True},
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
manifest = ModuleManifest(
|
||||
id=MODULE_ID,
|
||||
name=MODULE_NAME,
|
||||
version=MODULE_VERSION,
|
||||
dependencies=("access",),
|
||||
optional_dependencies=OPTIONAL_DEPENDENCIES,
|
||||
required_capabilities=(CAPABILITY_AUTH_PRINCIPAL_RESOLVER, CAPABILITY_AUTH_PERMISSION_EVALUATOR),
|
||||
required_capabilities=(
|
||||
CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
|
||||
CAPABILITY_AUTH_PERMISSION_EVALUATOR,
|
||||
),
|
||||
permissions=PERMISSIONS,
|
||||
role_templates=ROLE_TEMPLATES,
|
||||
route_factory=_router,
|
||||
nav_items=(
|
||||
NavItem(
|
||||
path="/records",
|
||||
label="Records",
|
||||
icon="archive",
|
||||
required_any=(READ_SCOPE,),
|
||||
order=47,
|
||||
surface_id="records.navigation",
|
||||
),
|
||||
),
|
||||
frontend=FrontendModule(
|
||||
module_id=MODULE_ID,
|
||||
package_name="@govoplan/records-webui",
|
||||
routes=(
|
||||
FrontendRoute(
|
||||
path="/records",
|
||||
component="RecordsPage",
|
||||
required_any=(READ_SCOPE,),
|
||||
order=47,
|
||||
surface_id="records.workspace",
|
||||
),
|
||||
),
|
||||
nav_items=(
|
||||
NavItem(
|
||||
path="/records",
|
||||
label="Records",
|
||||
icon="archive",
|
||||
required_any=(READ_SCOPE,),
|
||||
order=47,
|
||||
surface_id="records.navigation",
|
||||
),
|
||||
),
|
||||
view_surfaces=(
|
||||
ViewSurface(
|
||||
id="records.workspace.file-plan",
|
||||
module_id=MODULE_ID,
|
||||
kind="section",
|
||||
label="File plan",
|
||||
parent_id="records.workspace",
|
||||
order=10,
|
||||
),
|
||||
ViewSurface(
|
||||
id="records.workspace.list",
|
||||
module_id=MODULE_ID,
|
||||
kind="section",
|
||||
label="Record list",
|
||||
parent_id="records.workspace",
|
||||
order=20,
|
||||
),
|
||||
ViewSurface(
|
||||
id="records.workspace.detail",
|
||||
module_id=MODULE_ID,
|
||||
kind="section",
|
||||
label="Record detail",
|
||||
parent_id="records.workspace",
|
||||
order=30,
|
||||
),
|
||||
ViewSurface(
|
||||
id="records.workspace.file",
|
||||
module_id=MODULE_ID,
|
||||
kind="action",
|
||||
label="File source revision",
|
||||
parent_id="records.workspace.detail",
|
||||
order=40,
|
||||
),
|
||||
),
|
||||
),
|
||||
provides_interfaces=(
|
||||
ModuleInterfaceProvider(name="records.registry", version="1.0.0"),
|
||||
ModuleInterfaceProvider(name="records.filing", version="1.0.0"),
|
||||
),
|
||||
capability_factories={CAPABILITY_RECORDS_FILING: _records_registry},
|
||||
capability_documentation={
|
||||
CAPABILITY_RECORDS_FILING: CapabilityDocumentation(
|
||||
label="Record filing",
|
||||
summary="Resolves authorized exact source revisions and files immutable record items.",
|
||||
contract_version="1.0.0",
|
||||
),
|
||||
},
|
||||
migration_spec=MigrationSpec(
|
||||
module_id=MODULE_ID,
|
||||
metadata=Base.metadata,
|
||||
script_location=str(Path(__file__).with_name("migrations") / "versions"),
|
||||
retirement_supported=True,
|
||||
retirement_provider=drop_table_retirement_provider(
|
||||
record_models.RecordChronologyEntry,
|
||||
record_models.RecordItem,
|
||||
record_models.RecordVolumeRevision,
|
||||
record_models.RecordRevision,
|
||||
record_models.RecordIdentity,
|
||||
record_models.RecordClassRevision,
|
||||
record_models.RecordFilePlanRevision,
|
||||
label="Records",
|
||||
),
|
||||
retirement_notes=(
|
||||
"Destructive retirement requires a database snapshot and removes record identities, "
|
||||
"file plans, exact filing references, and chronology. Source content remains provider-owned."
|
||||
),
|
||||
),
|
||||
uninstall_guard_providers=(
|
||||
persistent_table_uninstall_guard(
|
||||
record_models.RecordIdentity,
|
||||
record_models.RecordRevision,
|
||||
record_models.RecordItem,
|
||||
record_models.RecordChronologyEntry,
|
||||
record_models.RecordClassRevision,
|
||||
record_models.RecordFilePlanRevision,
|
||||
label="Records",
|
||||
),
|
||||
),
|
||||
tenant_summary_providers=(_tenant_summary,),
|
||||
search_sources=(
|
||||
SearchSourceProviderRegistration(
|
||||
id="records.objects", factory=create_records_search_source
|
||||
),
|
||||
),
|
||||
documentation=DOCUMENTATION,
|
||||
information_governance=ModuleInformationGovernance(
|
||||
temporal_browsing=InformationGovernanceDimension(
|
||||
adoption="enforced",
|
||||
object_types=(
|
||||
"record",
|
||||
"record_volume",
|
||||
"record_item",
|
||||
"record_class",
|
||||
"file_plan_node",
|
||||
),
|
||||
evidence=(
|
||||
"src/govoplan_records/backend/service.py",
|
||||
"tests/test_records.py",
|
||||
),
|
||||
),
|
||||
purpose_aware_access=InformationGovernanceDimension(
|
||||
adoption="partial",
|
||||
object_types=("record", "record_item"),
|
||||
evidence=("src/govoplan_records/backend/service.py",),
|
||||
limitation=(
|
||||
"Purposes are mandatory and preserved for record operations, but Policy-backed "
|
||||
"object-level purpose constraints and restricted-record grants are not implemented yet."
|
||||
),
|
||||
),
|
||||
retention=InformationGovernanceDimension(
|
||||
adoption="contract_only",
|
||||
limitation=(
|
||||
"Record classes preserve retention inputs; closure, holds, calculation, appraisal, "
|
||||
"and disposition are tracked in Records #5."
|
||||
),
|
||||
),
|
||||
institutional_context=InformationGovernanceDimension(
|
||||
adoption="enforced",
|
||||
object_types=(
|
||||
"record",
|
||||
"record_item",
|
||||
"record_event",
|
||||
"record_class",
|
||||
"file_plan_node",
|
||||
),
|
||||
evidence=(
|
||||
"src/govoplan_records/backend/db/models.py",
|
||||
"src/govoplan_records/backend/service.py",
|
||||
"tests/test_records.py",
|
||||
),
|
||||
),
|
||||
),
|
||||
architecture=declared_module_architecture(
|
||||
layer="content_records_evidence",
|
||||
kind="domain",
|
||||
maturity="scaffold",
|
||||
documentation_ref="docs/RECORDS_DOMAIN_BOUNDARY.md",
|
||||
known_limits=("Record declaration, retention, hold, transfer, and disposal are not implemented yet.",),
|
||||
owned_concepts=("record", "record classification", "disposition"),
|
||||
non_owned_concepts=("file content", "audit event", "domain object"),
|
||||
maturity="vertical_slice",
|
||||
documentation_ref="docs/EAKTE_ARCHITECTURE.md",
|
||||
test_ref="tests/test_records.py",
|
||||
known_limits=(
|
||||
"Restricted object grants and lifecycle stages after open are tracked separately.",
|
||||
"Archive transfer and destructive effects are not part of the native kernel.",
|
||||
),
|
||||
supported_authority_modes=(
|
||||
"native_authoritative",
|
||||
"external_authoritative",
|
||||
"external_mirror",
|
||||
"governed_sync",
|
||||
"governance_overlay",
|
||||
"linked_reference",
|
||||
),
|
||||
owned_concepts=(
|
||||
"record",
|
||||
"record class",
|
||||
"file plan",
|
||||
"record volume",
|
||||
"record item",
|
||||
"filing decision",
|
||||
"record chronology",
|
||||
),
|
||||
non_owned_concepts=(
|
||||
"file content",
|
||||
"source object",
|
||||
"case lifecycle",
|
||||
"workflow execution",
|
||||
"generic policy",
|
||||
"audit event",
|
||||
"archive preservation provider",
|
||||
),
|
||||
reference_packages=(
|
||||
"product.service-to-decision",
|
||||
"product.monthly-data-operations",
|
||||
),
|
||||
migration_docs=("docs/EAKTE_ARCHITECTURE.md",),
|
||||
recovery_docs=("docs/EAKTE_ARCHITECTURE.md",),
|
||||
security_docs=("docs/EAKTE_ARCHITECTURE.md",),
|
||||
operations_docs=("docs/EAKTE_ARCHITECTURE.md",),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""Records Alembic migrations."""
|
||||
+401
@@ -0,0 +1,401 @@
|
||||
"""v0.1.18 Records kernel.
|
||||
|
||||
Revision ID: 6e4a2c8f1d9b
|
||||
Revises: None
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "6e4a2c8f1d9b"
|
||||
down_revision = None
|
||||
branch_labels = None
|
||||
depends_on = "4f2a9c8e7b6d"
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"record_file_plan_revisions",
|
||||
sa.Column("id", sa.String(length=36), nullable=False),
|
||||
sa.Column("tenant_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("node_id", sa.String(length=255), nullable=False),
|
||||
sa.Column("revision", sa.Integer(), nullable=False),
|
||||
sa.Column("previous_revision_id", sa.String(length=36), nullable=True),
|
||||
sa.Column("parent_node_id", sa.String(length=255), nullable=True),
|
||||
sa.Column("code", sa.String(length=120), nullable=False),
|
||||
sa.Column("label", sa.String(length=500), nullable=False),
|
||||
sa.Column("description", sa.Text(), nullable=True),
|
||||
sa.Column("active", sa.Boolean(), nullable=False),
|
||||
sa.Column("valid_from", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("valid_to", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("recorded_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("superseded_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("institutional_context", sa.JSON(), nullable=False),
|
||||
sa.Column("changed_by", sa.String(length=255), nullable=True),
|
||||
sa.Column("idempotency_key", sa.String(length=255), nullable=False),
|
||||
sa.Column("request_sha256", sa.String(length=64), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.ForeignKeyConstraint(
|
||||
["previous_revision_id"],
|
||||
["record_file_plan_revisions.id"],
|
||||
ondelete="RESTRICT",
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint(
|
||||
"tenant_id", "idempotency_key", name="uq_record_file_plan_idempotency"
|
||||
),
|
||||
sa.UniqueConstraint(
|
||||
"tenant_id", "node_id", "revision", name="uq_record_file_plan_revision"
|
||||
),
|
||||
)
|
||||
_indexes(
|
||||
"record_file_plan_revisions",
|
||||
"tenant_id",
|
||||
"node_id",
|
||||
"previous_revision_id",
|
||||
"parent_node_id",
|
||||
"code",
|
||||
"active",
|
||||
"recorded_at",
|
||||
"superseded_at",
|
||||
"changed_by",
|
||||
)
|
||||
op.create_index(
|
||||
"ix_record_file_plan_current",
|
||||
"record_file_plan_revisions",
|
||||
["tenant_id", "node_id", "superseded_at"],
|
||||
)
|
||||
op.create_index(
|
||||
"ix_record_file_plan_tree",
|
||||
"record_file_plan_revisions",
|
||||
["tenant_id", "parent_node_id", "code"],
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"record_class_revisions",
|
||||
sa.Column("id", sa.String(length=36), nullable=False),
|
||||
sa.Column("tenant_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("class_id", sa.String(length=255), nullable=False),
|
||||
sa.Column("revision", sa.Integer(), nullable=False),
|
||||
sa.Column("previous_revision_id", sa.String(length=36), nullable=True),
|
||||
sa.Column("file_plan_node_id", sa.String(length=255), nullable=False),
|
||||
sa.Column("key", sa.String(length=120), nullable=False),
|
||||
sa.Column("label", sa.String(length=500), nullable=False),
|
||||
sa.Column("description", sa.Text(), nullable=True),
|
||||
sa.Column("metadata_requirements", sa.JSON(), nullable=False),
|
||||
sa.Column("allowed_source_types", sa.JSON(), nullable=False),
|
||||
sa.Column("retention_period_days", sa.Integer(), nullable=True),
|
||||
sa.Column("closure_trigger", sa.String(length=255), nullable=True),
|
||||
sa.Column("access_mode", sa.String(length=30), nullable=False),
|
||||
sa.Column("active", sa.Boolean(), nullable=False),
|
||||
sa.Column("valid_from", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("valid_to", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("recorded_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("superseded_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("institutional_context", sa.JSON(), nullable=False),
|
||||
sa.Column("changed_by", sa.String(length=255), nullable=True),
|
||||
sa.Column("idempotency_key", sa.String(length=255), nullable=False),
|
||||
sa.Column("request_sha256", sa.String(length=64), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.ForeignKeyConstraint(
|
||||
["previous_revision_id"], ["record_class_revisions.id"], ondelete="RESTRICT"
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint(
|
||||
"tenant_id", "class_id", "revision", name="uq_record_class_revision"
|
||||
),
|
||||
sa.UniqueConstraint(
|
||||
"tenant_id", "idempotency_key", name="uq_record_class_idempotency"
|
||||
),
|
||||
)
|
||||
_indexes(
|
||||
"record_class_revisions",
|
||||
"tenant_id",
|
||||
"class_id",
|
||||
"previous_revision_id",
|
||||
"file_plan_node_id",
|
||||
"key",
|
||||
"active",
|
||||
"recorded_at",
|
||||
"superseded_at",
|
||||
"changed_by",
|
||||
)
|
||||
op.create_index(
|
||||
"ix_record_class_current",
|
||||
"record_class_revisions",
|
||||
["tenant_id", "class_id", "superseded_at"],
|
||||
)
|
||||
op.create_index(
|
||||
"ix_record_class_catalog",
|
||||
"record_class_revisions",
|
||||
["tenant_id", "file_plan_node_id", "active", "label"],
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"record_identities",
|
||||
sa.Column("id", sa.String(length=36), nullable=False),
|
||||
sa.Column("tenant_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("record_id", sa.String(length=255), nullable=False),
|
||||
sa.Column("record_number", sa.String(length=255), nullable=False),
|
||||
sa.Column("created_by", sa.String(length=255), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint(
|
||||
"tenant_id", "record_id", name="uq_record_identity_tenant_id"
|
||||
),
|
||||
sa.UniqueConstraint(
|
||||
"tenant_id", "record_number", name="uq_record_identity_tenant_number"
|
||||
),
|
||||
)
|
||||
_indexes(
|
||||
"record_identities", "tenant_id", "record_id", "record_number", "created_by"
|
||||
)
|
||||
op.create_index(
|
||||
"ix_record_identity_catalog",
|
||||
"record_identities",
|
||||
["tenant_id", "record_number"],
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"record_revisions",
|
||||
sa.Column("id", sa.String(length=36), nullable=False),
|
||||
sa.Column("tenant_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("record_id", sa.String(length=255), nullable=False),
|
||||
sa.Column("identity_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("revision", sa.Integer(), nullable=False),
|
||||
sa.Column("previous_revision_id", sa.String(length=36), nullable=True),
|
||||
sa.Column("class_id", sa.String(length=255), nullable=False),
|
||||
sa.Column("file_plan_node_id", sa.String(length=255), nullable=False),
|
||||
sa.Column("title", sa.String(length=500), nullable=False),
|
||||
sa.Column("description", sa.Text(), nullable=True),
|
||||
sa.Column("state", sa.String(length=40), nullable=False),
|
||||
sa.Column("source_authority_mode", sa.String(length=40), nullable=False),
|
||||
sa.Column("access_mode", sa.String(length=30), nullable=False),
|
||||
sa.Column("purpose", sa.String(length=255), nullable=False),
|
||||
sa.Column("classification", sa.String(length=120), nullable=True),
|
||||
sa.Column("responsible_unit_id", sa.String(length=255), nullable=True),
|
||||
sa.Column("responsible_function_id", sa.String(length=255), nullable=True),
|
||||
sa.Column("external_reference", sa.JSON(), nullable=False),
|
||||
sa.Column("institutional_context", sa.JSON(), nullable=False),
|
||||
sa.Column("search_text", sa.Text(), nullable=False),
|
||||
sa.Column("valid_from", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("valid_to", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("recorded_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("superseded_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("changed_by", sa.String(length=255), nullable=True),
|
||||
sa.Column("snapshot", sa.JSON(), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.ForeignKeyConstraint(
|
||||
["identity_id"], ["record_identities.id"], ondelete="RESTRICT"
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["previous_revision_id"], ["record_revisions.id"], ondelete="RESTRICT"
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint(
|
||||
"tenant_id", "record_id", "revision", name="uq_record_revision"
|
||||
),
|
||||
)
|
||||
_indexes(
|
||||
"record_revisions",
|
||||
"tenant_id",
|
||||
"record_id",
|
||||
"identity_id",
|
||||
"previous_revision_id",
|
||||
"class_id",
|
||||
"file_plan_node_id",
|
||||
"state",
|
||||
"classification",
|
||||
"responsible_unit_id",
|
||||
"responsible_function_id",
|
||||
"recorded_at",
|
||||
"superseded_at",
|
||||
"changed_by",
|
||||
)
|
||||
op.create_index(
|
||||
"ix_record_current",
|
||||
"record_revisions",
|
||||
["tenant_id", "record_id", "superseded_at"],
|
||||
)
|
||||
op.create_index(
|
||||
"ix_record_catalog",
|
||||
"record_revisions",
|
||||
["tenant_id", "state", "class_id", "file_plan_node_id"],
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"record_volume_revisions",
|
||||
sa.Column("id", sa.String(length=36), nullable=False),
|
||||
sa.Column("tenant_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("volume_id", sa.String(length=255), nullable=False),
|
||||
sa.Column("record_id", sa.String(length=255), nullable=False),
|
||||
sa.Column("revision", sa.Integer(), nullable=False),
|
||||
sa.Column("previous_revision_id", sa.String(length=36), nullable=True),
|
||||
sa.Column("sequence", sa.Integer(), nullable=False),
|
||||
sa.Column("label", sa.String(length=500), nullable=False),
|
||||
sa.Column("state", sa.String(length=40), nullable=False),
|
||||
sa.Column("valid_from", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("valid_to", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("recorded_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("superseded_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("changed_by", sa.String(length=255), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.ForeignKeyConstraint(
|
||||
["previous_revision_id"],
|
||||
["record_volume_revisions.id"],
|
||||
ondelete="RESTRICT",
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint(
|
||||
"tenant_id", "volume_id", "revision", name="uq_record_volume_revision"
|
||||
),
|
||||
)
|
||||
_indexes(
|
||||
"record_volume_revisions",
|
||||
"tenant_id",
|
||||
"volume_id",
|
||||
"record_id",
|
||||
"previous_revision_id",
|
||||
"state",
|
||||
"recorded_at",
|
||||
"superseded_at",
|
||||
"changed_by",
|
||||
)
|
||||
op.create_index(
|
||||
"ix_record_volume_current",
|
||||
"record_volume_revisions",
|
||||
["tenant_id", "volume_id", "superseded_at"],
|
||||
)
|
||||
op.create_index(
|
||||
"ix_record_volume_order",
|
||||
"record_volume_revisions",
|
||||
["tenant_id", "record_id", "sequence"],
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"record_items",
|
||||
sa.Column("id", sa.String(length=36), nullable=False),
|
||||
sa.Column("tenant_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("record_id", sa.String(length=255), nullable=False),
|
||||
sa.Column("volume_id", sa.String(length=255), nullable=True),
|
||||
sa.Column("sequence", sa.Integer(), nullable=False),
|
||||
sa.Column("source_module", sa.String(length=100), nullable=False),
|
||||
sa.Column("resource_type", sa.String(length=120), nullable=False),
|
||||
sa.Column("resource_id", sa.String(length=500), nullable=False),
|
||||
sa.Column("source_revision", sa.String(length=255), nullable=False),
|
||||
sa.Column("label", sa.String(length=500), nullable=False),
|
||||
sa.Column("relationship", sa.String(length=120), nullable=False),
|
||||
sa.Column("filing_reason", sa.Text(), nullable=False),
|
||||
sa.Column("purpose", sa.String(length=255), nullable=False),
|
||||
sa.Column("authority_mode", sa.String(length=40), nullable=False),
|
||||
sa.Column("content_sha256", sa.String(length=64), nullable=True),
|
||||
sa.Column("content_type", sa.String(length=255), nullable=True),
|
||||
sa.Column("size_bytes", sa.BigInteger(), nullable=True),
|
||||
sa.Column("source_valid_from", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("source_valid_to", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("source_recorded_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("launch_url", sa.String(length=1500), nullable=True),
|
||||
sa.Column("filed_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("filed_by", sa.String(length=255), nullable=True),
|
||||
sa.Column("actor_assignment_id", sa.String(length=255), nullable=True),
|
||||
sa.Column("actor_delegation_id", sa.String(length=255), nullable=True),
|
||||
sa.Column("institutional_context", sa.JSON(), nullable=False),
|
||||
sa.Column("source_metadata", sa.JSON(), nullable=False),
|
||||
sa.Column("filing_metadata", sa.JSON(), nullable=False),
|
||||
sa.Column("supersedes_item_id", sa.String(length=36), nullable=True),
|
||||
sa.Column("idempotency_key", sa.String(length=255), nullable=False),
|
||||
sa.Column("request_sha256", sa.String(length=64), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint(
|
||||
"tenant_id", "idempotency_key", name="uq_record_item_idempotency"
|
||||
),
|
||||
sa.UniqueConstraint(
|
||||
"tenant_id", "record_id", "sequence", name="uq_record_item_sequence"
|
||||
),
|
||||
)
|
||||
_indexes(
|
||||
"record_items",
|
||||
"tenant_id",
|
||||
"record_id",
|
||||
"volume_id",
|
||||
"source_module",
|
||||
"resource_type",
|
||||
"resource_id",
|
||||
"filed_at",
|
||||
"filed_by",
|
||||
"supersedes_item_id",
|
||||
)
|
||||
op.create_index(
|
||||
"ix_record_item_source",
|
||||
"record_items",
|
||||
["tenant_id", "source_module", "resource_type", "resource_id"],
|
||||
)
|
||||
op.create_index(
|
||||
"ix_record_item_record", "record_items", ["tenant_id", "record_id", "sequence"]
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"record_chronology_entries",
|
||||
sa.Column("id", sa.String(length=36), nullable=False),
|
||||
sa.Column("tenant_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("record_id", sa.String(length=255), nullable=False),
|
||||
sa.Column("event_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("event_type", sa.String(length=120), nullable=False),
|
||||
sa.Column("record_revision", sa.Integer(), nullable=False),
|
||||
sa.Column("summary", sa.String(length=500), nullable=False),
|
||||
sa.Column("occurred_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("actor_id", sa.String(length=255), nullable=True),
|
||||
sa.Column("actor_assignment_id", sa.String(length=255), nullable=True),
|
||||
sa.Column("actor_delegation_id", sa.String(length=255), nullable=True),
|
||||
sa.Column("purpose", sa.String(length=255), nullable=False),
|
||||
sa.Column("idempotency_key", sa.String(length=255), nullable=False),
|
||||
sa.Column("request_sha256", sa.String(length=64), nullable=False),
|
||||
sa.Column("institutional_context", sa.JSON(), nullable=False),
|
||||
sa.Column("payload", sa.JSON(), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint("tenant_id", "event_id", name="uq_record_chronology_event"),
|
||||
sa.UniqueConstraint(
|
||||
"tenant_id", "idempotency_key", name="uq_record_chronology_idempotency"
|
||||
),
|
||||
)
|
||||
_indexes(
|
||||
"record_chronology_entries",
|
||||
"tenant_id",
|
||||
"record_id",
|
||||
"event_id",
|
||||
"event_type",
|
||||
"occurred_at",
|
||||
"actor_id",
|
||||
)
|
||||
op.create_index(
|
||||
"ix_record_chronology_record",
|
||||
"record_chronology_entries",
|
||||
["tenant_id", "record_id", "occurred_at"],
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("record_chronology_entries")
|
||||
op.drop_table("record_items")
|
||||
op.drop_table("record_volume_revisions")
|
||||
op.drop_table("record_revisions")
|
||||
op.drop_table("record_identities")
|
||||
op.drop_table("record_class_revisions")
|
||||
op.drop_table("record_file_plan_revisions")
|
||||
|
||||
|
||||
def _indexes(table: str, *columns: str) -> None:
|
||||
for column in columns:
|
||||
op.create_index(op.f(f"ix_{table}_{column}"), table, [column], unique=False)
|
||||
@@ -0,0 +1 @@
|
||||
"""Records migration revisions."""
|
||||
@@ -0,0 +1,274 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_core.auth import ApiPrincipal, get_api_principal, has_scope
|
||||
from govoplan_core.core.records import RecordFilingRequest, RecordSourceLocator
|
||||
from govoplan_core.db.session import get_session
|
||||
from govoplan_records.backend.manifest import ADMIN_SCOPE, READ_SCOPE, WRITE_SCOPE
|
||||
from govoplan_records.backend.schemas import (
|
||||
FilePlanNodeWriteRequest,
|
||||
RecordCatalogResponse,
|
||||
RecordClassWriteRequest,
|
||||
RecordCreateRequest,
|
||||
RecordDetailResponse,
|
||||
RecordItemCreateRequest,
|
||||
RecordListResponse,
|
||||
RecordSourceProviderResponse,
|
||||
RecordUpdateRequest,
|
||||
RecordVolumeCreateRequest,
|
||||
)
|
||||
from govoplan_records.backend.service import (
|
||||
RecordConflictError,
|
||||
RecordNotFoundError,
|
||||
RecordSourceUnavailableError,
|
||||
RecordStoreError,
|
||||
SqlRecordRegistry,
|
||||
)
|
||||
|
||||
|
||||
def create_router(registry: object | None = None) -> APIRouter:
|
||||
router = APIRouter(prefix="/records", tags=["records"])
|
||||
records = SqlRecordRegistry(registry)
|
||||
|
||||
@router.get("/catalog", response_model=RecordCatalogResponse)
|
||||
def api_catalog(
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
) -> RecordCatalogResponse:
|
||||
_require(principal, READ_SCOPE)
|
||||
return RecordCatalogResponse(**records.catalog(session, principal))
|
||||
|
||||
@router.post(
|
||||
"/catalog/file-plan",
|
||||
response_model=dict[str, Any],
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
def api_write_file_plan_node(
|
||||
payload: FilePlanNodeWriteRequest,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
) -> dict[str, Any]:
|
||||
_require(principal, ADMIN_SCOPE)
|
||||
return _write(
|
||||
session,
|
||||
lambda: records.write_file_plan_node(
|
||||
session, principal, payload=payload.model_dump(mode="python")
|
||||
),
|
||||
)
|
||||
|
||||
@router.post(
|
||||
"/catalog/classes",
|
||||
response_model=dict[str, Any],
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
def api_write_record_class(
|
||||
payload: RecordClassWriteRequest,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
) -> dict[str, Any]:
|
||||
_require(principal, ADMIN_SCOPE)
|
||||
return _write(
|
||||
session,
|
||||
lambda: records.write_record_class(
|
||||
session, principal, payload=payload.model_dump(mode="python")
|
||||
),
|
||||
)
|
||||
|
||||
@router.get("/sources", response_model=RecordSourceProviderResponse)
|
||||
def api_source_providers(
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
) -> RecordSourceProviderResponse:
|
||||
_require(principal, WRITE_SCOPE)
|
||||
return RecordSourceProviderResponse(
|
||||
providers=records.source_providers(session, principal)
|
||||
)
|
||||
|
||||
@router.get("", response_model=RecordListResponse)
|
||||
def api_list_records(
|
||||
query: str | None = Query(default=None, max_length=500),
|
||||
record_state: str | None = Query(default=None, alias="state", max_length=40),
|
||||
class_id: str | None = Query(default=None, max_length=255),
|
||||
file_plan_node_id: str | None = Query(default=None, max_length=255),
|
||||
offset: int = Query(default=0, ge=0),
|
||||
limit: int = Query(default=100, ge=1, le=200),
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
) -> RecordListResponse:
|
||||
_require(principal, READ_SCOPE)
|
||||
items, total = records.list_records(
|
||||
session,
|
||||
principal,
|
||||
query=query,
|
||||
state=record_state,
|
||||
class_id=class_id,
|
||||
file_plan_node_id=file_plan_node_id,
|
||||
offset=offset,
|
||||
limit=limit,
|
||||
)
|
||||
return RecordListResponse(
|
||||
records=items, total=total, offset=offset, limit=limit
|
||||
)
|
||||
|
||||
@router.post("", response_model=dict[str, Any], status_code=status.HTTP_201_CREATED)
|
||||
def api_create_record(
|
||||
payload: RecordCreateRequest,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
) -> dict[str, Any]:
|
||||
_require(principal, WRITE_SCOPE)
|
||||
return _write(
|
||||
session,
|
||||
lambda: records.create_record(
|
||||
session, principal, payload=payload.model_dump(mode="python")
|
||||
),
|
||||
)
|
||||
|
||||
@router.get("/{record_id}", response_model=RecordDetailResponse)
|
||||
def api_get_record(
|
||||
record_id: str,
|
||||
revision: int | None = Query(default=None, ge=1),
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
) -> RecordDetailResponse:
|
||||
_require(principal, READ_SCOPE)
|
||||
try:
|
||||
return RecordDetailResponse(
|
||||
**records.get_record(
|
||||
session, principal, record_id=record_id, revision=revision
|
||||
)
|
||||
)
|
||||
except RecordStoreError as exc:
|
||||
raise _http_error(exc) from exc
|
||||
|
||||
@router.patch("/{record_id}", response_model=dict[str, Any])
|
||||
def api_update_record(
|
||||
record_id: str,
|
||||
payload: RecordUpdateRequest,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
) -> dict[str, Any]:
|
||||
_require(principal, WRITE_SCOPE)
|
||||
return _write(
|
||||
session,
|
||||
lambda: records.update_record(
|
||||
session,
|
||||
principal,
|
||||
record_id=record_id,
|
||||
payload=payload.model_dump(mode="python", exclude_unset=True),
|
||||
),
|
||||
)
|
||||
|
||||
@router.post(
|
||||
"/{record_id}/volumes",
|
||||
response_model=dict[str, Any],
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
def api_create_volume(
|
||||
record_id: str,
|
||||
payload: RecordVolumeCreateRequest,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
) -> dict[str, Any]:
|
||||
_require(principal, WRITE_SCOPE)
|
||||
return _write(
|
||||
session,
|
||||
lambda: records.create_volume(
|
||||
session,
|
||||
principal,
|
||||
record_id=record_id,
|
||||
payload=payload.model_dump(mode="python"),
|
||||
),
|
||||
)
|
||||
|
||||
@router.post(
|
||||
"/{record_id}/items",
|
||||
response_model=dict[str, Any],
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
def api_file_item(
|
||||
record_id: str,
|
||||
payload: RecordItemCreateRequest,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
) -> dict[str, Any]:
|
||||
_require(principal, WRITE_SCOPE)
|
||||
request = RecordFilingRequest(
|
||||
tenant_id=principal.tenant_id,
|
||||
record_id=record_id,
|
||||
source=RecordSourceLocator(
|
||||
tenant_id=principal.tenant_id,
|
||||
source_module=payload.source.source_module,
|
||||
resource_type=payload.source.resource_type,
|
||||
resource_id=payload.source.resource_id,
|
||||
source_revision=payload.source.source_revision,
|
||||
metadata=payload.source.metadata,
|
||||
),
|
||||
purpose=payload.purpose,
|
||||
filing_reason=payload.filing_reason,
|
||||
idempotency_key=payload.idempotency_key,
|
||||
volume_id=payload.volume_id,
|
||||
relationship=payload.relationship,
|
||||
institutional_context=payload.institutional_context,
|
||||
metadata=payload.metadata,
|
||||
)
|
||||
|
||||
def operation() -> dict[str, Any]:
|
||||
result = records.file(session, principal, request=request)
|
||||
return {
|
||||
"record_id": result.record_id,
|
||||
"item_id": result.item_id,
|
||||
"sequence": result.sequence,
|
||||
"filed_at": result.filed_at,
|
||||
"replayed": result.replayed,
|
||||
"source": {
|
||||
"source_module": result.source.locator.source_module,
|
||||
"resource_type": result.source.locator.resource_type,
|
||||
"resource_id": result.source.locator.resource_id,
|
||||
"source_revision": result.source.locator.source_revision,
|
||||
"label": result.source.label,
|
||||
},
|
||||
}
|
||||
|
||||
return _write(session, operation)
|
||||
|
||||
return router
|
||||
|
||||
|
||||
def _require(principal: ApiPrincipal, scope: str) -> None:
|
||||
if not has_scope(principal, scope):
|
||||
raise HTTPException(status_code=403, detail=f"Missing scope: {scope}")
|
||||
|
||||
|
||||
def _write(session: Session, operation):
|
||||
try:
|
||||
result = operation()
|
||||
session.commit()
|
||||
return result
|
||||
except (RecordStoreError, IntegrityError) as exc:
|
||||
session.rollback()
|
||||
if isinstance(exc, IntegrityError):
|
||||
raise HTTPException(
|
||||
status_code=409, detail="The record write conflicts with existing data."
|
||||
) from exc
|
||||
raise _http_error(exc) from exc
|
||||
|
||||
|
||||
def _http_error(exc: RecordStoreError) -> HTTPException:
|
||||
if isinstance(exc, RecordNotFoundError):
|
||||
code = 404
|
||||
elif isinstance(exc, RecordConflictError):
|
||||
code = 409
|
||||
elif isinstance(exc, RecordSourceUnavailableError):
|
||||
code = 503
|
||||
else:
|
||||
code = 422
|
||||
return HTTPException(status_code=code, detail=str(exc))
|
||||
|
||||
|
||||
__all__ = ["create_router"]
|
||||
@@ -0,0 +1,186 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any, Literal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
||||
|
||||
|
||||
class StrictModel(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
class FilePlanNodeWriteRequest(StrictModel):
|
||||
node_id: str = Field(min_length=1, max_length=255)
|
||||
code: str = Field(min_length=1, max_length=120)
|
||||
label: str = Field(min_length=1, max_length=500)
|
||||
parent_node_id: str | None = Field(default=None, max_length=255)
|
||||
description: str | None = Field(default=None, max_length=10_000)
|
||||
active: bool = True
|
||||
valid_from: datetime | None = None
|
||||
valid_to: datetime | None = None
|
||||
recorded_at: datetime
|
||||
expected_revision: int | None = Field(default=None, ge=1)
|
||||
idempotency_key: str = Field(min_length=1, max_length=255)
|
||||
institutional_context: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_interval(self):
|
||||
if self.valid_from and self.valid_to and self.valid_to <= self.valid_from:
|
||||
raise ValueError("valid_to must be after valid_from")
|
||||
return self
|
||||
|
||||
|
||||
class RecordClassWriteRequest(StrictModel):
|
||||
class_id: str = Field(min_length=1, max_length=255)
|
||||
file_plan_node_id: str = Field(min_length=1, max_length=255)
|
||||
key: str = Field(min_length=1, max_length=120)
|
||||
label: str = Field(min_length=1, max_length=500)
|
||||
description: str | None = Field(default=None, max_length=10_000)
|
||||
metadata_requirements: list[str] = Field(default_factory=list, max_length=100)
|
||||
allowed_source_types: list[str] = Field(default_factory=list, max_length=100)
|
||||
retention_period_days: int | None = Field(default=None, ge=0, le=365_000)
|
||||
closure_trigger: str | None = Field(default=None, max_length=255)
|
||||
access_mode: Literal["tenant", "restricted"] = "tenant"
|
||||
active: bool = True
|
||||
valid_from: datetime | None = None
|
||||
valid_to: datetime | None = None
|
||||
recorded_at: datetime
|
||||
expected_revision: int | None = Field(default=None, ge=1)
|
||||
idempotency_key: str = Field(min_length=1, max_length=255)
|
||||
institutional_context: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_interval(self):
|
||||
if self.valid_from and self.valid_to and self.valid_to <= self.valid_from:
|
||||
raise ValueError("valid_to must be after valid_from")
|
||||
return self
|
||||
|
||||
|
||||
class RecordCreateRequest(StrictModel):
|
||||
record_id: str | None = Field(default=None, max_length=255)
|
||||
record_number: str = Field(min_length=1, max_length=255)
|
||||
class_id: str = Field(min_length=1, max_length=255)
|
||||
file_plan_node_id: str = Field(min_length=1, max_length=255)
|
||||
title: str = Field(min_length=1, max_length=500)
|
||||
description: str | None = Field(default=None, max_length=20_000)
|
||||
state: Literal["planned", "open"] = "open"
|
||||
source_authority_mode: Literal[
|
||||
"native_authoritative",
|
||||
"external_authoritative",
|
||||
"external_mirror",
|
||||
"governed_sync",
|
||||
"governance_overlay",
|
||||
"linked_reference",
|
||||
] = "native_authoritative"
|
||||
access_mode: Literal["tenant", "restricted"] = "tenant"
|
||||
purpose: str = Field(min_length=1, max_length=255)
|
||||
classification: str | None = Field(default=None, max_length=120)
|
||||
responsible_unit_id: str | None = Field(default=None, max_length=255)
|
||||
responsible_function_id: str | None = Field(default=None, max_length=255)
|
||||
external_reference: dict[str, Any] = Field(default_factory=dict)
|
||||
institutional_context: dict[str, Any] = Field(default_factory=dict)
|
||||
valid_from: datetime | None = None
|
||||
valid_to: datetime | None = None
|
||||
recorded_at: datetime
|
||||
change_reason: str = Field(min_length=1, max_length=2_000)
|
||||
idempotency_key: str = Field(min_length=1, max_length=255)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_interval(self):
|
||||
if self.valid_from and self.valid_to and self.valid_to <= self.valid_from:
|
||||
raise ValueError("valid_to must be after valid_from")
|
||||
return self
|
||||
|
||||
|
||||
class RecordUpdateRequest(StrictModel):
|
||||
expected_revision: int = Field(ge=1)
|
||||
title: str | None = Field(default=None, min_length=1, max_length=500)
|
||||
description: str | None = Field(default=None, max_length=20_000)
|
||||
class_id: str | None = Field(default=None, min_length=1, max_length=255)
|
||||
file_plan_node_id: str | None = Field(default=None, min_length=1, max_length=255)
|
||||
state: Literal["planned", "open"] | None = None
|
||||
access_mode: Literal["tenant", "restricted"] | None = None
|
||||
purpose: str = Field(min_length=1, max_length=255)
|
||||
classification: str | None = Field(default=None, max_length=120)
|
||||
responsible_unit_id: str | None = Field(default=None, max_length=255)
|
||||
responsible_function_id: str | None = Field(default=None, max_length=255)
|
||||
institutional_context: dict[str, Any] | None = None
|
||||
valid_from: datetime | None = None
|
||||
valid_to: datetime | None = None
|
||||
recorded_at: datetime
|
||||
change_reason: str = Field(min_length=1, max_length=2_000)
|
||||
idempotency_key: str = Field(min_length=1, max_length=255)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_interval(self):
|
||||
if self.valid_from and self.valid_to and self.valid_to <= self.valid_from:
|
||||
raise ValueError("valid_to must be after valid_from")
|
||||
return self
|
||||
|
||||
|
||||
class RecordVolumeCreateRequest(StrictModel):
|
||||
volume_id: str | None = Field(default=None, max_length=255)
|
||||
label: str = Field(min_length=1, max_length=500)
|
||||
valid_from: datetime | None = None
|
||||
valid_to: datetime | None = None
|
||||
recorded_at: datetime
|
||||
purpose: str = Field(min_length=1, max_length=255)
|
||||
idempotency_key: str = Field(min_length=1, max_length=255)
|
||||
|
||||
|
||||
class RecordSourceLocatorRequest(StrictModel):
|
||||
source_module: str = Field(min_length=1, max_length=100)
|
||||
resource_type: str = Field(min_length=1, max_length=120)
|
||||
resource_id: str = Field(min_length=1, max_length=500)
|
||||
source_revision: str = Field(min_length=1, max_length=255)
|
||||
metadata: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class RecordItemCreateRequest(StrictModel):
|
||||
source: RecordSourceLocatorRequest
|
||||
purpose: str = Field(min_length=1, max_length=255)
|
||||
filing_reason: str = Field(min_length=1, max_length=2_000)
|
||||
idempotency_key: str = Field(min_length=1, max_length=255)
|
||||
volume_id: str | None = Field(default=None, max_length=255)
|
||||
relationship: str = Field(default="contains", min_length=1, max_length=120)
|
||||
institutional_context: dict[str, Any] = Field(default_factory=dict)
|
||||
metadata: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class RecordListResponse(StrictModel):
|
||||
records: list[dict[str, Any]]
|
||||
total: int
|
||||
offset: int
|
||||
limit: int
|
||||
|
||||
|
||||
class RecordCatalogResponse(StrictModel):
|
||||
file_plan: list[dict[str, Any]]
|
||||
classes: list[dict[str, Any]]
|
||||
|
||||
|
||||
class RecordDetailResponse(StrictModel):
|
||||
record: dict[str, Any]
|
||||
volumes: list[dict[str, Any]]
|
||||
items: list[dict[str, Any]]
|
||||
chronology: list[dict[str, Any]]
|
||||
access_explanation: dict[str, Any]
|
||||
|
||||
|
||||
class RecordSourceProviderResponse(StrictModel):
|
||||
providers: list[dict[str, Any]]
|
||||
|
||||
|
||||
__all__ = [
|
||||
"FilePlanNodeWriteRequest",
|
||||
"RecordCatalogResponse",
|
||||
"RecordClassWriteRequest",
|
||||
"RecordCreateRequest",
|
||||
"RecordDetailResponse",
|
||||
"RecordItemCreateRequest",
|
||||
"RecordListResponse",
|
||||
"RecordSourceProviderResponse",
|
||||
"RecordUpdateRequest",
|
||||
"RecordVolumeCreateRequest",
|
||||
]
|
||||
@@ -0,0 +1,163 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping, Sequence
|
||||
from urllib.parse import quote
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_core.auth import ApiPrincipal
|
||||
from govoplan_core.core.modules import ModuleContext
|
||||
from govoplan_core.core.search import (
|
||||
SearchAuthorizationRequest,
|
||||
SearchBackfillPage,
|
||||
SearchBackfillRequest,
|
||||
SearchDocument,
|
||||
SearchResourceType,
|
||||
)
|
||||
from govoplan_records.backend.db.models import RecordIdentity, RecordRevision
|
||||
|
||||
|
||||
PROVIDER_ID = "records.objects"
|
||||
RESOURCE_TYPE = "record"
|
||||
READ_SCOPE = "records:workspace:read"
|
||||
ADMIN_SCOPE = "records:workspace:admin"
|
||||
|
||||
|
||||
class RecordsSearchSource:
|
||||
def resource_types(self) -> Sequence[SearchResourceType]:
|
||||
return (
|
||||
SearchResourceType(
|
||||
provider_id=PROVIDER_ID,
|
||||
module_id="records",
|
||||
resource_type=RESOURCE_TYPE,
|
||||
label="Records",
|
||||
requires_authorization_recheck=True,
|
||||
),
|
||||
)
|
||||
|
||||
def backfill(
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
request: SearchBackfillRequest,
|
||||
) -> SearchBackfillPage:
|
||||
if request.provider_id != PROVIDER_ID or request.resource_type != RESOURCE_TYPE:
|
||||
raise ValueError("Unsupported Records search source.")
|
||||
db = _session(session)
|
||||
statement = (
|
||||
select(RecordRevision, RecordIdentity)
|
||||
.join(RecordIdentity, RecordIdentity.id == RecordRevision.identity_id)
|
||||
.where(
|
||||
RecordRevision.tenant_id == request.tenant_id,
|
||||
RecordRevision.superseded_at.is_(None),
|
||||
)
|
||||
)
|
||||
if request.cursor:
|
||||
statement = statement.where(RecordRevision.record_id > request.cursor)
|
||||
rows = list(
|
||||
db.execute(
|
||||
statement.order_by(RecordRevision.record_id).limit(request.limit + 1)
|
||||
).all()
|
||||
)
|
||||
has_more = len(rows) > request.limit
|
||||
selected = rows[: request.limit]
|
||||
high_watermark = db.scalar(
|
||||
select(func.max(RecordRevision.recorded_at)).where(
|
||||
RecordRevision.tenant_id == request.tenant_id,
|
||||
RecordRevision.superseded_at.is_(None),
|
||||
)
|
||||
)
|
||||
return SearchBackfillPage(
|
||||
documents=tuple(_document(row, identity) for row, identity in selected),
|
||||
next_cursor=selected[-1][0].record_id if has_more and selected else None,
|
||||
complete=not has_more,
|
||||
high_watermark=high_watermark.isoformat() if high_watermark else None,
|
||||
)
|
||||
|
||||
def authorize(
|
||||
self,
|
||||
session: object,
|
||||
principal: object,
|
||||
*,
|
||||
requests: Sequence[SearchAuthorizationRequest],
|
||||
) -> Mapping[str, bool]:
|
||||
decisions = {item.reference.key: False for item in requests}
|
||||
if not isinstance(principal, ApiPrincipal) or not (
|
||||
principal.has(READ_SCOPE) or principal.has(ADMIN_SCOPE)
|
||||
):
|
||||
return decisions
|
||||
db = _session(session)
|
||||
eligible = [
|
||||
request
|
||||
for request in requests
|
||||
if request.reference.tenant_id == principal.tenant_id
|
||||
and request.reference.module_id == "records"
|
||||
and request.reference.resource_type == RESOURCE_TYPE
|
||||
]
|
||||
resource_ids = {request.reference.resource_id for request in eligible}
|
||||
available_ids = (
|
||||
set(
|
||||
db.scalars(
|
||||
select(RecordRevision.record_id).where(
|
||||
RecordRevision.tenant_id == principal.tenant_id,
|
||||
RecordRevision.record_id.in_(resource_ids),
|
||||
RecordRevision.superseded_at.is_(None),
|
||||
)
|
||||
).all()
|
||||
)
|
||||
if resource_ids
|
||||
else set()
|
||||
)
|
||||
for request in eligible:
|
||||
reference = request.reference
|
||||
decisions[reference.key] = reference.resource_id in available_ids
|
||||
return decisions
|
||||
|
||||
|
||||
def create_records_search_source(_context: ModuleContext) -> RecordsSearchSource:
|
||||
return RecordsSearchSource()
|
||||
|
||||
|
||||
def _document(row: RecordRevision, identity: RecordIdentity) -> SearchDocument:
|
||||
return SearchDocument(
|
||||
tenant_id=row.tenant_id,
|
||||
module_id="records",
|
||||
provider_id=PROVIDER_ID,
|
||||
resource_type=RESOURCE_TYPE,
|
||||
resource_id=row.record_id,
|
||||
title=row.title,
|
||||
url=f"/records?recordId={quote(row.record_id, safe='')}",
|
||||
summary=(row.description or identity.record_number)[:4000],
|
||||
body=row.search_text[:200_000],
|
||||
keywords=tuple(
|
||||
value[:200]
|
||||
for value in (
|
||||
identity.record_number,
|
||||
row.classification or "",
|
||||
row.state,
|
||||
)
|
||||
if value
|
||||
),
|
||||
visibility="restricted",
|
||||
acl_tokens=(f"scope:{READ_SCOPE}", f"scope:{ADMIN_SCOPE}"),
|
||||
metadata={
|
||||
"record_number": identity.record_number,
|
||||
"class_id": row.class_id,
|
||||
"file_plan_node_id": row.file_plan_node_id,
|
||||
"state": row.state,
|
||||
"classification": row.classification,
|
||||
},
|
||||
source_revision=str(row.revision),
|
||||
source_updated_at=row.recorded_at,
|
||||
requires_authorization_recheck=True,
|
||||
)
|
||||
|
||||
|
||||
def _session(value: object) -> Session:
|
||||
if not isinstance(value, Session):
|
||||
raise TypeError("Records search requires a SQLAlchemy session.")
|
||||
return value
|
||||
|
||||
|
||||
__all__ = ["PROVIDER_ID", "RecordsSearchSource", "create_records_search_source"]
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user