Skip to content
Merged
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
5 changes: 3 additions & 2 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,9 @@
# Python & FastAPI
# ------------------------------------------------------------------------------
__pycache__/
*.py[cod]
*$py.class
*.pyc
*.pyo
*.pyd
*.so
.Python
build/
Expand Down
35 changes: 35 additions & 0 deletions apps/api/core/dependencies/rbac.py
Original file line number Diff line number Diff line change
@@ -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])
Original file line number Diff line number Diff line change
@@ -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)
Original file line number Diff line number Diff line change
@@ -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)
Original file line number Diff line number Diff line change
@@ -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
1 change: 1 addition & 0 deletions apps/api/infrastructure/db/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
4 changes: 4 additions & 0 deletions apps/api/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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():
Expand Down
121 changes: 121 additions & 0 deletions apps/api/modules/dashboard/repository.py
Original file line number Diff line number Diff line change
@@ -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
35 changes: 32 additions & 3 deletions apps/api/modules/dashboard/router.py
Original file line number Diff line number Diff line change
@@ -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)
30 changes: 29 additions & 1 deletion apps/api/modules/dashboard/schemas.py
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading