From 0f29544bdbae9b3478ab8a18cd8685bc69e3aefa Mon Sep 17 00:00:00 2001 From: Leo Vasanko Date: Tue, 27 Jan 2026 01:25:52 +0000 Subject: [PATCH] Database cleanup, better UUID passing and construction (User model). --- paskia/db/operations.py | 29 +++++++++------------------- paskia/db/structs.py | 39 +++++++++++++++++++++++++------------- paskia/fastapi/admin.py | 10 +++------- paskia/migrate/__init__.py | 26 +++++++++++++------------ paskia/migrate/sql.py | 20 +++++++++++++++---- 5 files changed, 68 insertions(+), 56 deletions(-) diff --git a/paskia/db/operations.py b/paskia/db/operations.py index 239f991..6a07619 100644 --- a/paskia/db/operations.py +++ b/paskia/db/operations.py @@ -44,7 +44,6 @@ from paskia.db.structs import ( _ResetTokenData, _RoleData, _SessionData, - _UserData, ) from paskia.util.passphrase import is_well_formed as _is_passphrase @@ -178,15 +177,9 @@ def build_permission(uuid: UUID) -> Permission: def build_user(uuid: UUID) -> User: - u = _db._data.users[uuid] - return User( - uuid=uuid, - display_name=u.display_name, - role_uuid=u.role, - created_at=u.created_at, - last_seen=u.last_seen, - visits=u.visits, - ) + user = _db._data.users[uuid] + user.uuid = uuid + return user def build_role(uuid: UUID) -> Role: @@ -809,16 +802,10 @@ def create_user(new_user: User, *, ctx: SessionContext | None = None) -> None: """Create a new user.""" if new_user.uuid in _db._data.users: raise ValueError(f"User {new_user.uuid} already exists") - if new_user.role_uuid not in _db._data.roles: - raise ValueError(f"Role {new_user.role_uuid} not found") + if new_user.role not in _db._data.roles: + raise ValueError(f"Role {new_user.role} not found") with _db.transaction("Created user", ctx): - _db._data.users[new_user.uuid] = _UserData( - display_name=new_user.display_name, - role=new_user.role_uuid, - created_at=new_user.created_at or datetime.now(timezone.utc), - last_seen=new_user.last_seen, - visits=new_user.visits, - ) + _db._data.users[new_user.uuid] = new_user def update_user_display_name( @@ -1331,13 +1318,15 @@ def bootstrap( ) # Create admin user - _db._data.users[user_uuid] = _UserData( + admin_user = User( display_name=admin_name, role=role_uuid, created_at=now, last_seen=None, visits=0, ) + admin_user.uuid = user_uuid + _db._data.users[user_uuid] = admin_user # Create reset token _db._data.reset_tokens[reset_key] = _ResetTokenData( diff --git a/paskia/db/structs.py b/paskia/db/structs.py index af3158c..f956ca8 100644 --- a/paskia/db/structs.py +++ b/paskia/db/structs.py @@ -2,6 +2,7 @@ from datetime import datetime from uuid import UUID import msgspec +import uuid7 class Permission(msgspec.Struct, omit_defaults=True): @@ -25,14 +26,34 @@ class Org(msgspec.Struct): roles: list[Role] = [] # roles belonging to this org -class User(msgspec.Struct): - uuid: UUID +class User(msgspec.Struct, dict=True): display_name: str - role_uuid: UUID - created_at: datetime | None = None + role: UUID + created_at: datetime last_seen: datetime | None = None visits: int = 0 + def __post_init__(self): + self.uuid: UUID | None = None # Convenience field, not serialized + + @classmethod + def create( + cls, + display_name: str, + role: UUID, + created_at: datetime | None = None, + ) -> "User": + """Create a new User with auto-generated uuid7.""" + from datetime import timezone + + user = cls( + display_name=display_name, + role=role, + created_at=created_at or datetime.now(timezone.utc), + ) + user.uuid = uuid7.create(user.created_at) + return user + class Credential(msgspec.Struct): uuid: UUID @@ -103,14 +124,6 @@ class _RoleData(msgspec.Struct): permissions: dict[UUID, bool] = {} # permission_uuid -> True -class _UserData(msgspec.Struct): - display_name: str - role: UUID - created_at: datetime - last_seen: datetime | None - visits: int - - class _CredentialData(msgspec.Struct): credential_id: bytes user: UUID @@ -141,7 +154,7 @@ class _DatabaseData(msgspec.Struct, omit_defaults=True): permissions: dict[UUID, _PermissionData] orgs: dict[UUID, _OrgData] roles: dict[UUID, _RoleData] - users: dict[UUID, _UserData] + users: dict[UUID, User] credentials: dict[UUID, _CredentialData] sessions: dict[str, _SessionData] reset_tokens: dict[bytes, _ResetTokenData] diff --git a/paskia/fastapi/admin.py b/paskia/fastapi/admin.py index b8fcd03..198db49 100644 --- a/paskia/fastapi/admin.py +++ b/paskia/fastapi/admin.py @@ -463,16 +463,12 @@ async def admin_create_user( role_obj = next((r for r in roles if r.display_name == role_name), None) if not role_obj: raise ValueError("Role not found in organization") - user_uuid = uuid4() - user = UserDC( - uuid=user_uuid, + user = UserDC.create( display_name=display_name, - role_uuid=role_obj.uuid, - visits=0, - created_at=None, + role=role_obj.uuid, ) db.create_user(user, ctx=ctx) - return {"uuid": str(user_uuid)} + return {"uuid": str(user.uuid)} @app.patch("/orgs/{org_uuid}/users/{user_uuid}/role") diff --git a/paskia/migrate/__init__.py b/paskia/migrate/__init__.py index 6024055..56f1d61 100644 --- a/paskia/migrate/__init__.py +++ b/paskia/migrate/__init__.py @@ -55,13 +55,13 @@ async def migrate_from_sql( from paskia.db.operations import DB as JSONDB from paskia.db.structs import ( + User, _CredentialData, _OrgData, _PermissionData, _ResetTokenData, _RoleData, _SessionData, - _UserData, ) # Initialize source SQL database @@ -163,15 +163,17 @@ async def migrate_from_sql( result = await session.execute(select(UserModel)) user_models = result.scalars().all() for um in user_models: - user = um.as_dataclass() - user_key: UUID = user.uuid - json_db._data.users[user_key] = _UserData( - display_name=user.display_name, - role=user.role_uuid, - created_at=user.created_at or datetime.now(timezone.utc), - last_seen=user.last_seen, - visits=user.visits, + legacy_user = um.as_dataclass() + user_key: UUID = legacy_user.uuid + new_user = User( + display_name=legacy_user.display_name, + role=legacy_user.role_uuid, + created_at=legacy_user.created_at or datetime.now(timezone.utc), + last_seen=legacy_user.last_seen, + visits=legacy_user.visits, ) + new_user.uuid = user_key + json_db._data.users[user_key] = new_user print(f" Migrated {len(user_models)} users") # Migrate credentials @@ -238,9 +240,9 @@ async def migrate_from_sql( ) print(f" Migrated {len(token_models)} reset tokens") - # Queue and flush all changes with actor "migrate" - json_db._current_actor = "migrate" - json_db._queue_change() + # Queue and flush all changes using the transaction mechanism + with json_db.transaction("migrate"): + pass # All data already added to _data, transaction commits on exit from paskia.db.jsonl import flush_changes await flush_changes(json_db.db_path, json_db._pending_changes) diff --git a/paskia/migrate/sql.py b/paskia/migrate/sql.py index 9eb381f..7b5eb95 100644 --- a/paskia/migrate/sql.py +++ b/paskia/migrate/sql.py @@ -30,10 +30,22 @@ from paskia.db import ( Org, ResetToken, Role, - User, ) +# Legacy User class for SQL schema (uses 'role_uuid' not 'role') +@dataclass +class _LegacyUser: + """User as stored in the old SQL schema with role_uuid field.""" + + uuid: UUID + display_name: str + role_uuid: UUID + created_at: datetime | None = None + last_seen: datetime | None = None + visits: int = 0 + + # Local Permission class for SQL schema (uses 'id' not 'uuid' + 'scope') @dataclass class SqlPermission: @@ -129,8 +141,8 @@ class UserModel(Base): ) visits: Mapped[int] = mapped_column(Integer, nullable=False, default=0) - def as_dataclass(self) -> User: - return User( + def as_dataclass(self) -> "_LegacyUser": + return _LegacyUser( uuid=UUID(bytes=self.uuid), display_name=self.display_name, role_uuid=UUID(bytes=self.role_uuid), @@ -140,7 +152,7 @@ class UserModel(Base): ) @staticmethod - def from_dataclass(user: User): + def from_dataclass(user: "_LegacyUser"): return UserModel( uuid=user.uuid.bytes, display_name=user.display_name,