From 5f9ea80621795205d9b00debf1f8ee936b147569 Mon Sep 17 00:00:00 2001 From: Markov Date: Sun, 15 Feb 2026 20:30:20 +0100 Subject: [PATCH] feat: task numbers (TEA-1, TEA-2...) + project key_prefix - Project: key_prefix (auto from name), task_counter - Task: number (auto-increment per project) - API returns 'key' field (e.g. TEA-1) - Migration numbers existing tasks --- ..._add_task_number_and_project_key_prefix.py | 55 +++++++++++++++++++ src/tracker/api/projects.py | 10 +++- src/tracker/api/tasks.py | 44 +++++++++++++-- src/tracker/models/project.py | 4 +- src/tracker/models/task.py | 1 + 5 files changed, 107 insertions(+), 7 deletions(-) create mode 100644 alembic/versions/864b0ce5d657_add_task_number_and_project_key_prefix.py diff --git a/alembic/versions/864b0ce5d657_add_task_number_and_project_key_prefix.py b/alembic/versions/864b0ce5d657_add_task_number_and_project_key_prefix.py new file mode 100644 index 0000000..58eee32 --- /dev/null +++ b/alembic/versions/864b0ce5d657_add_task_number_and_project_key_prefix.py @@ -0,0 +1,55 @@ +"""add task number and project key_prefix + +Revision ID: 864b0ce5d657 +Revises: 59dd2d9071fd +Create Date: 2026-02-15 19:27:43.338390 +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +revision: str = '864b0ce5d657' +down_revision: Union[str, None] = '59dd2d9071fd' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # Add as nullable first + op.add_column('projects', sa.Column('key_prefix', sa.String(length=10), nullable=True)) + op.add_column('projects', sa.Column('task_counter', sa.Integer(), nullable=True)) + op.add_column('tasks', sa.Column('number', sa.Integer(), nullable=True)) + + # Fill defaults + op.execute("UPDATE projects SET key_prefix = UPPER(LEFT(REGEXP_REPLACE(name, '[^a-zA-Z]', '', 'g'), 3)) WHERE key_prefix IS NULL") + op.execute("UPDATE projects SET key_prefix = 'PRJ' WHERE key_prefix IS NULL OR key_prefix = ''") + op.execute("UPDATE projects SET task_counter = 0 WHERE task_counter IS NULL") + op.execute("UPDATE tasks SET number = 0 WHERE number IS NULL") + + # Number existing tasks sequentially per project + op.execute(""" + WITH numbered AS ( + SELECT id, ROW_NUMBER() OVER (PARTITION BY project_id ORDER BY created_at) as rn + FROM tasks + ) + UPDATE tasks SET number = numbered.rn FROM numbered WHERE tasks.id = numbered.id + """) + # Update project counters + op.execute(""" + UPDATE projects SET task_counter = COALESCE( + (SELECT MAX(number) FROM tasks WHERE tasks.project_id = projects.id), 0 + ) + """) + + # Make NOT NULL + op.alter_column('projects', 'key_prefix', nullable=False) + op.alter_column('projects', 'task_counter', nullable=False, server_default='0') + op.alter_column('tasks', 'number', nullable=False, server_default='0') + + +def downgrade() -> None: + op.drop_column('tasks', 'number') + op.drop_column('projects', 'task_counter') + op.drop_column('projects', 'key_prefix') diff --git a/src/tracker/api/projects.py b/src/tracker/api/projects.py index 9eabfe6..5380b03 100644 --- a/src/tracker/api/projects.py +++ b/src/tracker/api/projects.py @@ -17,6 +17,7 @@ class ProjectCreate(BaseModel): name: str slug: str description: str | None = None + key_prefix: str | None = None # auto-generated from name if not provided git_repo: str | None = None @@ -31,6 +32,8 @@ class ProjectOut(BaseModel): name: str slug: str description: str | None + key_prefix: str + task_counter: int git_repo: str | None model_config = {"from_attributes": True} @@ -44,7 +47,12 @@ async def list_projects(db: AsyncSession = Depends(get_db)): @router.post("/", response_model=ProjectOut, status_code=201) async def create_project(data: ProjectCreate, db: AsyncSession = Depends(get_db)): - project = Project(**data.model_dump()) + dump = data.model_dump() + # Auto-generate key_prefix from name (first 3 chars, uppercase) + if not dump.get("key_prefix"): + prefix = "".join(c for c in data.name.upper() if c.isalpha())[:3] + dump["key_prefix"] = prefix or "PRJ" + project = Project(**dump) db.add(project) await db.commit() await db.refresh(project) diff --git a/src/tracker/api/tasks.py b/src/tracker/api/tasks.py index acf89ef..f74e993 100644 --- a/src/tracker/api/tasks.py +++ b/src/tracker/api/tasks.py @@ -9,7 +9,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload from tracker.database import get_db -from tracker.models import Task +from tracker.models import Task, Project router = APIRouter(prefix="/tasks", tags=["tasks"]) @@ -46,11 +46,33 @@ class TaskOut(BaseModel): requires_pr: bool pr_url: str | None assigned_agent_id: uuid.UUID | None + number: int + key: str | None = None # e.g. "TEA-1" position: int model_config = {"from_attributes": True} +async def _enrich_tasks(tasks: list[Task], db: AsyncSession) -> list[TaskOut]: + """Add key (e.g. TEA-1) to task outputs.""" + if not tasks: + return [] + # Collect project ids + project_ids = {t.project_id for t in tasks} + projects = {} + for pid in project_ids: + p = await db.get(Project, pid) + if p: + projects[p.id] = p.key_prefix + result = [] + for t in tasks: + out = TaskOut.model_validate(t) + prefix = projects.get(t.project_id, "???") + out.key = f"{prefix}-{t.number}" if t.number else None + result.append(out) + return result + + @router.get("/", response_model=list[TaskOut]) async def list_tasks( project_id: uuid.UUID | None = None, @@ -63,16 +85,27 @@ async def list_tasks( if status: q = q.where(Task.status == status) result = await db.execute(q) - return result.scalars().all() + tasks = list(result.scalars().all()) + return await _enrich_tasks(tasks, db) @router.post("/", response_model=TaskOut, status_code=201) async def create_task(data: TaskCreate, db: AsyncSession = Depends(get_db)): - task = Task(**data.model_dump()) + # Get project and increment counter + project = await db.get(Project, data.project_id) + if not project: + raise HTTPException(404, "Project not found") + project.task_counter += 1 + number = project.task_counter + + task = Task(**data.model_dump(), number=number) db.add(task) await db.commit() await db.refresh(task) - return task + # Attach key + task_out = TaskOut.model_validate(task) + task_out.key = f"{project.key_prefix}-{number}" + return task_out @router.get("/{task_id}", response_model=TaskOut) @@ -80,7 +113,8 @@ async def get_task(task_id: uuid.UUID, db: AsyncSession = Depends(get_db)): task = await db.get(Task, task_id) if not task: raise HTTPException(404, "Task not found") - return task + enriched = await _enrich_tasks([task], db) + return enriched[0] @router.patch("/{task_id}", response_model=TaskOut) diff --git a/src/tracker/models/project.py b/src/tracker/models/project.py index a1a5204..f2f6142 100644 --- a/src/tracker/models/project.py +++ b/src/tracker/models/project.py @@ -3,7 +3,7 @@ import uuid from typing import TYPE_CHECKING -from sqlalchemy import String, Text +from sqlalchemy import Integer, String, Text from sqlalchemy.orm import Mapped, mapped_column, relationship from tracker.models.base import Base @@ -19,6 +19,8 @@ class Project(Base): name: Mapped[str] = mapped_column(String(255), nullable=False) slug: Mapped[str] = mapped_column(String(255), unique=True, nullable=False) description: Mapped[str | None] = mapped_column(Text) + key_prefix: Mapped[str] = mapped_column(String(10), nullable=False, default="PRJ") # e.g. "TEA", "DIC" + task_counter: Mapped[int] = mapped_column(Integer, default=0) # auto-increment for task numbers git_repo: Mapped[str | None] = mapped_column(String(500)) tasks: Mapped[list["Task"]] = relationship(back_populates="project", cascade="all, delete-orphan") diff --git a/src/tracker/models/task.py b/src/tracker/models/task.py index 5ceb155..5636c9f 100644 --- a/src/tracker/models/task.py +++ b/src/tracker/models/task.py @@ -26,6 +26,7 @@ class Task(Base): requires_pr: Mapped[bool] = mapped_column(Boolean, default=False) pr_url: Mapped[str | None] = mapped_column(String(500)) assigned_agent_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("agents.id")) + number: Mapped[int] = mapped_column(Integer, nullable=False, default=0) # project-scoped sequential number (TEA-1, TEA-2...) position: Mapped[int] = mapped_column(Integer, default=0) # ordering within column project: Mapped["Project"] = relationship(back_populates="tasks")