Track preschool planning days, partner invites and real vacation forfeiture
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>
This commit is contained in:
@@ -0,0 +1,115 @@
|
||||
"""SQLite implementation of PreschoolPlanningDaysRepository.
|
||||
|
||||
Follows the SQLiteVacationDaysRepository reference shape: map rows <->
|
||||
entities explicitly in `_row_to_entity`, always parameterize queries, raise
|
||||
NotFoundError from update/delete when the row is missing.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from uuid import UUID
|
||||
|
||||
import aiosqlite
|
||||
|
||||
from app.db.connection import commit
|
||||
from app.entities.preschool_planning_days import PreschoolPlanningDays
|
||||
from app.repositories.filter_validator import validate_filters
|
||||
from app.repositories.preschool_planning_days_repository import (
|
||||
PreschoolPlanningDaysRepository,
|
||||
)
|
||||
from app.utils.errors import NotFoundError
|
||||
|
||||
|
||||
class SQLitePreschoolPlanningDaysRepository(PreschoolPlanningDaysRepository):
|
||||
def __init__(self, connection: aiosqlite.Connection) -> None:
|
||||
self._conn = connection
|
||||
|
||||
@staticmethod
|
||||
def _row_to_entity(row: aiosqlite.Row) -> PreschoolPlanningDays:
|
||||
return PreschoolPlanningDays(
|
||||
id=UUID(row["id"]),
|
||||
child_id=UUID(row["child_id"]),
|
||||
year=row["year"],
|
||||
planned_days=row["planned_days"],
|
||||
taken_days=row["taken_days"],
|
||||
created_at=datetime.fromisoformat(row["created_at"]),
|
||||
updated_at=datetime.fromisoformat(row["updated_at"]),
|
||||
)
|
||||
|
||||
async def create(self, item: PreschoolPlanningDays) -> PreschoolPlanningDays:
|
||||
now = datetime.now()
|
||||
item.created_at = item.created_at or now
|
||||
item.updated_at = now
|
||||
await self._conn.execute(
|
||||
"""
|
||||
INSERT INTO preschool_planning_days
|
||||
(id, child_id, year, planned_days, taken_days, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
str(item.id),
|
||||
str(item.child_id),
|
||||
item.year,
|
||||
item.planned_days,
|
||||
item.taken_days,
|
||||
item.created_at.isoformat(),
|
||||
item.updated_at.isoformat(),
|
||||
),
|
||||
)
|
||||
await commit(self._conn)
|
||||
return item
|
||||
|
||||
async def get_by_id(self, id: UUID) -> PreschoolPlanningDays | None:
|
||||
cursor = await self._conn.execute(
|
||||
"SELECT * FROM preschool_planning_days WHERE id = ?", (str(id),)
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
return self._row_to_entity(row) if row else None
|
||||
|
||||
async def get_by_child_year(self, child_id: UUID, year: int) -> PreschoolPlanningDays | None:
|
||||
cursor = await self._conn.execute(
|
||||
"SELECT * FROM preschool_planning_days WHERE child_id = ? AND year = ?",
|
||||
(str(child_id), year),
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
return self._row_to_entity(row) if row else None
|
||||
|
||||
async def update(self, id: UUID, item: PreschoolPlanningDays) -> PreschoolPlanningDays:
|
||||
item.updated_at = datetime.now()
|
||||
cursor = await self._conn.execute(
|
||||
"""
|
||||
UPDATE preschool_planning_days
|
||||
SET planned_days = ?, taken_days = ?, updated_at = ?
|
||||
WHERE id = ?
|
||||
""",
|
||||
(item.planned_days, item.taken_days, item.updated_at.isoformat(), str(id)),
|
||||
)
|
||||
await commit(self._conn)
|
||||
if cursor.rowcount == 0:
|
||||
raise NotFoundError(f"PreschoolPlanningDays {id} not found")
|
||||
refreshed = await self.get_by_id(id)
|
||||
assert refreshed is not None # just updated
|
||||
return refreshed
|
||||
|
||||
async def delete(self, id: UUID) -> None:
|
||||
cursor = await self._conn.execute(
|
||||
"DELETE FROM preschool_planning_days WHERE id = ?", (str(id),)
|
||||
)
|
||||
await commit(self._conn)
|
||||
if cursor.rowcount == 0:
|
||||
raise NotFoundError(f"PreschoolPlanningDays {id} not found")
|
||||
|
||||
async def list(self, **filters: object) -> list[PreschoolPlanningDays]:
|
||||
validate_filters(filters, {"child_id", "year"})
|
||||
clauses: list[str] = []
|
||||
params: list[object] = []
|
||||
for column in ("child_id", "year"):
|
||||
if column in filters and filters[column] is not None:
|
||||
value = filters[column]
|
||||
clauses.append(f"{column} = ?")
|
||||
params.append(str(value) if isinstance(value, UUID) else value)
|
||||
where = f" WHERE {' AND '.join(clauses)}" if clauses else ""
|
||||
cursor = await self._conn.execute(
|
||||
f"SELECT * FROM preschool_planning_days{where} ORDER BY year DESC", params
|
||||
)
|
||||
rows = await cursor.fetchall()
|
||||
return [self._row_to_entity(row) for row in rows]
|
||||
@@ -58,10 +58,33 @@ class SQLiteUserRepository(UserRepository):
|
||||
row = await cursor.fetchone()
|
||||
return self._row_to_entity(row) if row else None
|
||||
|
||||
async def update(self, id: UUID, item: User) -> User:
|
||||
async def get_by_personal_data_uuid(self, personal_data_uuid: UUID) -> User | None:
|
||||
cursor = await self._conn.execute(
|
||||
'UPDATE "user" SET email = ?, hashed_password = ?, is_active = ? WHERE id = ?',
|
||||
(item.email, item.hashed_password, int(item.is_active), str(id)),
|
||||
'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:
|
||||
|
||||
Reference in New Issue
Block a user