diff --git a/.gitignore b/.gitignore index db1672d..0a85f24 100644 --- a/.gitignore +++ b/.gitignore @@ -13,8 +13,9 @@ # Python & FastAPI # ------------------------------------------------------------------------------ __pycache__/ -*.py[cod] -*$py.class +*.pyc +*.pyo +*.pyd *.so .Python build/ diff --git a/apps/api/core/dependencies/rbac.py b/apps/api/core/dependencies/rbac.py new file mode 100644 index 0000000..fbe5df2 --- /dev/null +++ b/apps/api/core/dependencies/rbac.py @@ -0,0 +1,35 @@ +from typing import Callable, Sequence +from fastapi import Depends, HTTPException, status + +# pyrefly: ignore [missing-import] +from apps.api.modules.users.models import User, Role +# pyrefly: ignore [missing-import] +from apps.api.core.dependencies.auth import get_current_active_user + +class RequireRoles: + """ + Dependency class to verify that the current user possesses one of the allowed roles. + Relies on get_current_active_user to ensure the user is authenticated and active. + """ + def __init__(self, allowed_roles: Sequence[Role]): + self.allowed_roles = allowed_roles + + def __call__(self, current_user: User = Depends(get_current_active_user)) -> User: + if current_user.role not in self.allowed_roles: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Not enough permissions" + ) + return current_user + +def require_roles(*roles: Role) -> Callable: + """Returns a dependency that requires the user to have one of the specified roles.""" + return RequireRoles(roles) + +def require_admin() -> Callable: + """Dependency that restricts access to the ADMIN role only.""" + return RequireRoles([Role.ADMIN]) + +def require_technician() -> Callable: + """Dependency that restricts access to TECHNICIAN or ADMIN roles.""" + return RequireRoles([Role.ADMIN, Role.TECHNICIAN]) diff --git a/apps/api/infrastructure/db/migrations/versions/002_create_tickets_table.py b/apps/api/infrastructure/db/migrations/versions/002_create_tickets_table.py new file mode 100644 index 0000000..26e2af4 --- /dev/null +++ b/apps/api/infrastructure/db/migrations/versions/002_create_tickets_table.py @@ -0,0 +1,62 @@ +"""create tickets table + +Revision ID: 002_create_tickets_table +Revises: 001_create_users_table +Create Date: 2026-07-24 16:00:00.000000 + +""" +from typing import Sequence, Union +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +revision: str = '002_create_tickets_table' +down_revision: Union[str, None] = '001_create_users_table' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + +def upgrade() -> None: + # Create enums + ticket_status_enum = postgresql.ENUM('OPEN', 'IN_PROGRESS', 'CLOSED', name='ticketstatus', create_type=False) + ticket_status_enum.create(op.get_bind(), checkfirst=True) + + ticket_priority_enum = postgresql.ENUM('LOW', 'MEDIUM', 'HIGH', name='ticketpriority', create_type=False) + ticket_priority_enum.create(op.get_bind(), checkfirst=True) + + # Create table + op.create_table('tickets', + sa.Column('id', sa.Uuid(), nullable=False), + sa.Column('title', sa.String(length=200), nullable=False), + sa.Column('description', sa.Text(), nullable=False), + sa.Column('category', sa.String(length=100), nullable=True), + sa.Column('priority', postgresql.ENUM('LOW', 'MEDIUM', 'HIGH', name='ticketpriority', create_type=False), nullable=False), + sa.Column('status', postgresql.ENUM('OPEN', 'IN_PROGRESS', 'CLOSED', name='ticketstatus', create_type=False), nullable=False), + sa.Column('created_by', sa.Uuid(), nullable=False), + sa.Column('assigned_to', sa.Uuid(), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.ForeignKeyConstraint(['assigned_to'], ['users.id'], ), + sa.ForeignKeyConstraint(['created_by'], ['users.id'], ), + sa.PrimaryKeyConstraint('id') + ) + + # Create indexes + op.create_index(op.f('ix_tickets_assigned_to'), 'tickets', ['assigned_to'], unique=False) + op.create_index(op.f('ix_tickets_created_at'), 'tickets', ['created_at'], unique=False) + op.create_index(op.f('ix_tickets_created_by'), 'tickets', ['created_by'], unique=False) + op.create_index(op.f('ix_tickets_priority'), 'tickets', ['priority'], unique=False) + op.create_index(op.f('ix_tickets_status'), 'tickets', ['status'], unique=False) + +def downgrade() -> None: + op.drop_index(op.f('ix_tickets_status'), table_name='tickets') + op.drop_index(op.f('ix_tickets_priority'), table_name='tickets') + op.drop_index(op.f('ix_tickets_created_by'), table_name='tickets') + op.drop_index(op.f('ix_tickets_created_at'), table_name='tickets') + op.drop_index(op.f('ix_tickets_assigned_to'), table_name='tickets') + op.drop_table('tickets') + + ticket_priority_enum = postgresql.ENUM('LOW', 'MEDIUM', 'HIGH', name='ticketpriority', create_type=False) + ticket_priority_enum.drop(op.get_bind(), checkfirst=True) + + ticket_status_enum = postgresql.ENUM('OPEN', 'IN_PROGRESS', 'CLOSED', name='ticketstatus', create_type=False) + ticket_status_enum.drop(op.get_bind(), checkfirst=True) diff --git a/apps/api/infrastructure/db/migrations/versions/003_create_assignment_history.py b/apps/api/infrastructure/db/migrations/versions/003_create_assignment_history.py new file mode 100644 index 0000000..529a0f8 --- /dev/null +++ b/apps/api/infrastructure/db/migrations/versions/003_create_assignment_history.py @@ -0,0 +1,48 @@ +"""create ticket assignment history + +Revision ID: 003_create_assignment_history +Revises: 002_create_tickets_table +Create Date: 2026-07-24 16:15:00.000000 + +""" +from typing import Sequence, Union +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +revision: str = '003_create_assignment_history' +down_revision: Union[str, None] = '002_create_tickets_table' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + +def upgrade() -> None: + # Create enum + assignment_action_enum = postgresql.ENUM('ASSIGNED', 'REASSIGNED', 'UNASSIGNED', name='assignmentactiontype', create_type=False) + assignment_action_enum.create(op.get_bind(), checkfirst=True) + + # Create table + op.create_table('ticket_assignment_history', + sa.Column('id', sa.Uuid(), nullable=False), + sa.Column('ticket_id', sa.Uuid(), nullable=False), + sa.Column('assigned_from', sa.Uuid(), nullable=True), + sa.Column('assigned_to', sa.Uuid(), nullable=True), + sa.Column('action_type', postgresql.ENUM('ASSIGNED', 'REASSIGNED', 'UNASSIGNED', name='assignmentactiontype', create_type=False), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.ForeignKeyConstraint(['assigned_from'], ['users.id'], ), + sa.ForeignKeyConstraint(['assigned_to'], ['users.id'], ), + sa.ForeignKeyConstraint(['ticket_id'], ['tickets.id'], ), + sa.PrimaryKeyConstraint('id') + ) + + # Create indexes + op.create_index(op.f('ix_ticket_assignment_history_created_at'), 'ticket_assignment_history', ['created_at'], unique=False) + op.create_index(op.f('ix_ticket_assignment_history_ticket_id'), 'ticket_assignment_history', ['ticket_id'], unique=False) + +def downgrade() -> None: + op.drop_index(op.f('ix_ticket_assignment_history_ticket_id'), table_name='ticket_assignment_history') + op.drop_index(op.f('ix_ticket_assignment_history_created_at'), table_name='ticket_assignment_history') + op.drop_table('ticket_assignment_history') + + assignment_action_enum = postgresql.ENUM('ASSIGNED', 'REASSIGNED', 'UNASSIGNED', name='assignmentactiontype', create_type=False) + assignment_action_enum.drop(op.get_bind(), checkfirst=True) diff --git a/apps/api/infrastructure/db/migrations/versions/004_add_pending_resolved_ticket_statuses.py b/apps/api/infrastructure/db/migrations/versions/004_add_pending_resolved_ticket_statuses.py new file mode 100644 index 0000000..e42e9a2 --- /dev/null +++ b/apps/api/infrastructure/db/migrations/versions/004_add_pending_resolved_ticket_statuses.py @@ -0,0 +1,26 @@ +"""add pending resolved ticket statuses + +Revision ID: 004_add_pending_resolved +Revises: 003_create_assignment_history +Create Date: 2026-07-24 16:25:00.000000 + +""" +from typing import Sequence, Union +from alembic import op +import sqlalchemy as sa + +revision: str = '004_add_pending_resolved' +down_revision: Union[str, None] = '003_create_assignment_history' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + +def upgrade() -> None: + # Disable transaction block for ALTER TYPE + with op.get_context().autocommit_block(): + op.execute("ALTER TYPE ticketstatus ADD VALUE IF NOT EXISTS 'PENDING'") + op.execute("ALTER TYPE ticketstatus ADD VALUE IF NOT EXISTS 'RESOLVED'") + +def downgrade() -> None: + # PostgreSQL does not natively support dropping ENUM values. + # Dropping them requires dropping the type and recreating it, which is complex and risky. + pass diff --git a/apps/api/infrastructure/db/models.py b/apps/api/infrastructure/db/models.py index 17327a4..e8572bf 100644 --- a/apps/api/infrastructure/db/models.py +++ b/apps/api/infrastructure/db/models.py @@ -7,5 +7,6 @@ # Import all models below: from apps.api.modules.users.models import User +from apps.api.modules.tickets.models import Ticket, TicketAssignmentHistory # Future models (Tickets, Dashboard, etc.) will be imported here. diff --git a/apps/api/main.py b/apps/api/main.py index e9a8820..d258ad5 100644 --- a/apps/api/main.py +++ b/apps/api/main.py @@ -7,8 +7,12 @@ # TODO: Add exception handlers from apps.api.modules.auth.router import router as auth_router +from apps.api.modules.tickets.router import router as tickets_router +from apps.api.modules.dashboard.router import router as dashboard_router app.include_router(auth_router, prefix="/api/v1/auth") +app.include_router(tickets_router, prefix="/api/v1/tickets") +app.include_router(dashboard_router, prefix="/api/v1/dashboard") @app.get("/") def read_root(): diff --git a/apps/api/modules/dashboard/repository.py b/apps/api/modules/dashboard/repository.py new file mode 100644 index 0000000..7739bd0 --- /dev/null +++ b/apps/api/modules/dashboard/repository.py @@ -0,0 +1,121 @@ +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select, func, union_all, literal, cast, String, desc, case +from sqlalchemy.exc import SQLAlchemyError +from apps.api.modules.tickets.models import Ticket, TicketAssignmentHistory, TicketStatus, TicketPriority + +class DashboardRepository: + + @staticmethod + async def get_statistics(db: AsyncSession): + try: + total_res = await db.execute(select(func.count(Ticket.id))) + total_tickets = total_res.scalar_one() + + status_res = await db.execute(select(Ticket.status, func.count(Ticket.id)).group_by(Ticket.status)) + + status_counts = {status.value: 0 for status in TicketStatus} + for row in status_res.all(): + status_counts[row[0].value] = row[1] + + by_status = [{"status": k, "count": v} for k, v in status_counts.items()] + + priority_res = await db.execute(select(Ticket.priority, func.count(Ticket.id)).group_by(Ticket.priority)) + + priority_counts = {priority.value: 0 for priority in TicketPriority} + for row in priority_res.all(): + priority_counts[row[0].value] = row[1] + + by_priority = [{"priority": k, "count": v} for k, v in priority_counts.items()] + + return { + "total_tickets": total_tickets, + "by_status": by_status, + "by_priority": by_priority + } + except SQLAlchemyError: + await db.rollback() + raise + + @staticmethod + async def get_recent_activity(db: AsyncSession, limit: int = 10, skip: int = 0): + try: + query_tickets = select( + Ticket.id.label('entity_id'), + literal("TICKET_CREATED").label('action'), + Ticket.created_by.label('actor_id'), + Ticket.created_at.label('timestamp') + ) + + query_assignments = select( + TicketAssignmentHistory.ticket_id.label('entity_id'), + cast(TicketAssignmentHistory.action_type, String).label('action'), + TicketAssignmentHistory.assigned_from.label('actor_id'), + TicketAssignmentHistory.created_at.label('timestamp') + ) + + union_query = union_all(query_tickets, query_assignments).subquery() + + stmt = ( + select( + union_query.c.entity_id, + union_query.c.action, + union_query.c.actor_id, + union_query.c.timestamp + ) + .order_by(desc(union_query.c.timestamp)) + .offset(skip) + .limit(limit) + ) + + result = await db.execute(stmt) + + activities = [] + for row in result.all(): + activities.append({ + "entity_id": row.entity_id, + "action": row.action, + "actor_id": row.actor_id, + "timestamp": row.timestamp + }) + + return activities + except SQLAlchemyError: + await db.rollback() + raise + + @staticmethod + async def get_summary_cards(db: AsyncSession): + try: + stmt = select( + func.count(Ticket.id).label("total"), + func.sum(case((Ticket.status == TicketStatus.OPEN, 1), else_=0)).label("open"), + func.sum(case((Ticket.status == TicketStatus.IN_PROGRESS, 1), else_=0)).label("in_progress"), + func.sum(case((Ticket.status == TicketStatus.RESOLVED, 1), else_=0)).label("resolved"), + func.sum(case((Ticket.status == TicketStatus.CLOSED, 1), else_=0)).label("closed"), + func.sum(case((Ticket.priority == TicketPriority.HIGH, 1), else_=0)).label("high") + ) + + result = await db.execute(stmt) + row = result.first() + + if not row: + return { + "total_tickets": 0, + "open_tickets": 0, + "in_progress_tickets": 0, + "resolved_tickets": 0, + "closed_tickets": 0, + "high_priority_tickets": 0 + } + + return { + "total_tickets": row.total or 0, + "open_tickets": row.open or 0, + "in_progress_tickets": row.in_progress or 0, + "resolved_tickets": row.resolved or 0, + "closed_tickets": row.closed or 0, + "high_priority_tickets": row.high or 0 + } + except SQLAlchemyError: + await db.rollback() + raise diff --git a/apps/api/modules/dashboard/router.py b/apps/api/modules/dashboard/router.py index 2d1b1bc..8b89596 100644 --- a/apps/api/modules/dashboard/router.py +++ b/apps/api/modules/dashboard/router.py @@ -1,5 +1,34 @@ -from fastapi import APIRouter +from fastapi import APIRouter, Depends, Query +from sqlalchemy.ext.asyncio import AsyncSession +from typing import List -router = APIRouter(prefix="/dashboard", tags=["dashboard"]) +from apps.api.infrastructure.db.session import get_db +from apps.api.core.dependencies.auth import get_current_active_user +from apps.api.modules.users.models import User +from apps.api.modules.dashboard.schemas import DashboardStatistics, DashboardSummary, RecentActivityRead +from apps.api.modules.dashboard.service import DashboardService -# TODO: Implement dashboard endpoints +router = APIRouter(tags=["Dashboard"]) + +@router.get("/statistics", response_model=DashboardStatistics) +async def get_statistics( + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_active_user) +): + return await DashboardService.get_statistics(db, current_user) + +@router.get("/activity", response_model=List[RecentActivityRead]) +async def get_recent_activity( + skip: int = Query(0, ge=0), + limit: int = Query(10, ge=1, le=100), + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_active_user) +): + return await DashboardService.get_recent_activity(db, limit, skip, current_user) + +@router.get("/summary", response_model=DashboardSummary) +async def get_summary_cards( + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_active_user) +): + return await DashboardService.get_summary_cards(db, current_user) diff --git a/apps/api/modules/dashboard/schemas.py b/apps/api/modules/dashboard/schemas.py index 75f8264..3afb0c8 100644 --- a/apps/api/modules/dashboard/schemas.py +++ b/apps/api/modules/dashboard/schemas.py @@ -1,3 +1,31 @@ from pydantic import BaseModel +from typing import List, Dict +import uuid +from datetime import datetime -# TODO: Define dashboard Pydantic schemas +class StatusCount(BaseModel): + status: str + count: int + +class PriorityCount(BaseModel): + priority: str + count: int + +class DashboardStatistics(BaseModel): + total_tickets: int + by_status: List[StatusCount] + by_priority: List[PriorityCount] + +class DashboardSummary(BaseModel): + total_tickets: int + open_tickets: int + in_progress_tickets: int + resolved_tickets: int + closed_tickets: int + high_priority_tickets: int + +class RecentActivityRead(BaseModel): + entity_id: uuid.UUID + action: str + actor_id: uuid.UUID | None + timestamp: datetime diff --git a/apps/api/modules/dashboard/service.py b/apps/api/modules/dashboard/service.py index 64f05e5..e6fc66c 100644 --- a/apps/api/modules/dashboard/service.py +++ b/apps/api/modules/dashboard/service.py @@ -1 +1,17 @@ -# TODO: Implement dashboard business logic +from sqlalchemy.ext.asyncio import AsyncSession +from apps.api.modules.dashboard.repository import DashboardRepository +from apps.api.modules.users.models import User + +class DashboardService: + + @staticmethod + async def get_statistics(db: AsyncSession, current_user: User): + return await DashboardRepository.get_statistics(db) + + @staticmethod + async def get_recent_activity(db: AsyncSession, limit: int, skip: int, current_user: User): + return await DashboardRepository.get_recent_activity(db, limit, skip) + + @staticmethod + async def get_summary_cards(db: AsyncSession, current_user: User): + return await DashboardRepository.get_summary_cards(db) diff --git a/apps/api/modules/tickets/models.py b/apps/api/modules/tickets/models.py index 8d25be3..e23ad56 100644 --- a/apps/api/modules/tickets/models.py +++ b/apps/api/modules/tickets/models.py @@ -1 +1,54 @@ -# TODO: Define tickets database models +import enum +import uuid +from sqlalchemy import String, Enum, Text, ForeignKey, Index +from sqlalchemy.orm import Mapped, mapped_column, relationship +# pyrefly: ignore [missing-import] +from apps.api.infrastructure.db.base import Base + +class TicketStatus(str, enum.Enum): + OPEN = "OPEN" + IN_PROGRESS = "IN_PROGRESS" + PENDING = "PENDING" + RESOLVED = "RESOLVED" + CLOSED = "CLOSED" + +class TicketPriority(str, enum.Enum): + LOW = "LOW" + MEDIUM = "MEDIUM" + HIGH = "HIGH" + +class Ticket(Base): + __tablename__ = "tickets" + __table_args__ = ( + Index("ix_tickets_created_at", "created_at"), + ) + + title: Mapped[str] = mapped_column(String(200), nullable=False) + description: Mapped[str] = mapped_column(Text, nullable=False) + category: Mapped[str | None] = mapped_column(String(100), nullable=True) + priority: Mapped[TicketPriority] = mapped_column(Enum(TicketPriority), index=True, nullable=False) + status: Mapped[TicketStatus] = mapped_column(Enum(TicketStatus), index=True, default=TicketStatus.OPEN, nullable=False) + + created_by: Mapped[uuid.UUID] = mapped_column(ForeignKey("users.id"), index=True, nullable=False) + assigned_to: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("users.id"), index=True, nullable=True) + + creator: Mapped["User"] = relationship("User", foreign_keys=[created_by]) + assignee: Mapped["User"] = relationship("User", foreign_keys=[assigned_to]) + +class AssignmentActionType(str, enum.Enum): + ASSIGNED = "ASSIGNED" + REASSIGNED = "REASSIGNED" + UNASSIGNED = "UNASSIGNED" + +class TicketAssignmentHistory(Base): + __tablename__ = "ticket_assignment_history" + __table_args__ = ( + Index("ix_ticket_assignment_history_ticket_id", "ticket_id"), + Index("ix_ticket_assignment_history_created_at", "created_at"), + ) + + ticket_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("tickets.id"), nullable=False) + assigned_from: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("users.id"), nullable=True) + assigned_to: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("users.id"), nullable=True) + action_type: Mapped[AssignmentActionType] = mapped_column(Enum(AssignmentActionType), nullable=False) + diff --git a/apps/api/modules/tickets/repository.py b/apps/api/modules/tickets/repository.py new file mode 100644 index 0000000..f4abb01 --- /dev/null +++ b/apps/api/modules/tickets/repository.py @@ -0,0 +1,190 @@ +from __future__ import annotations +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select, or_ +from sqlalchemy.orm import selectinload +from sqlalchemy.exc import SQLAlchemyError +import uuid + +# pyrefly: ignore [missing-import] +from apps.api.modules.tickets.models import Ticket, TicketStatus, TicketPriority, TicketAssignmentHistory, AssignmentActionType +from apps.api.modules.users.models import User +# pyrefly: ignore [missing-import] +from apps.api.modules.tickets.schemas import TicketCreate, TicketUpdate + +class TicketRepository: + """Handles data access logic for Tickets.""" + + @staticmethod + async def create(db: AsyncSession, ticket_in: TicketCreate, created_by: uuid.UUID) -> Ticket: + try: + db_ticket = Ticket( + **ticket_in.model_dump(), + created_by=created_by + ) + db.add(db_ticket) + await db.commit() + await db.refresh(db_ticket) + return db_ticket + except SQLAlchemyError: + await db.rollback() + raise + + @staticmethod + async def get(db: AsyncSession, ticket_id: uuid.UUID) -> Ticket | None: + try: + result = await db.execute( + select(Ticket) + .options(selectinload(Ticket.creator), selectinload(Ticket.assignee)) + .where(Ticket.id == ticket_id) + ) + return result.scalar_one_or_none() + except SQLAlchemyError: + await db.rollback() + raise + + @staticmethod + async def update(db: AsyncSession, db_ticket: Ticket, ticket_in: TicketUpdate) -> Ticket: + try: + update_data = ticket_in.model_dump(exclude_unset=True) + for field, value in update_data.items(): + setattr(db_ticket, field, value) + + db.add(db_ticket) + await db.commit() + await db.refresh(db_ticket) + return db_ticket + except SQLAlchemyError: + await db.rollback() + raise + + @staticmethod + async def delete(db: AsyncSession, ticket_id: uuid.UUID) -> bool: + try: + result = await db.execute(select(Ticket).where(Ticket.id == ticket_id)) + db_ticket = result.scalar_one_or_none() + if not db_ticket: + return False + + await db.delete(db_ticket) + await db.commit() + return True + except SQLAlchemyError: + await db.rollback() + raise + + @staticmethod + async def list( + db: AsyncSession, + skip: int = 0, + limit: int = 100, + status: TicketStatus | None = None, + priority: TicketPriority | None = None, + search: str | None = None + ) -> list[Ticket]: + try: + query = ( + select(Ticket) + .options(selectinload(Ticket.creator), selectinload(Ticket.assignee)) + ) + + if status: + query = query.where(Ticket.status == status) + if priority: + query = query.where(Ticket.priority == priority) + if search: + query = query.where( + or_( + Ticket.title.ilike(f"%{search}%"), + Ticket.description.ilike(f"%{search}%") + ) + ) + + query = query.offset(skip).limit(limit) + result = await db.execute(query) + return list(result.scalars().all()) + except SQLAlchemyError: + await db.rollback() + raise + + @staticmethod + async def assign_ticket( + db: AsyncSession, + ticket: Ticket, + technician_id: uuid.UUID, + assigned_from: uuid.UUID | None, + action_type: AssignmentActionType + ) -> Ticket: + try: + ticket.assigned_to = technician_id + + history = TicketAssignmentHistory( + ticket_id=ticket.id, + assigned_from=assigned_from, + assigned_to=technician_id, + action_type=action_type + ) + + db.add(ticket) + db.add(history) + await db.commit() + await db.refresh(ticket) + return ticket + except SQLAlchemyError: + await db.rollback() + raise + + @staticmethod + async def unassign_ticket( + db: AsyncSession, + ticket: Ticket, + assigned_from: uuid.UUID | None + ) -> Ticket: + try: + ticket.assigned_to = None + + history = TicketAssignmentHistory( + ticket_id=ticket.id, + assigned_from=assigned_from, + assigned_to=None, + action_type=AssignmentActionType.UNASSIGNED + ) + + db.add(ticket) + db.add(history) + await db.commit() + await db.refresh(ticket) + return ticket + except SQLAlchemyError: + await db.rollback() + raise + + @staticmethod + async def get_assignment_history(db: AsyncSession, ticket_id: uuid.UUID) -> list[TicketAssignmentHistory]: + try: + query = select(TicketAssignmentHistory).where(TicketAssignmentHistory.ticket_id == ticket_id).order_by(TicketAssignmentHistory.created_at.asc()) + result = await db.execute(query) + return list(result.scalars().all()) + except SQLAlchemyError: + await db.rollback() + raise + + @staticmethod + async def get_user(db: AsyncSession, user_id: uuid.UUID) -> User | None: + try: + result = await db.execute(select(User).where(User.id == user_id)) + return result.scalar_one_or_none() + except SQLAlchemyError: + await db.rollback() + raise + + @staticmethod + async def update_status(db: AsyncSession, ticket: Ticket, new_status: TicketStatus) -> Ticket: + try: + ticket.status = new_status + db.add(ticket) + await db.commit() + await db.refresh(ticket) + return ticket + except SQLAlchemyError: + await db.rollback() + raise diff --git a/apps/api/modules/tickets/router.py b/apps/api/modules/tickets/router.py index 9834f73..4f326c2 100644 --- a/apps/api/modules/tickets/router.py +++ b/apps/api/modules/tickets/router.py @@ -1,5 +1,138 @@ -from fastapi import APIRouter +import uuid +from typing import List, Optional -router = APIRouter(prefix="/tickets", tags=["tickets"]) +from fastapi import APIRouter, Depends, HTTPException, status, Query +from sqlalchemy.ext.asyncio import AsyncSession -# TODO: Implement tickets endpoints +from apps.api.infrastructure.db.session import get_db +from apps.api.core.dependencies.auth import get_current_active_user +from apps.api.modules.users.models import User +from apps.api.modules.tickets.models import TicketStatus, TicketPriority +from apps.api.modules.tickets.schemas import ( + TicketCreate, TicketUpdate, TicketRead, + TicketAssignRequest, TicketAssignmentHistoryRead, + TicketStatusUpdateRequest +) +from apps.api.modules.tickets.service import TicketService + +router = APIRouter(tags=["Tickets"]) + +@router.post("", response_model=TicketRead, status_code=status.HTTP_201_CREATED) +async def create_ticket( + ticket_in: TicketCreate, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_active_user) +): + return await TicketService.create_ticket( + db=db, + ticket_in=ticket_in, + created_by=current_user.id + ) + +@router.get("", response_model=List[TicketRead]) +async def list_tickets( + skip: int = Query(0, ge=0), + limit: int = Query(100, ge=1, le=100), + ticket_status: Optional[TicketStatus] = Query(None, alias="status"), + priority: Optional[TicketPriority] = None, + search: Optional[str] = None, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_active_user) +): + return await TicketService.list_tickets( + db=db, + skip=skip, + limit=limit, + status=ticket_status, + priority=priority, + search=search + ) + +@router.get("/{id}", response_model=TicketRead) +async def get_ticket( + id: uuid.UUID, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_active_user) +): + ticket = await TicketService.get_ticket(db=db, ticket_id=id) + if not ticket: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Ticket not found") + return ticket + +@router.patch("/{id}", response_model=TicketRead) +async def update_ticket( + id: uuid.UUID, + ticket_in: TicketUpdate, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_active_user) +): + db_ticket = await TicketService.get_ticket(db=db, ticket_id=id) + if not db_ticket: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Ticket not found") + + return await TicketService.update_ticket( + db=db, + db_ticket=db_ticket, + ticket_in=ticket_in + ) + +@router.delete("/{id}", status_code=status.HTTP_204_NO_CONTENT) +async def delete_ticket( + id: uuid.UUID, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_active_user) +): + success = await TicketService.delete_ticket(db=db, ticket_id=id) + if not success: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Ticket not found") + +@router.patch("/{id}/assign", response_model=TicketRead) +async def assign_ticket( + id: uuid.UUID, + request: TicketAssignRequest, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_active_user) +): + try: + return await TicketService.assign_ticket(db, id, request.technician_id, current_user.id) + except ValueError as e: + if "found" in str(e): + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e)) + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) + +@router.patch("/{id}/unassign", response_model=TicketRead) +async def unassign_ticket( + id: uuid.UUID, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_active_user) +): + try: + return await TicketService.unassign_ticket(db, id, current_user.id) + except ValueError as e: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e)) + +@router.get("/{id}/assignment-history", response_model=List[TicketAssignmentHistoryRead]) +async def get_assignment_history( + id: uuid.UUID, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_active_user) +): + try: + return await TicketService.get_assignment_history(db, id) + except ValueError as e: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e)) + +@router.patch("/{id}/status", response_model=TicketRead) +async def update_ticket_status( + id: uuid.UUID, + request: TicketStatusUpdateRequest, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_active_user) +): + try: + return await TicketService.update_ticket_status(db, id, request.status) + except ValueError as e: + if "found" in str(e): + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e)) + # 422 Unprocessable Entity or 400 Bad Request for domain logic errors + raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(e)) diff --git a/apps/api/modules/tickets/schemas.py b/apps/api/modules/tickets/schemas.py index 7901458..e70165b 100644 --- a/apps/api/modules/tickets/schemas.py +++ b/apps/api/modules/tickets/schemas.py @@ -1,3 +1,51 @@ -from pydantic import BaseModel +from pydantic import BaseModel, Field +import uuid +from datetime import datetime +# pyrefly: ignore [missing-import] +from apps.api.modules.tickets.models import TicketStatus, TicketPriority, AssignmentActionType -# TODO: Define tickets Pydantic schemas +class TicketBase(BaseModel): + title: str = Field(..., max_length=200) + description: str + category: str | None = Field(None, max_length=100) + priority: TicketPriority + +class TicketCreate(TicketBase): + pass + +class TicketRead(TicketBase): + id: uuid.UUID + status: TicketStatus + created_by: uuid.UUID + assigned_to: uuid.UUID | None = None + created_at: datetime + updated_at: datetime + + class Config: + from_attributes = True + +class TicketUpdate(BaseModel): + title: str | None = Field(None, max_length=200) + description: str | None = None + category: str | None = Field(None, max_length=100) + priority: TicketPriority | None = None + status: TicketStatus | None = None + assigned_to: uuid.UUID | None = None + +class TicketAssignRequest(BaseModel): + technician_id: uuid.UUID + +class TicketAssignmentHistoryRead(BaseModel): + id: uuid.UUID + ticket_id: uuid.UUID + assigned_from: uuid.UUID | None + assigned_to: uuid.UUID | None + action_type: AssignmentActionType + created_at: datetime + updated_at: datetime + + class Config: + from_attributes = True + +class TicketStatusUpdateRequest(BaseModel): + status: TicketStatus diff --git a/apps/api/modules/tickets/service.py b/apps/api/modules/tickets/service.py index ac8e802..2502f19 100644 --- a/apps/api/modules/tickets/service.py +++ b/apps/api/modules/tickets/service.py @@ -1 +1,115 @@ -# TODO: Implement tickets business logic +from sqlalchemy.ext.asyncio import AsyncSession +import uuid + +# pyrefly: ignore [missing-import] +from apps.api.modules.tickets.models import Ticket, TicketStatus, TicketPriority, TicketAssignmentHistory, AssignmentActionType +# pyrefly: ignore [missing-import] +from apps.api.modules.tickets.schemas import TicketCreate, TicketUpdate +# pyrefly: ignore [missing-import] +from apps.api.modules.tickets.repository import TicketRepository +from apps.api.modules.tickets.workflow import can_transition + +class TicketService: + """ + Handles business logic for Tickets. + Currently acts as a pass-through to the repository. + Explicitly excludes assignment, workflow validations, and RBAC as per requirements. + """ + + @staticmethod + async def create_ticket(db: AsyncSession, ticket_in: TicketCreate, created_by: uuid.UUID) -> Ticket: + # Default status is handled inherently by the model definition. + return await TicketRepository.create(db=db, ticket_in=ticket_in, created_by=created_by) + + @staticmethod + async def get_ticket(db: AsyncSession, ticket_id: uuid.UUID) -> Ticket | None: + return await TicketRepository.get(db=db, ticket_id=ticket_id) + + @staticmethod + async def update_ticket(db: AsyncSession, db_ticket: Ticket, ticket_in: TicketUpdate) -> Ticket: + # Update logic strictly executes payload application without workflow validation. + return await TicketRepository.update(db=db, db_ticket=db_ticket, ticket_in=ticket_in) + + @staticmethod + async def delete_ticket(db: AsyncSession, ticket_id: uuid.UUID) -> bool: + return await TicketRepository.delete(db=db, ticket_id=ticket_id) + + @staticmethod + async def list_tickets( + db: AsyncSession, + skip: int = 0, + limit: int = 100, + status: TicketStatus | None = None, + priority: TicketPriority | None = None, + search: str | None = None + ) -> list[Ticket]: + # Delegates pagination, filtering, and search logic to repository + return await TicketRepository.list( + db=db, + skip=skip, + limit=limit, + status=status, + priority=priority, + search=search + ) + + @staticmethod + async def assign_ticket( + db: AsyncSession, + ticket_id: uuid.UUID, + technician_id: uuid.UUID, + current_user_id: uuid.UUID + ) -> Ticket: + ticket = await TicketRepository.get(db, ticket_id) + if not ticket: + raise ValueError("Ticket not found") + + target_user = await TicketRepository.get_user(db, technician_id) + if not target_user: + raise ValueError("Technician not found") + + if not target_user.is_active: + raise ValueError("Cannot assign inactive users") + + if target_user.role.value != "TECHNICIAN": + raise ValueError("Only users with Role.TECHNICIAN can be assigned") + + action_type = AssignmentActionType.ASSIGNED if ticket.assigned_to is None else AssignmentActionType.REASSIGNED + return await TicketRepository.assign_ticket(db, ticket, technician_id, current_user_id, action_type) + + @staticmethod + async def unassign_ticket( + db: AsyncSession, + ticket_id: uuid.UUID, + current_user_id: uuid.UUID + ) -> Ticket: + ticket = await TicketRepository.get(db, ticket_id) + if not ticket: + raise ValueError("Ticket not found") + + return await TicketRepository.unassign_ticket(db, ticket, current_user_id) + + @staticmethod + async def get_assignment_history( + db: AsyncSession, + ticket_id: uuid.UUID + ) -> list[TicketAssignmentHistory]: + ticket = await TicketRepository.get(db, ticket_id) + if not ticket: + raise ValueError("Ticket not found") + return await TicketRepository.get_assignment_history(db, ticket_id) + + @staticmethod + async def update_ticket_status( + db: AsyncSession, + ticket_id: uuid.UUID, + new_status: TicketStatus + ) -> Ticket: + ticket = await TicketRepository.get(db, ticket_id) + if not ticket: + raise ValueError("Ticket not found") + + if not can_transition(ticket.status, new_status): + raise ValueError(f"Invalid transition from {ticket.status.value} to {new_status.value}") + + return await TicketRepository.update_status(db, ticket, new_status) diff --git a/apps/api/modules/tickets/workflow.py b/apps/api/modules/tickets/workflow.py new file mode 100644 index 0000000..733bd92 --- /dev/null +++ b/apps/api/modules/tickets/workflow.py @@ -0,0 +1,17 @@ +from apps.api.modules.tickets.models import TicketStatus + +ALLOWED_TRANSITIONS = { + TicketStatus.OPEN: {TicketStatus.IN_PROGRESS}, + TicketStatus.IN_PROGRESS: {TicketStatus.PENDING, TicketStatus.RESOLVED, TicketStatus.OPEN}, + TicketStatus.PENDING: {TicketStatus.IN_PROGRESS, TicketStatus.RESOLVED}, + TicketStatus.RESOLVED: {TicketStatus.CLOSED, TicketStatus.IN_PROGRESS}, + TicketStatus.CLOSED: set() +} + +def can_transition(current_status: TicketStatus, new_status: TicketStatus) -> bool: + """ + Validates if a status transition is allowed based on domain rules. + """ + if current_status not in ALLOWED_TRANSITIONS: + return False + return new_status in ALLOWED_TRANSITIONS[current_status]