0e9b20ec37
Three strands of work that grew together across the same settings screen and
API client, so they land as one change.
Per-year preschool planning days ("studiedagar"): new table, repository
(SQLite + Postgres), service, router and migration 007, plus the hooks and
Settings UI to plan and record them per child and year. Allocation now
reserves the recorded figure for a year where one exists and only falls back
to the child-level estimate otherwise, marking that fallback as assumed.
Partner invitations: workspaces issue and list invitations, registration
accepts an invite token to join the inviter's workspace instead of creating a
personal one, and a /join/:token page plus the settings invite panel drive it.
Vacation forfeiture: tips and the dashboard now distinguish days a parent
genuinely loses from days that merely age, so the warning stops firing on
balances that were never at risk.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
107 lines
4.0 KiB
Python
107 lines
4.0 KiB
Python
"""SQLite implementation of UserRepository."""
|
|
|
|
from datetime import datetime
|
|
from uuid import UUID
|
|
|
|
import aiosqlite
|
|
|
|
from app.db.connection import commit
|
|
from app.entities.user import User
|
|
from app.repositories.filter_validator import validate_filters
|
|
from app.repositories.user_repository import UserRepository
|
|
from app.utils.errors import NotFoundError
|
|
|
|
|
|
class SQLiteUserRepository(UserRepository):
|
|
def __init__(self, connection: aiosqlite.Connection) -> None:
|
|
self._conn = connection
|
|
|
|
@staticmethod
|
|
def _row_to_entity(row: aiosqlite.Row) -> User:
|
|
return User(
|
|
id=UUID(row["id"]),
|
|
personal_data_uuid=UUID(row["personal_data_uuid"]),
|
|
email=row["email"],
|
|
hashed_password=row["hashed_password"],
|
|
created_at=datetime.fromisoformat(row["created_at"]),
|
|
is_active=bool(row["is_active"]),
|
|
)
|
|
|
|
async def create(self, item: User) -> User:
|
|
now = datetime.now()
|
|
item.created_at = item.created_at or now
|
|
await self._conn.execute(
|
|
"""
|
|
INSERT INTO "user"
|
|
(id, personal_data_uuid, email, hashed_password, created_at, is_active)
|
|
VALUES (?, ?, ?, ?, ?, ?)
|
|
""",
|
|
(
|
|
str(item.id),
|
|
str(item.personal_data_uuid),
|
|
item.email,
|
|
item.hashed_password,
|
|
item.created_at.isoformat(),
|
|
int(item.is_active),
|
|
),
|
|
)
|
|
await commit(self._conn)
|
|
return item
|
|
|
|
async def get_by_id(self, id: UUID) -> User | None:
|
|
cursor = await self._conn.execute('SELECT * FROM "user" WHERE id = ?', (str(id),))
|
|
row = await cursor.fetchone()
|
|
return self._row_to_entity(row) if row else None
|
|
|
|
async def get_by_email(self, email: str) -> User | None:
|
|
cursor = await self._conn.execute('SELECT * FROM "user" WHERE email = ?', (email,))
|
|
row = await cursor.fetchone()
|
|
return self._row_to_entity(row) if row else None
|
|
|
|
async def get_by_personal_data_uuid(self, personal_data_uuid: UUID) -> User | None:
|
|
cursor = await self._conn.execute(
|
|
'SELECT * FROM "user" WHERE personal_data_uuid = ?', (str(personal_data_uuid),)
|
|
)
|
|
row = await cursor.fetchone()
|
|
return self._row_to_entity(row) if row else None
|
|
|
|
async def update(self, id: UUID, item: User) -> User:
|
|
# `personal_data_uuid` is included here (unlike a typical immutable
|
|
# FK) because `WorkspaceService.redeem_invitation` and
|
|
# `AuthService.register(..., invite_token=...)` re-point a user's
|
|
# login onto an existing placeholder `personal_data` row when
|
|
# claiming it. `user.personal_data_uuid` is UNIQUE, so re-pointing it
|
|
# onto a row already linked to another user fails at the DB.
|
|
cursor = await self._conn.execute(
|
|
"""
|
|
UPDATE "user"
|
|
SET personal_data_uuid = ?, email = ?, hashed_password = ?, is_active = ?
|
|
WHERE id = ?
|
|
""",
|
|
(
|
|
str(item.personal_data_uuid),
|
|
item.email,
|
|
item.hashed_password,
|
|
int(item.is_active),
|
|
str(id),
|
|
),
|
|
)
|
|
await commit(self._conn)
|
|
if cursor.rowcount == 0:
|
|
raise NotFoundError(f"User {id} not found")
|
|
refreshed = await self.get_by_id(id)
|
|
assert refreshed is not None
|
|
return refreshed
|
|
|
|
async def delete(self, id: UUID) -> None:
|
|
cursor = await self._conn.execute('DELETE FROM "user" WHERE id = ?', (str(id),))
|
|
await commit(self._conn)
|
|
if cursor.rowcount == 0:
|
|
raise NotFoundError(f"User {id} not found")
|
|
|
|
async def list(self, **filters: object) -> list[User]:
|
|
validate_filters(filters, set())
|
|
cursor = await self._conn.execute('SELECT * FROM "user"')
|
|
rows = await cursor.fetchall()
|
|
return [self._row_to_entity(r) for r in rows]
|