feat: task numbers (TEA-1, TEA-2...) + project key_prefix
All checks were successful
Deploy Tracker / deploy (push) Successful in 7s
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:
parent
37c2ac3717
commit
5f9ea80621
@ -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')
|
||||||
@ -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)
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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")
|
||||||
|
|||||||
@ -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")
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user