Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
"""add projects table and ticket project relation

Revision ID: 0003_projects_table_and_ticket_project
Revises: 0002_agents_table
Create Date: 2026-03-03
"""

from alembic import op
import sqlalchemy as sa


revision = '0003_projects_table_and_ticket_project'
down_revision = '0002_agents_table'
branch_labels = None
depends_on = None


def upgrade() -> None:
op.create_table(
'projects',
sa.Column('id', sa.Integer(), primary_key=True),
sa.Column('name', sa.String(length=120), nullable=False),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('is_active', sa.Boolean(), nullable=False, server_default=sa.text('true')),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False, server_default=sa.text('now()')),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False, server_default=sa.text('now()')),
sa.UniqueConstraint('name'),
)
op.create_index('ix_projects_name', 'projects', ['name'], unique=True)

op.execute("INSERT INTO projects (name, description, is_active) VALUES ('General', 'Default project', true)")

op.add_column('tickets', sa.Column('project_id', sa.Integer(), nullable=True))
op.execute('UPDATE tickets SET project_id = (SELECT id FROM projects WHERE name = \'General\' LIMIT 1)')
op.alter_column('tickets', 'project_id', nullable=False)
op.create_foreign_key('fk_tickets_project_id_projects', 'tickets', 'projects', ['project_id'], ['id'])


def downgrade() -> None:
op.drop_constraint('fk_tickets_project_id_projects', 'tickets', type_='foreignkey')
op.drop_column('tickets', 'project_id')
op.drop_index('ix_projects_name', table_name='projects')
op.drop_table('projects')
71 changes: 71 additions & 0 deletions apps/dashboard_api/src/api/projects.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session

from src.core.auth import CurrentUser, get_current_user, require_admin
from src.db.session import get_db
from src.models import Project, Ticket
from src.schemas.common import ProjectCreate, ProjectOut, ProjectUpdate
from src.services.audit import write_audit

router = APIRouter(prefix='/projects', tags=['projects'])


@router.get('', response_model=list[ProjectOut])
def list_projects(db: Session = Depends(get_db), _: CurrentUser = Depends(get_current_user)):
stmt = select(Project).order_by(Project.name.asc())
return list(db.execute(stmt).scalars())


@router.post('', response_model=ProjectOut)
def create_project(payload: ProjectCreate, db: Session = Depends(get_db), user: CurrentUser = Depends(require_admin)):
project = Project(name=payload.name, description=payload.description, is_active=payload.is_active)
db.add(project)
try:
db.commit()
except IntegrityError:
db.rollback()
raise HTTPException(409, 'Project name already exists')
db.refresh(project)
write_audit(db, user.id, 'PROJECT_CREATED', 'project', str(project.id), {'name': project.name})
db.commit()
return project


@router.put('/{project_id}', response_model=ProjectOut)
def update_project(project_id: int, payload: ProjectUpdate, db: Session = Depends(get_db), user: CurrentUser = Depends(require_admin)):
project = db.get(Project, project_id)
if not project:
raise HTTPException(404, 'Project not found')
project.name = payload.name
project.description = payload.description
project.is_active = payload.is_active
try:
db.commit()
except IntegrityError:
db.rollback()
raise HTTPException(409, 'Project name already exists')
db.refresh(project)
write_audit(db, user.id, 'PROJECT_UPDATED', 'project', str(project.id), {'name': project.name})
db.commit()
return project


@router.post('/{project_id}/update', response_model=ProjectOut)
def update_project_via_post(project_id: int, payload: ProjectUpdate, db: Session = Depends(get_db), user: CurrentUser = Depends(require_admin)):
return update_project(project_id=project_id, payload=payload, db=db, user=user)


@router.delete('/{project_id}')
def delete_project(project_id: int, db: Session = Depends(get_db), user: CurrentUser = Depends(require_admin)):
project = db.get(Project, project_id)
if not project:
raise HTTPException(404, 'Project not found')
used_ticket = db.execute(select(Ticket.id).where(Ticket.project_id == project.id).limit(1)).scalar_one_or_none()
if used_ticket is not None:
raise HTTPException(409, 'Project is referenced by existing tickets and cannot be deleted')
db.delete(project)
write_audit(db, user.id, 'PROJECT_DELETED', 'project', str(project.id), {'name': project.name})
db.commit()
return {'ok': True}
18 changes: 15 additions & 3 deletions apps/dashboard_api/src/api/tickets.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

from src.core.auth import CurrentUser, get_current_user, require_admin
from src.db.session import get_db
from src.models import Agent, Ticket, TicketPriority, TicketStatus
from src.models import Agent, Project, Ticket, TicketPriority, TicketStatus
from src.schemas.common import ActionPayload, TicketCreate, TicketOut, TicketUpdate
from src.services.audit import write_audit

Expand All @@ -19,10 +19,16 @@ def _ensure_agent_exists(db: Session, agent_name: str):
raise HTTPException(400, 'Assigned agent does not exist or is inactive')


def _ensure_project_exists(db: Session, project_id: int):
project = db.execute(select(Project).where(Project.id == project_id, Project.is_active.is_(True))).scalar_one_or_none()
if not project:
raise HTTPException(400, 'Project does not exist or is inactive')


@router.post('', response_model=TicketOut)
def create_ticket(payload: TicketCreate, db: Session = Depends(get_db), user: CurrentUser = Depends(get_current_user)):
_ensure_agent_exists(db, payload.assigned_agent)
_ensure_project_exists(db, payload.project_id)

ticket = Ticket(
title=payload.title,
Expand All @@ -31,11 +37,12 @@ def create_ticket(payload: TicketCreate, db: Session = Depends(get_db), user: Cu
priority=payload.priority,
status=TicketStatus.PENDING_APPROVAL,
created_by=user.id,
project_id=payload.project_id,
assigned_agent=payload.assigned_agent,
metadata_json=payload.metadata_json,
)
db.add(ticket)
write_audit(db, user.id, 'TICKET_CREATED', 'ticket', 'new', {'title': payload.title})
write_audit(db, user.id, 'TICKET_CREATED', 'ticket', 'new', {'title': payload.title, 'project_id': payload.project_id})
db.commit()
db.refresh(ticket)
return ticket
Expand All @@ -46,6 +53,7 @@ def list_tickets(
status: TicketStatus | None = Query(default=None),
priority: TicketPriority | None = Query(default=None),
assigned_agent: str | None = Query(default=None),
project_id: int | None = Query(default=None),
type: str | None = Query(default=None),
db: Session = Depends(get_db),
_: CurrentUser = Depends(get_current_user),
Expand All @@ -57,6 +65,8 @@ def list_tickets(
stmt = stmt.where(Ticket.priority == priority)
if assigned_agent:
stmt = stmt.where(Ticket.assigned_agent == assigned_agent)
if project_id:
stmt = stmt.where(Ticket.project_id == project_id)
if type:
stmt = stmt.where(Ticket.type == type)
return list(db.execute(stmt.order_by(Ticket.id.desc())).scalars())
Expand All @@ -71,15 +81,17 @@ def update_ticket(ticket_id: int, payload: TicketUpdate, db: Session = Depends(g
_ensure_update_permission(ticket, user)

_ensure_agent_exists(db, payload.assigned_agent)
_ensure_project_exists(db, payload.project_id)

ticket.title = payload.title
ticket.description = payload.description
ticket.type = payload.type
ticket.priority = payload.priority
ticket.project_id = payload.project_id
ticket.assigned_agent = payload.assigned_agent
ticket.metadata_json = payload.metadata_json

write_audit(db, user.id, 'TICKET_UPDATED', 'ticket', str(ticket.id), {'title': payload.title})
write_audit(db, user.id, 'TICKET_UPDATED', 'ticket', str(ticket.id), {'title': payload.title, 'project_id': payload.project_id})
db.commit()
db.refresh(ticket)
return ticket
Expand Down
3 changes: 3 additions & 0 deletions apps/dashboard_api/src/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

from src.api.agents import router as agents_router
from src.api.audit import router as audit_router
from src.api.projects import router as projects_router
from src.api.runs import router as runs_router
from src.api.tickets import router as tickets_router
from src.core.config import settings
Expand Down Expand Up @@ -32,3 +33,5 @@ def version():
app.include_router(agents_router)
app.include_router(runs_router)
app.include_router(audit_router)

app.include_router(projects_router)
4 changes: 2 additions & 2 deletions apps/dashboard_api/src/models/__init__.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
from src.models.entities import Agent, AuditLog, Permission, Role, RolePermission, Run, RunStatus, Ticket, TicketPriority, TicketStatus, User, UserRole
from src.models.entities import Agent, AuditLog, Permission, Project, Role, RolePermission, Run, RunStatus, Ticket, TicketPriority, TicketStatus, User, UserRole

__all__ = [
'User', 'Role', 'Permission', 'UserRole', 'RolePermission', 'Agent', 'Ticket', 'Run', 'AuditLog',
'User', 'Role', 'Permission', 'UserRole', 'RolePermission', 'Agent', 'Project', 'Ticket', 'Run', 'AuditLog',
'TicketStatus', 'TicketPriority', 'RunStatus'
]
13 changes: 13 additions & 0 deletions apps/dashboard_api/src/models/entities.py
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,17 @@ class Agent(Base):
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=datetime.utcnow, onupdate=datetime.utcnow)


class Project(Base):
__tablename__ = 'projects'

id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(String(120), unique=True, index=True)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=datetime.utcnow)
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=datetime.utcnow, onupdate=datetime.utcnow)


class Ticket(Base):
__tablename__ = 'tickets'

Expand All @@ -94,6 +105,7 @@ class Ticket(Base):
status: Mapped[TicketStatus] = mapped_column(Enum(TicketStatus, create_type=False), default=TicketStatus.PENDING_APPROVAL)
priority: Mapped[TicketPriority] = mapped_column(Enum(TicketPriority, create_type=False), default=TicketPriority.P2)
created_by: Mapped[int] = mapped_column(ForeignKey('users.id'))
project_id: Mapped[int] = mapped_column(ForeignKey('projects.id'))
assigned_agent: Mapped[str] = mapped_column(String(120), default='dashboard-dev')
approved_by: Mapped[int | None] = mapped_column(ForeignKey('users.id'), nullable=True)
approved_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
Expand All @@ -105,6 +117,7 @@ class Ticket(Base):
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=datetime.utcnow, onupdate=datetime.utcnow)

runs: Mapped[list['Run']] = relationship(back_populates='ticket')
project: Mapped['Project'] = relationship()


class Run(Base):
Expand Down
24 changes: 24 additions & 0 deletions apps/dashboard_api/src/schemas/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ class TicketOut(BaseModel):
status: TicketStatus
priority: TicketPriority
created_by: int
project_id: int
assigned_agent: str
approved_by: int | None
approved_at: datetime | None
Expand All @@ -33,6 +34,7 @@ class TicketCreate(BaseModel):
description: str
type: TicketType
priority: TicketPriority = TicketPriority.P2
project_id: int
assigned_agent: str = 'dashboard-dev'
metadata_json: dict | None = None

Expand All @@ -42,6 +44,7 @@ class TicketUpdate(BaseModel):
description: str
type: TicketType
priority: TicketPriority
project_id: int
assigned_agent: str
metadata_json: dict | None = None

Expand Down Expand Up @@ -99,3 +102,24 @@ class AgentUpdate(BaseModel):
name: str
description: str | None = None
is_active: bool


class ProjectOut(BaseModel):
id: int
name: str
description: str | None = None
is_active: bool

model_config = {'from_attributes': True}


class ProjectCreate(BaseModel):
name: str
description: str | None = None
is_active: bool = True


class ProjectUpdate(BaseModel):
name: str
description: str | None = None
is_active: bool
6 changes: 5 additions & 1 deletion apps/dashboard_api/tests/test_api_smoke.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,13 @@
from fastapi import HTTPException

from src.api.agents import create_agent
from src.api.projects import create_project
from src.api.runs import finish_run, run_next
from src.api.tickets import approve_ticket, create_ticket, get_ticket, queue_ticket, update_ticket
from src.core.auth import CurrentUser, require_admin
from src.main import health
from src.models import TicketStatus
from src.schemas.common import AgentCreate, RunFinishPayload, TicketCreate, TicketUpdate
from src.schemas.common import AgentCreate, ProjectCreate, RunFinishPayload, TicketCreate, TicketUpdate

ADMIN = CurrentUser(id=1, role='Admin')
MEMBER = CurrentUser(id=2, role='Member')
Expand All @@ -16,12 +17,14 @@

def create_base_ticket(db_session):
create_agent(AgentCreate(name='agent-alpha', description='demo', is_active=True), db_session, ADMIN)
project = create_project(ProjectCreate(name='project-alpha', description='demo', is_active=True), db_session, ADMIN)
return create_ticket(
TicketCreate(
title='Smoke test ticket',
description='Validate major workflow',
type='Bug',
priority='P1',
project_id=project.id,
assigned_agent='agent-alpha',
metadata_json={'source': 'test'},
),
Expand Down Expand Up @@ -78,6 +81,7 @@ def test_member_cannot_update_others_ticket(db_session):
description='changed',
type='Task',
priority='P2',
project_id=ticket.project_id,
assigned_agent='agent-alpha',
metadata_json={'changed': True},
),
Expand Down
30 changes: 14 additions & 16 deletions apps/dashboard_web/app/agents/page.js
Original file line number Diff line number Diff line change
Expand Up @@ -100,22 +100,20 @@ export default function AgentsPage() {
<div className="ticket-title">{agent.name}</div>
<div className="ticket-meta">{agent.is_active ? 'Active' : 'Inactive'}</div>
{agent.description && <div style={{ whiteSpace: 'pre-wrap' }}>{agent.description}</div>}
<div className="controls">
<button
className="btn btn-secondary"
onClick={() => {
if (!isAdmin) {
setError('You do not have permission to edit agents. Please login as admin.');
return;
}
setEditingId(agent.id);
setForm({ name: agent.name, description: agent.description || '', is_active: agent.is_active });
}}
>
Edit
</button>
<button className="btn btn-danger" onClick={() => deleteAgent(agent.id)}>Delete</button>
</div>
{isAdmin && (
<div className="controls">
<button
className="btn btn-secondary"
onClick={() => {
setEditingId(agent.id);
setForm({ name: agent.name, description: agent.description || '', is_active: agent.is_active });
}}
>
Edit
</button>
<button className="btn btn-danger" onClick={() => deleteAgent(agent.id)}>Delete</button>
</div>
)}
</article>
))}
</div>
Expand Down
1 change: 1 addition & 0 deletions apps/dashboard_web/app/components/nav-links.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ const links = [
{ href: '/tickets', label: 'Tickets' },
{ href: '/runs', label: 'Runs' },
{ href: '/agents', label: 'Agents' },
{ href: '/projects', label: 'Projects' },
];

export default function NavLinks() {
Expand Down
Loading