"""Where a task sits — its project, its milestone, its step position, what is next. WHY THIS EXISTS (milestone 409 step 1) An agent reporting finished work to the operator is asked to place it: which milestone, which step of how many, what comes next. Without those facts in hand it reconstructs them from memory, and a reconstruction reads exactly like the real thing while being wrong — the drafting of the feature note that started this milestone invented a milestone title and named a "next" step that was already done. So the facts come back on the write that changes a task, where the report is about to be written, instead of being left to recall. THE SHAPE {"project": {"id", "title"}, "milestone": {"id", "title", "status"}, "position": {"step": 3, "of": 6}, "progress": {"completed", "total", "pct"}, "next": {"id", "title", "status"} | None} `milestone`, `position`, `progress` and `next` appear only for a task in a milestone; a task with a project and no milestone gets `project` alone; a task with neither gets no placement at all (None), which the doors omit rather than send empty. WHAT "STEP" AND "NEXT" MEAN Notes carry no order column, so a milestone's steps are in CREATION order (created_at, then id) — the order a plan's steps are written in, and the order a batch create inserts them. Deliberately NOT get_milestone's listing order (status, then last update): that is a display choice, and it reshuffles every time anything is touched. `next` is the first open step (todo or in_progress) AFTER this one; when every later step is closed it falls back to the earliest open step before it, since that is still the milestone's next piece of work. None when nothing else is open. ACCESS Siblings are read through access.readable_notes_clause, so a collaborator on a shared task is never shown the title of a step they cannot open. Position and progress are computed over that same readable set — one list, so the counts can never disagree with the titles they sit beside. For an owner the readable set is every step, and the numbers equal get_milestone's. """ from __future__ import annotations import logging from sqlalchemy import select from scribe.models import async_session from scribe.models.milestone import Milestone from scribe.models.note import Note from scribe.models.project import Project from scribe.services import access as access_svc from scribe.services.milestones import OPEN_STEP_STATUSES, _progress_from_counts # Imported, not restated. The milestone summary names a plan's next open step # too (#4154), and the two answers must agree about what "open" means. _OPEN = OPEN_STEP_STATUSES logger = logging.getLogger(__name__) def _next_open(steps: list, current_id: int) -> dict | None: """The next open step after `current_id`, else the earliest open one before it.""" index = next((i for i, s in enumerate(steps) if s.id == current_id), -1) later = [s for s in steps[index + 1:] if s.status in _OPEN] earlier = [s for s in steps[:max(index, 0)] if s.status in _OPEN] pick = (later or earlier or [None])[0] if pick is None: return None return {"id": pick.id, "title": pick.title, "status": pick.status} async def task_placement(user_id: int, task) -> dict | None: """Placement for `task` as `user_id` may see it, or None when it has none. `task` is the Note the caller already holds — it was just written or read — so its own row is not fetched again. """ project_id = getattr(task, "project_id", None) milestone_id = getattr(task, "milestone_id", None) if not project_id and not milestone_id: return None async with async_session() as session: milestone = None if milestone_id: milestone = (await session.execute( select(Milestone).where( Milestone.id == milestone_id, Milestone.deleted_at.is_(None), ) )).scalars().first() project_id = project_id or (milestone.project_id if milestone else None) project = None if project_id and await access_svc.can_read_project(user_id, project_id): project = await session.get(Project, project_id) if project is not None and project.deleted_at is not None: project = None steps: list = [] if milestone is not None: steps = list((await session.execute( select(Note).where( Note.milestone_id == milestone.id, Note.status.isnot(None), Note.deleted_at.is_(None), access_svc.readable_notes_clause(user_id), ).order_by(Note.created_at.asc(), Note.id.asc()) )).scalars().all()) out: dict = {} if project is not None: out["project"] = {"id": project.id, "title": project.title} # A milestone is shown only to someone who can read its project (or who # owns it): the task being readable does not make its plan readable. if milestone is not None and (project is not None or milestone.user_id == user_id): counts: dict[str, int] = {} for step in steps: counts[step.status] = counts.get(step.status, 0) + 1 progress = _progress_from_counts(counts) position = next((i for i, s in enumerate(steps, start=1) if s.id == task.id), None) out["milestone"] = {"id": milestone.id, "title": milestone.title, "status": milestone.status} out["position"] = {"step": position, "of": len(steps)} out["progress"] = {k: progress[k] for k in ("completed", "total", "pct")} out["next"] = _next_open(steps, task.id) return out or None async def attach_placement(user_id: int, data: dict, task) -> dict: """Add `placement` to a task payload the door is about to return. Fail-open, like every in-band decoration: the write it rides on has already happened, and a placement lookup that errors must not turn a successful update into a reported failure. Omitted, never sent empty. """ try: placement = await task_placement(user_id, task) except Exception: # noqa: BLE001 - a decoration never breaks its payload logger.warning("placement lookup failed for task %s", getattr(task, "id", None), exc_info=True) return data if placement: data["placement"] = placement return data