diff --git a/apps/dashboard_api/alembic/versions/0003_projects_table_and_ticket_project.py b/apps/dashboard_api/alembic/versions/0003_projects_table_and_ticket_project.py new file mode 100644 index 0000000..16f54a8 --- /dev/null +++ b/apps/dashboard_api/alembic/versions/0003_projects_table_and_ticket_project.py @@ -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') diff --git a/apps/dashboard_api/src/api/projects.py b/apps/dashboard_api/src/api/projects.py new file mode 100644 index 0000000..bb023ca --- /dev/null +++ b/apps/dashboard_api/src/api/projects.py @@ -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} diff --git a/apps/dashboard_api/src/api/tickets.py b/apps/dashboard_api/src/api/tickets.py index 5bca23b..71da7fa 100644 --- a/apps/dashboard_api/src/api/tickets.py +++ b/apps/dashboard_api/src/api/tickets.py @@ -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 @@ -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, @@ -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 @@ -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), @@ -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()) @@ -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 diff --git a/apps/dashboard_api/src/main.py b/apps/dashboard_api/src/main.py index 360c762..95b46c3 100644 --- a/apps/dashboard_api/src/main.py +++ b/apps/dashboard_api/src/main.py @@ -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 @@ -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) diff --git a/apps/dashboard_api/src/models/__init__.py b/apps/dashboard_api/src/models/__init__.py index dba1847..e90c32c 100644 --- a/apps/dashboard_api/src/models/__init__.py +++ b/apps/dashboard_api/src/models/__init__.py @@ -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' ] diff --git a/apps/dashboard_api/src/models/entities.py b/apps/dashboard_api/src/models/entities.py index 3975c2c..85d66c8 100644 --- a/apps/dashboard_api/src/models/entities.py +++ b/apps/dashboard_api/src/models/entities.py @@ -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' @@ -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) @@ -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): diff --git a/apps/dashboard_api/src/schemas/common.py b/apps/dashboard_api/src/schemas/common.py index db64b6b..63bf5a8 100644 --- a/apps/dashboard_api/src/schemas/common.py +++ b/apps/dashboard_api/src/schemas/common.py @@ -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 @@ -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 @@ -42,6 +44,7 @@ class TicketUpdate(BaseModel): description: str type: TicketType priority: TicketPriority + project_id: int assigned_agent: str metadata_json: dict | None = None @@ -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 diff --git a/apps/dashboard_api/tests/test_api_smoke.py b/apps/dashboard_api/tests/test_api_smoke.py index a8928b9..f3c578e 100644 --- a/apps/dashboard_api/tests/test_api_smoke.py +++ b/apps/dashboard_api/tests/test_api_smoke.py @@ -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') @@ -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'}, ), @@ -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}, ), diff --git a/apps/dashboard_web/app/agents/page.js b/apps/dashboard_web/app/agents/page.js index adc8859..2a569da 100644 --- a/apps/dashboard_web/app/agents/page.js +++ b/apps/dashboard_web/app/agents/page.js @@ -100,22 +100,20 @@ export default function AgentsPage() {
{agent.name}
{agent.is_active ? 'Active' : 'Inactive'}
{agent.description &&
{agent.description}
} -
- - -
+ {isAdmin && ( +
+ + +
+ )} ))} diff --git a/apps/dashboard_web/app/components/nav-links.js b/apps/dashboard_web/app/components/nav-links.js index 47aa66c..95e20ba 100644 --- a/apps/dashboard_web/app/components/nav-links.js +++ b/apps/dashboard_web/app/components/nav-links.js @@ -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() { diff --git a/apps/dashboard_web/app/projects/page.js b/apps/dashboard_web/app/projects/page.js new file mode 100644 index 0000000..170c81a --- /dev/null +++ b/apps/dashboard_web/app/projects/page.js @@ -0,0 +1,123 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { useRouter } from 'next/navigation'; +import { apiFetch, getRole, getToken } from '../../lib/api'; + +const emptyForm = { name: '', description: '', is_active: true }; + +export default function ProjectsPage() { + const router = useRouter(); + const [projects, setProjects] = useState([]); + const [form, setForm] = useState(emptyForm); + const [editingId, setEditingId] = useState(null); + const [error, setError] = useState(''); + const [isAdmin, setIsAdmin] = useState(false); + const [authReady, setAuthReady] = useState(false); + + useEffect(() => { + const token = getToken(); + setIsAdmin(getRole() === 'admin'); + setAuthReady(true); + + if (!token) { + router.push('/login'); + return; + } + loadProjects().catch((err) => setError(String(err.message))); + }, [router]); + + async function loadProjects() { + const data = await apiFetch('/projects'); + setProjects(data); + } + + async function submitForm(e) { + e.preventDefault(); + if (!isAdmin) { + setError('Only admin can manage projects.'); + return; + } + setError(''); + try { + if (editingId) { + await apiFetch(`/projects/${editingId}`, { method: 'PUT', body: JSON.stringify(form) }); + } else { + await apiFetch('/projects', { method: 'POST', body: JSON.stringify(form) }); + } + setForm(emptyForm); + setEditingId(null); + await loadProjects(); + } catch (err) { + setError(String(err.message)); + } + } + + async function deleteProject(id) { + if (!isAdmin) { + setError('You do not have permission to delete projects. Please login as admin.'); + return; + } + try { + await apiFetch(`/projects/${id}`, { method: 'DELETE' }); + await loadProjects(); + } catch (err) { + setError(String(err.message)); + } + } + + return ( +
+
+

Project Management

+

Use project tags to classify tickets by department or business line.

+ {authReady && !isAdmin &&

Your account is read-only on this page. Please login as admin to manage projects.

} +
+ + {authReady && isAdmin && ( +
+

{editingId ? 'Edit project' : 'Create project'}

+ setForm({ ...form, name: e.target.value })} required /> +