feat: task numbers (TEA-1, TEA-2...) + project key_prefix
All checks were successful
Deploy Tracker / deploy (push) Successful in 7s

- 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
This commit is contained in:
Markov 2026-02-15 20:30:20 +01:00
parent 37c2ac3717
commit 5f9ea80621
5 changed files with 107 additions and 7 deletions

View File

@ -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')

View File

@ -17,6 +17,7 @@ class ProjectCreate(BaseModel):
name: str name: str
slug: str slug: str
description: str | None = None description: str | None = None
key_prefix: str | None = None # auto-generated from name if not provided
git_repo: str | None = None git_repo: str | None = None
@ -31,6 +32,8 @@ class ProjectOut(BaseModel):
name: str name: str
slug: str slug: str
description: str | None description: str | None
key_prefix: str
task_counter: int
git_repo: str | None git_repo: str | None
model_config = {"from_attributes": True} 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) @router.post("/", response_model=ProjectOut, status_code=201)
async def create_project(data: ProjectCreate, db: AsyncSession = Depends(get_db)): 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) db.add(project)
await db.commit() await db.commit()
await db.refresh(project) await db.refresh(project)

View File

@ -9,7 +9,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload from sqlalchemy.orm import selectinload
from tracker.database import get_db from tracker.database import get_db
from tracker.models import Task from tracker.models import Task, Project
router = APIRouter(prefix="/tasks", tags=["tasks"]) router = APIRouter(prefix="/tasks", tags=["tasks"])
@ -46,11 +46,33 @@ class TaskOut(BaseModel):
requires_pr: bool requires_pr: bool
pr_url: str | None pr_url: str | None
assigned_agent_id: uuid.UUID | None assigned_agent_id: uuid.UUID | None
number: int
key: str | None = None # e.g. "TEA-1"
position: int position: int
model_config = {"from_attributes": True} 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]) @router.get("/", response_model=list[TaskOut])
async def list_tasks( async def list_tasks(
project_id: uuid.UUID | None = None, project_id: uuid.UUID | None = None,
@ -63,16 +85,27 @@ async def list_tasks(
if status: if status:
q = q.where(Task.status == status) q = q.where(Task.status == status)
result = await db.execute(q) 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) @router.post("/", response_model=TaskOut, status_code=201)
async def create_task(data: TaskCreate, db: AsyncSession = Depends(get_db)): 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) db.add(task)
await db.commit() await db.commit()
await db.refresh(task) 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) @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) task = await db.get(Task, task_id)
if not task: if not task:
raise HTTPException(404, "Task not found") raise HTTPException(404, "Task not found")
return task enriched = await _enrich_tasks([task], db)
return enriched[0]
@router.patch("/{task_id}", response_model=TaskOut) @router.patch("/{task_id}", response_model=TaskOut)

View File

@ -3,7 +3,7 @@
import uuid import uuid
from typing import TYPE_CHECKING 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 sqlalchemy.orm import Mapped, mapped_column, relationship
from tracker.models.base import Base from tracker.models.base import Base
@ -19,6 +19,8 @@ class Project(Base):
name: Mapped[str] = mapped_column(String(255), nullable=False) name: Mapped[str] = mapped_column(String(255), nullable=False)
slug: Mapped[str] = mapped_column(String(255), unique=True, nullable=False) slug: Mapped[str] = mapped_column(String(255), unique=True, nullable=False)
description: Mapped[str | None] = mapped_column(Text) 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)) git_repo: Mapped[str | None] = mapped_column(String(500))
tasks: Mapped[list["Task"]] = relationship(back_populates="project", cascade="all, delete-orphan") tasks: Mapped[list["Task"]] = relationship(back_populates="project", cascade="all, delete-orphan")

View File

@ -26,6 +26,7 @@ class Task(Base):
requires_pr: Mapped[bool] = mapped_column(Boolean, default=False) requires_pr: Mapped[bool] = mapped_column(Boolean, default=False)
pr_url: Mapped[str | None] = mapped_column(String(500)) 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")) 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 position: Mapped[int] = mapped_column(Integer, default=0) # ordering within column
project: Mapped["Project"] = relationship(back_populates="tasks") project: Mapped["Project"] = relationship(back_populates="tasks")