"""A member's draft: pending add/modify/delete changes until Push (SPEC C-14, C-15). One draft per member.""" from collections.abc import Callable from dataclasses import dataclass from datetime import UTC, datetime, timedelta from sqlalchemy import delete, select from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload from app.db.models import ChangeOp, Draft, DraftChange, utcnow from app.db.session import SessionMaker MAX_CHANGES = 50 class DraftFull(Exception): pass @dataclass(frozen=True) class Change: path: str op: ChangeOp content: bytes | None @dataclass(frozen=True) class DraftView: base_sha: str message: str | None changes: list[Change] def change_for(self, path: str) -> Change | None: return next((c for c in self.changes if c.path == path), None) class DraftService: def __init__( self, sessions: SessionMaker, ttl: timedelta = timedelta(hours=24), clock: Callable[[], datetime] = utcnow ): self._sessions = sessions self._ttl = ttl self._clock = clock async def get(self, user_id: int) -> DraftView | None: async with self._sessions() as s: draft = await self._load(s, user_id) if draft is None: return None return DraftView( base_sha=draft.base_sha, message=draft.message, changes=[Change(c.path, c.op, c.content) for c in draft.changes], ) async def put(self, user_id: int, base_sha: str, path: str, op: ChangeOp, content: bytes | None) -> None: """Record a change. Deleting a file that only exists in the draft just drops it from the draft.""" async with self._sessions.begin() as s: draft = await self._load(s, user_id) if draft is None: draft = Draft( user_id=user_id, base_sha=base_sha, created_at=self._clock(), expires_at=self._clock() + self._ttl ) s.add(draft) existing = next((c for c in draft.changes if c.path == path), None) if existing is not None: if op is ChangeOp.DELETE and existing.op is ChangeOp.ADD: draft.changes.remove(existing) return # editing a file added in this draft keeps it an "add" existing.op = ChangeOp.ADD if existing.op is ChangeOp.ADD and op is ChangeOp.MODIFY else op existing.content = content else: if len(draft.changes) >= MAX_CHANGES: raise DraftFull draft.changes.append(DraftChange(path=path, op=op, content=content)) draft.expires_at = self._clock() + self._ttl async def drop(self, user_id: int, path: str) -> None: async with self._sessions.begin() as s: draft = await self._load(s, user_id) if draft is None: return draft.changes = [c for c in draft.changes if c.path != path] async def set_message(self, user_id: int, message: str) -> None: async with self._sessions.begin() as s: draft = await self._load(s, user_id) if draft is not None: draft.message = message async def clear(self, user_id: int) -> None: async with self._sessions.begin() as s: await s.execute(delete(Draft).where(Draft.user_id == user_id)) async def _load(self, s: AsyncSession, user_id: int) -> Draft | None: draft = await s.scalar(select(Draft).where(Draft.user_id == user_id).options(selectinload(Draft.changes))) if draft is not None and _aware(draft.expires_at) <= self._clock(): await s.delete(draft) await s.flush() return None return draft def _aware(value: datetime) -> datetime: return value if value.tzinfo is not None else value.replace(tzinfo=UTC)