diff --git a/README.md b/README.md index 7a8f383..4dbc2c1 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,9 @@ # GovOPlaN Tenancy -`govoplan-tenancy` owns the live `tenants` table, tenant lifecycle, and tenant -settings API route contributions during the GovOPlaN module split. +`govoplan-tenancy` owns the live `tenancy_tenants` table, tenant lifecycle, and +tenant settings API route contributions during the GovOPlaN module split. `govoplan-access` depends on this module, and core's registry inserts tenancy -before access for authenticated platform composition. The historical `tenants` -table name is intentionally preserved for migration compatibility. +before access for authenticated platform composition. Core migrations rename the +historical `tenants` table to the module-prefixed table name for existing +development databases. diff --git a/src/govoplan_tenancy.egg-info/PKG-INFO b/src/govoplan_tenancy.egg-info/PKG-INFO index 8bfe04a..ed22f4b 100644 --- a/src/govoplan_tenancy.egg-info/PKG-INFO +++ b/src/govoplan_tenancy.egg-info/PKG-INFO @@ -1,17 +1,18 @@ Metadata-Version: 2.4 Name: govoplan-tenancy -Version: 0.1.4 +Version: 0.1.6 Summary: GovOPlaN tenancy platform module. Author: GovOPlaN Requires-Python: >=3.12 Description-Content-Type: text/markdown -Requires-Dist: govoplan-core>=0.1.4 +Requires-Dist: govoplan-core>=0.1.6 # GovOPlaN Tenancy -`govoplan-tenancy` owns the live `tenants` table, tenant lifecycle, and tenant -settings API route contributions during the GovOPlaN module split. +`govoplan-tenancy` owns the live `tenancy_tenants` table, tenant lifecycle, and +tenant settings API route contributions during the GovOPlaN module split. `govoplan-access` depends on this module, and core's registry inserts tenancy -before access for authenticated platform composition. The historical `tenants` -table name is intentionally preserved for migration compatibility. +before access for authenticated platform composition. Core migrations rename the +historical `tenants` table to the module-prefixed table name for existing +development databases. diff --git a/src/govoplan_tenancy.egg-info/requires.txt b/src/govoplan_tenancy.egg-info/requires.txt index 5182e50..2e396b6 100644 --- a/src/govoplan_tenancy.egg-info/requires.txt +++ b/src/govoplan_tenancy.egg-info/requires.txt @@ -1 +1 @@ -govoplan-core>=0.1.4 +govoplan-core>=0.1.6 diff --git a/src/govoplan_tenancy/backend/__pycache__/capabilities.cpython-313.pyc b/src/govoplan_tenancy/backend/__pycache__/capabilities.cpython-313.pyc deleted file mode 100644 index 36a4539..0000000 Binary files a/src/govoplan_tenancy/backend/__pycache__/capabilities.cpython-313.pyc and /dev/null differ diff --git a/src/govoplan_tenancy/backend/__pycache__/manifest.cpython-313.pyc b/src/govoplan_tenancy/backend/__pycache__/manifest.cpython-313.pyc index 97c0deb..deaa380 100644 Binary files a/src/govoplan_tenancy/backend/__pycache__/manifest.cpython-313.pyc and b/src/govoplan_tenancy/backend/__pycache__/manifest.cpython-313.pyc differ diff --git a/src/govoplan_tenancy/backend/api/v1/__pycache__/routes.cpython-312.pyc b/src/govoplan_tenancy/backend/api/v1/__pycache__/routes.cpython-312.pyc index 8b58cf8..68ff7f5 100644 Binary files a/src/govoplan_tenancy/backend/api/v1/__pycache__/routes.cpython-312.pyc and b/src/govoplan_tenancy/backend/api/v1/__pycache__/routes.cpython-312.pyc differ diff --git a/src/govoplan_tenancy/backend/api/v1/__pycache__/routes.cpython-313.pyc b/src/govoplan_tenancy/backend/api/v1/__pycache__/routes.cpython-313.pyc index a546619..e196350 100644 Binary files a/src/govoplan_tenancy/backend/api/v1/__pycache__/routes.cpython-313.pyc and b/src/govoplan_tenancy/backend/api/v1/__pycache__/routes.cpython-313.pyc differ diff --git a/src/govoplan_tenancy/backend/api/v1/__pycache__/schemas.cpython-312.pyc b/src/govoplan_tenancy/backend/api/v1/__pycache__/schemas.cpython-312.pyc index 86adf47..1289f25 100644 Binary files a/src/govoplan_tenancy/backend/api/v1/__pycache__/schemas.cpython-312.pyc and b/src/govoplan_tenancy/backend/api/v1/__pycache__/schemas.cpython-312.pyc differ diff --git a/src/govoplan_tenancy/backend/api/v1/__pycache__/schemas.cpython-313.pyc b/src/govoplan_tenancy/backend/api/v1/__pycache__/schemas.cpython-313.pyc index a757006..284c1b7 100644 Binary files a/src/govoplan_tenancy/backend/api/v1/__pycache__/schemas.cpython-313.pyc and b/src/govoplan_tenancy/backend/api/v1/__pycache__/schemas.cpython-313.pyc differ diff --git a/src/govoplan_tenancy/backend/api/v1/routes.py b/src/govoplan_tenancy/backend/api/v1/routes.py index caafd30..3a3acd3 100644 --- a/src/govoplan_tenancy/backend/api/v1/routes.py +++ b/src/govoplan_tenancy/backend/api/v1/routes.py @@ -1,15 +1,25 @@ from __future__ import annotations -from fastapi import APIRouter, Depends, HTTPException, status +from fastapi import APIRouter, Depends, HTTPException, Query, status +from sqlalchemy import and_, func, or_ from sqlalchemy.orm import Session from govoplan_core.admin.common import AdminValidationError, slugify from govoplan_core.admin.settings import get_system_settings -from govoplan_access.backend.auth.dependencies import ApiPrincipal, get_api_principal, has_scope, require_scope +from govoplan_access.auth import ApiPrincipal, get_api_principal, has_scope, require_scope from govoplan_core.audit.logging import audit_event from govoplan_core.core.access import CAPABILITY_ACCESS_TENANT_PROVISIONER, TenantAccessProvisioner +from govoplan_core.core.change_sequence import ChangeSequenceEntry, decode_sequence_watermark, encode_sequence_watermark, record_change, sequence_watermark_is_expired from govoplan_core.core.runtime import get_registry from govoplan_core.db.session import get_session +from govoplan_core.i18n import ( + i18n_settings, + normalize_enabled_language_codes, + system_enabled_language_codes, + system_i18n_payload, + tenant_enabled_language_codes, + update_i18n_settings, +) from govoplan_core.tenancy.service import ( assert_tenant_governance_override_allowed, effective_tenant_governance, @@ -20,16 +30,27 @@ from govoplan_tenancy.backend.db.models import Tenant from .schemas import ( TenantAdminItem, TenantCreateRequest, + TenantListDeltaResponse, TenantListResponse, TenantOwnerCandidate, TenantOwnerCandidateListResponse, TenantSettingsItem, + TenantSettingsDeltaResponse, TenantSettingsUpdateRequest, TenantUpdateRequest, ) router = APIRouter(prefix="/admin", tags=["admin"]) +TENANCY_MODULE_ID = "tenancy" +TENANT_LIST_COLLECTION = "tenancy.tenants" +TENANT_LIST_RESOURCE = "tenant" +TENANT_SETTINGS_COLLECTION = "tenancy.tenant_settings" +TENANT_SETTINGS_RESOURCE = "tenant_settings_section" +ADMIN_MODULE_ID = "admin" +ADMIN_SYSTEM_SETTINGS_COLLECTION = "admin.system_settings" +TENANT_SETTINGS_SECTIONS = ("identity", "locale", "languages", "settings") + def _tenant_access_provisioner() -> TenantAccessProvisioner: registry = get_registry() @@ -70,16 +91,169 @@ def _tenant_item(session: Session, tenant: Tenant) -> TenantAdminItem: ) -def _tenant_settings_item(tenant: Tenant) -> TenantSettingsItem: +def _tenant_settings_item(session: Session, tenant: Tenant) -> TenantSettingsItem: + system_settings = get_system_settings(session) + system_payload = system_i18n_payload(system_settings) + system_enabled = system_enabled_language_codes(system_settings.settings, default_locale=system_settings.default_locale) + enabled = tenant_enabled_language_codes(tenant.settings, system_enabled, default_locale=tenant.default_locale) return TenantSettingsItem( id=tenant.id, slug=tenant.slug, name=tenant.name, default_locale=tenant.default_locale, + available_languages=system_payload["available_languages"], + system_enabled_language_codes=system_enabled, + enabled_language_codes=enabled, settings=tenant.settings or {}, ) +def _tenant_settings_sections(item: TenantSettingsItem) -> dict[str, Any]: + payload = item.model_dump(mode="json") + return { + "identity": {"id": payload["id"], "slug": payload["slug"], "name": payload["name"]}, + "locale": {"default_locale": payload["default_locale"]}, + "languages": { + "available_languages": payload["available_languages"], + "system_enabled_language_codes": payload["system_enabled_language_codes"], + "enabled_language_codes": payload["enabled_language_codes"], + }, + "settings": payload["settings"], + } + + +def _record_tenant_settings_section_changes( + session: Session, + *, + tenant_id: str, + before: dict[str, Any], + after: dict[str, Any], + principal: ApiPrincipal, +) -> None: + for section in TENANT_SETTINGS_SECTIONS: + if before.get(section) == after.get(section): + continue + record_change( + session, + module_id=TENANCY_MODULE_ID, + collection=TENANT_SETTINGS_COLLECTION, + resource_type=TENANT_SETTINGS_RESOURCE, + resource_id=section, + operation="updated", + tenant_id=tenant_id, + actor_type="user", + actor_id=principal.user.id, + payload={"section": section}, + ) + + +def _record_tenant_list_change(session: Session, *, tenant: Tenant, operation: str, principal: ApiPrincipal) -> None: + record_change( + session, + module_id=TENANCY_MODULE_ID, + collection=TENANT_LIST_COLLECTION, + resource_type=TENANT_LIST_RESOURCE, + resource_id=tenant.id, + operation=operation, + tenant_id=None, + actor_type="user", + actor_id=principal.user.id, + payload={"slug": tenant.slug, "name": tenant.name, "is_active": tenant.is_active}, + ) + + +def _tenant_list_delta_query(session: Session, *, since_sequence: int): + return session.query(ChangeSequenceEntry).filter( + ChangeSequenceEntry.id > since_sequence, + ChangeSequenceEntry.module_id == TENANCY_MODULE_ID, + ChangeSequenceEntry.collection == TENANT_LIST_COLLECTION, + ) + + +def _tenant_list_watermark(session: Session) -> str: + sequence_id = _tenant_list_delta_query(session, since_sequence=0).with_entities(func.max(ChangeSequenceEntry.id)).scalar() + return encode_sequence_watermark(int(sequence_id or 0)) + + +def _tenant_list_delta_entries(session: Session, *, since: str, limit: int): + try: + since_sequence = decode_sequence_watermark(since) + except ValueError as exc: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc + if sequence_watermark_is_expired( + session, + since=since_sequence, + tenant_id=None, + module_id=TENANCY_MODULE_ID, + collections=(TENANT_LIST_COLLECTION,), + ): + return None, False + entries_plus_one = _tenant_list_delta_query(session, since_sequence=since_sequence).order_by(ChangeSequenceEntry.id.asc()).limit(limit + 1).all() + has_more = len(entries_plus_one) > limit + return entries_plus_one[:limit], has_more + + +def _tenant_list_response_watermark(session: Session, *, entries, has_more: bool) -> str: + return encode_sequence_watermark(entries[-1].id) if has_more and entries else _tenant_list_watermark(session) + + +def _tenant_list_deleted_entries(entries: list[ChangeSequenceEntry], visible_tenant_ids: set[str]): + return [ + {"id": entry.resource_id, "resource_type": entry.resource_type or TENANT_LIST_RESOURCE} + for entry in entries + if entry.resource_id and (entry.operation == "deleted" or entry.resource_id not in visible_tenant_ids) + ] + + +def _tenant_settings_delta_query(session: Session, *, tenant_id: str, since_sequence: int): + return session.query(ChangeSequenceEntry).filter( + ChangeSequenceEntry.id > since_sequence, + or_( + and_( + ChangeSequenceEntry.module_id == TENANCY_MODULE_ID, + ChangeSequenceEntry.collection == TENANT_SETTINGS_COLLECTION, + ChangeSequenceEntry.tenant_id == tenant_id, + ), + and_( + ChangeSequenceEntry.module_id == ADMIN_MODULE_ID, + ChangeSequenceEntry.collection == ADMIN_SYSTEM_SETTINGS_COLLECTION, + ChangeSequenceEntry.resource_id == "languages", + ), + ), + ) + + +def _tenant_settings_watermark(session: Session, *, tenant_id: str) -> str: + sequence_id = _tenant_settings_delta_query(session, tenant_id=tenant_id, since_sequence=0).with_entities(func.max(ChangeSequenceEntry.id)).scalar() + return encode_sequence_watermark(int(sequence_id or 0)) + + +def _tenant_settings_delta_entries(session: Session, *, tenant_id: str, since: str, limit: int): + try: + since_sequence = decode_sequence_watermark(since) + except ValueError as exc: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc + if sequence_watermark_is_expired( + session, + since=since_sequence, + tenant_id=tenant_id, + module_id=TENANCY_MODULE_ID, + collections=(TENANT_SETTINGS_COLLECTION,), + ): + return None, False + entries_plus_one = _tenant_settings_delta_query( + session, + tenant_id=tenant_id, + since_sequence=since_sequence, + ).order_by(ChangeSequenceEntry.id.asc()).limit(limit + 1).all() + has_more = len(entries_plus_one) > limit + return entries_plus_one[:limit], has_more + + +def _tenant_settings_response_watermark(session: Session, *, tenant_id: str, entries, has_more: bool) -> str: + return encode_sequence_watermark(entries[-1].id) if has_more and entries else _tenant_settings_watermark(session, tenant_id=tenant_id) + + @router.get("/tenants", response_model=TenantListResponse) def list_tenants( session: Session = Depends(get_session), @@ -89,6 +263,44 @@ def list_tenants( return TenantListResponse(tenants=[_tenant_item(session, tenant) for tenant in tenants]) +def _full_tenant_list_delta_response(session: Session) -> TenantListDeltaResponse: + tenants = session.query(Tenant).order_by(Tenant.name.asc()).all() + return TenantListDeltaResponse( + tenants=[_tenant_item(session, tenant) for tenant in tenants], + deleted=[], + watermark=_tenant_list_watermark(session), + has_more=False, + full=True, + ) + + +@router.get("/tenants/delta", response_model=TenantListDeltaResponse) +def list_tenants_delta( + since: str | None = None, + limit: int = Query(default=100, ge=1, le=500), + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(require_scope("system:tenants:read")), +): + del principal + if since is None: + return _full_tenant_list_delta_response(session) + entries, has_more = _tenant_list_delta_entries(session, since=since, limit=limit) + if entries is None: + return _full_tenant_list_delta_response(session) + changed_ids = [entry.resource_id for entry in entries if entry.resource_id and entry.operation != "deleted"] + tenants = [] + if changed_ids: + tenants = session.query(Tenant).filter(Tenant.id.in_(changed_ids)).order_by(Tenant.name.asc()).all() + visible_tenant_ids = {tenant.id for tenant in tenants} + return TenantListDeltaResponse( + tenants=[_tenant_item(session, tenant) for tenant in tenants], + deleted=_tenant_list_deleted_entries(entries, visible_tenant_ids), + watermark=_tenant_list_response_watermark(session, entries=entries, has_more=has_more), + has_more=has_more, + full=False, + ) + + @router.get("/tenants/owner-candidates", response_model=TenantOwnerCandidateListResponse) def list_tenant_owner_candidates( session: Session = Depends(get_session), @@ -119,7 +331,7 @@ def create_tenant( assert_tenant_governance_override_allowed(session, field="allow_custom_roles", value=payload.allow_custom_roles) assert_tenant_governance_override_allowed(session, field="allow_api_keys", value=payload.allow_api_keys) except AdminValidationError as exc: - raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc + raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)) from exc tenant = Tenant( slug=tenant_slug, name=payload.name.strip(), @@ -141,7 +353,7 @@ def create_tenant( owner_account_id=owner_account_id, ) except AdminValidationError as exc: - raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc + raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)) from exc audit_event( session, tenant_id=tenant.id, @@ -152,6 +364,7 @@ def create_tenant( scope="system", details={"slug": tenant.slug, "name": tenant.name, "creator_account_id": principal.account_id, "owner_account_id": owner_account_id}, ) + _record_tenant_list_change(session, tenant=tenant, operation="created", principal=principal) session.commit() return _tenant_item(session, tenant) @@ -171,6 +384,7 @@ def update_tenant( tenant = session.get(Tenant, tenant_id) if tenant is None: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Tenant not found") + before_sections = _tenant_settings_sections(_tenant_settings_item(session, tenant)) if payload.name is not None: tenant.name = payload.name.strip() if "description" in payload.model_fields_set: @@ -190,7 +404,7 @@ def update_tenant( assert_tenant_governance_override_allowed(session, field="allow_api_keys", value=payload.allow_api_keys) tenant.allow_api_keys = payload.allow_api_keys except AdminValidationError as exc: - raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc + raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)) from exc if payload.is_active is not None: if not payload.is_active and tenant.id == principal.tenant_id: raise HTTPException( @@ -209,6 +423,9 @@ def update_tenant( object_id=tenant.id, details=payload.model_dump(exclude_unset=True), ) + after_sections = _tenant_settings_sections(_tenant_settings_item(session, tenant)) + _record_tenant_settings_section_changes(session, tenant_id=tenant.id, before=before_sections, after=after_sections, principal=principal) + _record_tenant_list_change(session, tenant=tenant, operation="updated", principal=principal) session.commit() return _tenant_item(session, tenant) @@ -221,7 +438,55 @@ def get_tenant_settings( tenant = session.get(Tenant, principal.tenant_id) if tenant is None: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Tenant not found") - return _tenant_settings_item(tenant) + return _tenant_settings_item(session, tenant) + + +def _full_tenant_settings_delta_response(session: Session, tenant: Tenant) -> TenantSettingsDeltaResponse: + item = _tenant_settings_item(session, tenant) + return TenantSettingsDeltaResponse( + item=item, + sections=_tenant_settings_sections(item), + changed_sections=list(TENANT_SETTINGS_SECTIONS), + deleted=[], + watermark=_tenant_settings_watermark(session, tenant_id=tenant.id), + has_more=False, + full=True, + ) + + +@router.get("/tenant/settings/delta", response_model=TenantSettingsDeltaResponse) +def get_tenant_settings_delta( + since: str | None = None, + limit: int = Query(default=100, ge=1, le=500), + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(require_scope("admin:settings:read")), +): + tenant = session.get(Tenant, principal.tenant_id) + if tenant is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Tenant not found") + if since is None: + return _full_tenant_settings_delta_response(session, tenant) + entries, has_more = _tenant_settings_delta_entries(session, tenant_id=tenant.id, since=since, limit=limit) + if entries is None: + return _full_tenant_settings_delta_response(session, tenant) + changed = set() + for entry in entries: + if entry.module_id == ADMIN_MODULE_ID and entry.collection == ADMIN_SYSTEM_SETTINGS_COLLECTION and entry.resource_id == "languages": + changed.add("languages") + elif entry.resource_type == TENANT_SETTINGS_RESOURCE: + changed.add(entry.resource_id) + changed_sections = [section for section in TENANT_SETTINGS_SECTIONS if section in changed] + item = _tenant_settings_item(session, tenant) + sections = _tenant_settings_sections(item) + return TenantSettingsDeltaResponse( + item=None, + sections={section: sections[section] for section in changed_sections}, + changed_sections=changed_sections, + deleted=[], + watermark=_tenant_settings_response_watermark(session, tenant_id=tenant.id, entries=entries, has_more=has_more), + has_more=has_more, + full=False, + ) @router.patch("/tenant/settings", response_model=TenantSettingsItem) @@ -233,7 +498,19 @@ def update_tenant_settings( tenant = session.get(Tenant, principal.tenant_id) if tenant is None: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Tenant not found") - tenant.default_locale = payload.default_locale.strip() or "en" + before_sections = _tenant_settings_sections(_tenant_settings_item(session, tenant)) + system_settings = get_system_settings(session) + system_enabled = system_enabled_language_codes(system_settings.settings, default_locale=system_settings.default_locale) + current_i18n = i18n_settings(tenant.settings) + raw_enabled = payload.enabled_language_codes if "enabled_language_codes" in payload.model_fields_set else current_i18n.get("enabled_language_codes") + enabled = normalize_enabled_language_codes( + raw_enabled, + [{"code": code} for code in system_enabled], + default_locale=payload.default_locale, + fallback_codes=system_enabled, + ) + tenant.default_locale = payload.default_locale.strip() or enabled[0] + tenant.settings = update_i18n_settings(tenant.settings, enabled_language_codes=enabled) session.add(tenant) audit_event( session, @@ -242,7 +519,9 @@ def update_tenant_settings( action="tenant.settings.updated", object_type="tenant", object_id=tenant.id, - details={"default_locale": tenant.default_locale}, + details={"default_locale": tenant.default_locale, "enabled_language_codes": enabled}, ) + after_sections = _tenant_settings_sections(_tenant_settings_item(session, tenant)) + _record_tenant_settings_section_changes(session, tenant_id=tenant.id, before=before_sections, after=after_sections, principal=principal) session.commit() - return _tenant_settings_item(tenant) + return _tenant_settings_item(session, tenant) diff --git a/src/govoplan_tenancy/backend/api/v1/schemas.py b/src/govoplan_tenancy/backend/api/v1/schemas.py index 6566a69..9a48299 100644 --- a/src/govoplan_tenancy/backend/api/v1/schemas.py +++ b/src/govoplan_tenancy/backend/api/v1/schemas.py @@ -5,6 +5,8 @@ from typing import Any, Literal from pydantic import BaseModel, ConfigDict, Field, field_validator +from govoplan_core.api.v1.schemas import DeltaDeletedItem + class TenantAdminItem(BaseModel): id: str @@ -27,6 +29,14 @@ class TenantListResponse(BaseModel): tenants: list[TenantAdminItem] +class TenantListDeltaResponse(BaseModel): + tenants: list[TenantAdminItem] = Field(default_factory=list) + deleted: list[DeltaDeletedItem] = Field(default_factory=list) + watermark: str | None = None + has_more: bool = False + full: bool = False + + class TenantOwnerCandidate(BaseModel): account_id: str email: str @@ -69,10 +79,24 @@ class TenantSettingsItem(BaseModel): slug: str name: str default_locale: str = Field(default="en", min_length=1, max_length=20) + available_languages: list[dict[str, Any]] = Field(default_factory=list) + system_enabled_language_codes: list[str] = Field(default_factory=list) + enabled_language_codes: list[str] = Field(default_factory=list) settings: dict[str, Any] = Field(default_factory=dict) +class TenantSettingsDeltaResponse(BaseModel): + item: TenantSettingsItem | None = None + sections: dict[str, Any] = Field(default_factory=dict) + changed_sections: list[str] = Field(default_factory=list) + deleted: list[DeltaDeletedItem] = Field(default_factory=list) + watermark: str | None = None + has_more: bool = False + full: bool = False + + class TenantSettingsUpdateRequest(BaseModel): model_config = ConfigDict(extra="forbid") default_locale: str = Field(min_length=1, max_length=20) + enabled_language_codes: list[str] | None = None diff --git a/src/govoplan_tenancy/backend/db/__pycache__/models.cpython-312.pyc b/src/govoplan_tenancy/backend/db/__pycache__/models.cpython-312.pyc index e876a04..74937ed 100644 Binary files a/src/govoplan_tenancy/backend/db/__pycache__/models.cpython-312.pyc and b/src/govoplan_tenancy/backend/db/__pycache__/models.cpython-312.pyc differ diff --git a/src/govoplan_tenancy/backend/db/__pycache__/models.cpython-313.pyc b/src/govoplan_tenancy/backend/db/__pycache__/models.cpython-313.pyc index c02b973..c182461 100644 Binary files a/src/govoplan_tenancy/backend/db/__pycache__/models.cpython-313.pyc and b/src/govoplan_tenancy/backend/db/__pycache__/models.cpython-313.pyc differ diff --git a/src/govoplan_tenancy/backend/db/models.py b/src/govoplan_tenancy/backend/db/models.py index 77c2dfd..1dd0005 100644 --- a/src/govoplan_tenancy/backend/db/models.py +++ b/src/govoplan_tenancy/backend/db/models.py @@ -14,7 +14,7 @@ def new_uuid() -> str: class Tenant(Base, TimestampMixin): - __tablename__ = "tenants" + __tablename__ = "tenancy_tenants" id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid) slug: Mapped[str] = mapped_column(String(100), unique=True, nullable=False, index=True)