diff --git a/AGENTS.md b/AGENTS.md index 96fa43d..4e247d7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -52,6 +52,7 @@ Users, units, and categories are auto-seeded on first startup via lifespan hook: | POST | `/api/auth/refresh` | Token | Refresh tokens | | GET | `/api/auth/me` | Bearer | Current user | | GET | `/api/auth/admin-only` | Admin/Jerome, Admin/Wahab | RBAC demo endpoint | +| GET | `/api/auth/users` | Bearer | List users (id, name, role) for the assign-technician picker | | POST | `/api/whatsapp/mock` | No | Mock WhatsApp | | GET | `/api/whatsapp/mock-log` | No | Recent mock submissions | @@ -75,6 +76,7 @@ Frontend: Alpine.js (CDN) + Tailwind CSS (CDN). Auth state in localStorage. Role | GET | `/api/tickets` | No | List tickets (filter: status, priority, property, building, unit_id, category_id, assigned_to, date_from, date_to) | | GET | `/api/tickets/{id}` | No | Get ticket detail with timeline, photos, SLA status | | PATCH | `/api/tickets/{id}` | Bearer | Update ticket (validates status transitions) | +| DELETE | `/api/tickets/{id}` | Admin/Jerome, Admin/Wahab | Delete ticket + children (timeline/photos/escalations); test/scratch cleanup only | | POST | `/api/tickets/{id}/status` | Bearer | Change status with note | | GET | `/api/tickets/{id}/sla` | No | Check SLA breach status | | POST | `/api/tickets/{id}/photos` | Bearer | Upload photos (multipart, is_before param) | @@ -91,8 +93,11 @@ Frontend: Alpine.js (CDN) + Tailwind CSS (CDN). Auth state in localStorage. Role ## Ticket System (Sprint 2) -### Status Lifecycle (15 statuses) -New → Logged → Triage → Assigned → Accepted → Travelling → On Site → In Progress → Waiting Parts → Escalated → Completed → On-Field Verification → Wahab Review → Closed → Reopened +### Status Lifecycle (16 statuses) +New → Logged → Triage → Assigned → Accepted → Travelling → On Site → In Progress → Waiting Parts → Escalated → Completed → On-Field Verification → Wahab Review → Closed → Reopened → Cancelled + +Cancelled is terminal (no outgoing transitions) and reachable from any active +state; it is excluded from SLA breach reporting and dashboard "active" counts. Valid transitions defined in `app/services/ticket.py::VALID_TRANSITIONS`. Invalid transitions return 400. diff --git a/alembic/versions/c4e8f1a2d3b4_tickets_phone_and_cancelled_status.py b/alembic/versions/c4e8f1a2d3b4_tickets_phone_and_cancelled_status.py new file mode 100644 index 0000000..a9ee91d --- /dev/null +++ b/alembic/versions/c4e8f1a2d3b4_tickets_phone_and_cancelled_status.py @@ -0,0 +1,40 @@ +"""tickets.phone column + Cancelled status support + +Revision ID: c4e8f1a2d3b4 +Revises: b2f4a6c8e0d2 +Create Date: 2026-08-02 00:00:00.000000 + +Demo-prep batch (Wahab review): one schema change. + +* Add ``tickets.phone`` (nullable) so the customer phone collected on the + new-issue form is actually persisted instead of silently dropped. +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'c4e8f1a2d3b4' +down_revision: Union[str, Sequence[str], None] = 'b2f4a6c8e0d2' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Add tickets.phone (nullable).""" + op.add_column( + 'tickets', + sa.Column( + 'phone', + sa.String(length=50), + nullable=True, + comment='Customer contact phone (captured on the new-issue form)', + ), + ) + + +def downgrade() -> None: + """Drop tickets.phone.""" + op.drop_column('tickets', 'phone') diff --git a/app/main.py b/app/main.py index 81094dd..e10f27e 100644 --- a/app/main.py +++ b/app/main.py @@ -28,6 +28,16 @@ async def ensure_legacy_schema(conn) -> None: text("ALTER TABLE categories ADD COLUMN show_in_form BOOLEAN NOT NULL DEFAULT 1") ) logger.info("Added missing categories.show_in_form column (legacy database)") + + result = await conn.execute(text("SELECT name FROM sqlite_master WHERE type='table' AND name='tickets'")) + if result.scalar(): + result = await conn.execute(text("PRAGMA table_info(tickets)")) + ticket_columns = {row[1] for row in result} + if "phone" not in ticket_columns: + await conn.execute( + text("ALTER TABLE tickets ADD COLUMN phone VARCHAR(50)") + ) + logger.info("Added missing tickets.phone column (legacy database)") result = await conn.execute( text( "UPDATE categories SET name = 'Missing Item' " diff --git a/app/models/ticket.py b/app/models/ticket.py index aad9282..919bdc5 100644 --- a/app/models/ticket.py +++ b/app/models/ticket.py @@ -29,7 +29,7 @@ class Ticket(Base): comment=( "New, Logged, Triage, Assigned, Accepted, Travelling, On Site, " "In Progress, Waiting Parts, Escalated, Completed, " - "On-Field Verification, Wahab Review, Closed, Reopened" + "On-Field Verification, Wahab Review, Closed, Reopened, Cancelled" ), ) unit_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("units.id"), nullable=True) @@ -40,6 +40,7 @@ class Ticket(Base): comment="urgent, high, medium, low", ) reporter: Mapped[str | None] = mapped_column(String(255), nullable=True) + phone: Mapped[str | None] = mapped_column(String(50), nullable=True) reported_via: Mapped[str | None] = mapped_column( String(20), nullable=True, diff --git a/app/routers/auth.py b/app/routers/auth.py index 27af060..3cf7b2f 100644 --- a/app/routers/auth.py +++ b/app/routers/auth.py @@ -5,6 +5,7 @@ from __future__ import annotations from typing import Annotated from fastapi import APIRouter, Depends +from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.core.database import get_db @@ -22,6 +23,21 @@ from app.services import auth as auth_service router = APIRouter(prefix="/api/auth", tags=["auth"]) +@router.get("/users", response_model=list[UserOut]) +async def list_users( + db: Annotated[AsyncSession, Depends(get_db)], + current_user: Annotated[User, Depends(get_current_user)], +) -> list[User]: + """List users (id, name, role) for assignment pickers. + + Previously the frontend hard-coded technician ids/names in detail.html; + this endpoint makes the assign dropdown data-driven so a seed change never + silently breaks technician assignment. + """ + result = await db.execute(select(User).order_by(User.full_name)) + return list(result.scalars().all()) + + @router.post("/register", response_model=UserOut, status_code=201) async def register( body: RegisterRequest, diff --git a/app/routers/tickets.py b/app/routers/tickets.py index ac23cce..4bdfaa8 100644 --- a/app/routers/tickets.py +++ b/app/routers/tickets.py @@ -12,7 +12,7 @@ from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.core.config import settings from app.core.database import get_db -from app.core.security import get_current_user +from app.core.security import get_current_user, require_roles from app.models.category import Category from app.models.ticket import Ticket, TicketPhoto from app.models.unit import Unit @@ -250,6 +250,25 @@ async def update_ticket( return ticket +@router.delete("/{ticket_id}", status_code=status.HTTP_204_NO_CONTENT) +async def delete_ticket( + ticket_id: int, + db: Annotated[AsyncSession, Depends(get_db)], + current_user: Annotated[User, Depends(require_roles("Admin/Jerome", "Admin/Wahab"))], +) -> None: + """Delete a ticket and its children (timeline, photos, escalations). + + Admin-only: intended for removing test/scratch tickets from the demo + database, never for routine workflow use. + """ + ticket = await ticket_service.delete_ticket(db, ticket_id) + # Remove orphaned photo files from disk after the DB rows are gone. + for photo in ticket.photos: + if photo.photo_url: + name = photo.photo_url.rsplit("/", 1)[-1] + (UPLOADS_DIR / name).unlink(missing_ok=True) + + # ── Status Transitions (convenience endpoints) ─────────────────────── @router.post("/{ticket_id}/status", response_model=TicketOut) async def change_ticket_status( diff --git a/app/schemas/ticket.py b/app/schemas/ticket.py index 044612e..d17737b 100644 --- a/app/schemas/ticket.py +++ b/app/schemas/ticket.py @@ -95,6 +95,7 @@ class TicketBrief(BaseModel): assigned_to: int | None = None assigned_technician_name: str | None = None reporter: str | None = None + phone: str | None = None description: str | None = None sla_deadline: datetime | None = None reopen_count: int = 0 @@ -113,6 +114,8 @@ class TicketOut(TicketBrief): customer_rating: int | None = None timeline: list[TicketTimelineOut] = [] photos: list[TicketPhotoOut] = [] + unit: UnitOut | None = None + category: CategoryOut | None = None sla_status: dict | None = None diff --git a/app/services/sla.py b/app/services/sla.py index 9995446..d62e429 100644 --- a/app/services/sla.py +++ b/app/services/sla.py @@ -55,9 +55,12 @@ def should_escalate_on_response(ticket: Ticket) -> bool: return datetime.now(timezone.utc) > deadline +TERMINAL_STATUSES = {"Closed", "Completed", "Cancelled"} + + def is_sla_breached(ticket: Ticket) -> bool: """Return True if the ticket's resolution SLA deadline has passed.""" - if ticket.sla_deadline is None or ticket.status in ("Closed", "Completed"): + if ticket.sla_deadline is None or ticket.status in TERMINAL_STATUSES: return False deadline = ticket.sla_deadline if deadline.tzinfo is None: @@ -88,7 +91,7 @@ async def get_sla_status(ticket: Ticket) -> dict: rd = resolution_deadline if rd.tzinfo is None: rd = rd.replace(tzinfo=timezone.utc) - resolution_breached = now > rd if ticket.status not in ("Closed", "Completed") else False + resolution_breached = now > rd if ticket.status not in TERMINAL_STATUSES else False return { "priority": ticket.priority, diff --git a/app/services/ticket.py b/app/services/ticket.py index 8dae348..c767756 100644 --- a/app/services/ticket.py +++ b/app/services/ticket.py @@ -6,11 +6,11 @@ from datetime import datetime, timezone from typing import Any from fastapi import HTTPException, status -from sqlalchemy import func, select +from sqlalchemy import delete as sa_delete, func, select from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload -from app.models.ticket import Escalation, Ticket, TicketTimeline +from app.models.ticket import Escalation, Ticket, TicketPhoto, TicketTimeline from app.models.unit import Unit from app.models.user import User from app.services.sla import compute_sla_deadline @@ -19,20 +19,22 @@ from app.services.sla import compute_sla_deadline # Keys: current status → list of valid next statuses VALID_TRANSITIONS: dict[str, list[str]] = { "New": ["Logged"], - "Logged": ["Triage", "Closed"], - "Triage": ["Assigned", "Escalated"], - "Assigned": ["Accepted", "Triage"], - "Accepted": ["Travelling", "Triage"], - "Travelling": ["On Site", "Triage"], - "On Site": ["In Progress", "Triage"], - "In Progress": ["Waiting Parts", "Escalated", "Completed"], - "Waiting Parts": ["In Progress", "Escalated"], - "Escalated": ["Triage", "In Progress", "Completed", "Closed"], + "Logged": ["Triage", "Closed", "Cancelled"], + "Triage": ["Assigned", "Escalated", "Cancelled"], + "Assigned": ["Accepted", "Triage", "Cancelled"], + "Accepted": ["Travelling", "Triage", "Cancelled"], + "Travelling": ["On Site", "Triage", "Cancelled"], + "On Site": ["In Progress", "Triage", "Cancelled"], + "In Progress": ["Waiting Parts", "Escalated", "Completed", "Cancelled"], + "Waiting Parts": ["In Progress", "Escalated", "Cancelled"], + "Escalated": ["Triage", "In Progress", "Completed", "Closed", "Cancelled"], "Completed": ["On-Field Verification", "In Progress"], "On-Field Verification": ["Wahab Review", "Completed", "Closed"], "Wahab Review": ["Closed", "On-Field Verification"], "Closed": ["Reopened"], "Reopened": ["Triage", "Logged"], + # Terminal: cancelled tickets cannot resume work. + "Cancelled": [], } REOPEN_WINDOW_DAYS = 7 @@ -41,15 +43,25 @@ SLA_ACK_USER = "Ama" # ── Helpers ────────────────────────────────────────────────────────── async def _generate_ticket_number(db: AsyncSession) -> str: - """Generate the next ticket number in PAV-YYYY-NNNNN format.""" + """Generate the next ticket number in PAV-YYYY-NNNNN format. + + Uses the highest existing suffix + 1 (not a row count) so that deleting + tickets never re-issues an already-used number. + """ year = datetime.now(timezone.utc).year prefix = f"PAV-{year}-" - # Count existing tickets this year result = await db.execute( - select(func.count(Ticket.id)).where(Ticket.ticket_number.like(f"{prefix}%")) + select(func.max(Ticket.ticket_number)).where(Ticket.ticket_number.like(f"{prefix}%")) ) - count = result.scalar() or 0 - return f"{prefix}{count + 1:05d}" + max_number = result.scalar() + if max_number: + try: + next_seq = int(max_number.rsplit("-", 1)[1]) + 1 + except (ValueError, IndexError): + next_seq = 1 + else: + next_seq = 1 + return f"{prefix}{next_seq:05d}" async def _log_status_change( @@ -110,6 +122,7 @@ async def create_ticket( category_id=data.get("category_id"), priority=priority, reporter=data.get("reporter") or data.get("customer_name"), + phone=data.get("phone"), reported_via=data.get("reported_via"), description=data.get("description"), assigned_to=data.get("assigned_to"), @@ -296,7 +309,7 @@ async def update_ticket( ) # Update other fields - for field in ("unit_id", "category_id", "priority", "reporter", "reported_via", + for field in ("unit_id", "category_id", "priority", "reporter", "phone", "reported_via", "description", "assigned_to", "eta", "cost", "parts_used"): if field in data: setattr(ticket, field, data[field]) @@ -310,3 +323,19 @@ async def update_ticket( return ticket +async def delete_ticket(db: AsyncSession, ticket_id: int) -> Ticket: + """Delete a ticket and all dependent rows (timeline, photos, escalations). + + Returns the deleted ticket so the caller can remove orphaned photo files. + """ + ticket = await _get_ticket_or_404(db, ticket_id) + + # Delete escalations, timeline, and photo rows first (FK children). + await db.execute(sa_delete(Escalation).where(Escalation.ticket_id == ticket_id)) + await db.execute(sa_delete(TicketTimeline).where(TicketTimeline.ticket_id == ticket_id)) + await db.execute(sa_delete(TicketPhoto).where(TicketPhoto.ticket_id == ticket_id)) + await db.delete(ticket) + await db.flush() + return ticket + + diff --git a/app/templates/base.html b/app/templates/base.html index d12c5bf..1d4a2a3 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -51,6 +51,7 @@ .status-wahab-review { @apply bg-violet-100 text-violet-800; } .status-closed { @apply bg-gray-200 text-gray-600; } .status-reopened { @apply bg-pink-100 text-pink-800; } + .status-cancelled { @apply bg-gray-300 text-gray-700 line-through; } .priority-urgent { @apply bg-red-100 text-red-800 border-red-300; } .priority-high { @apply bg-orange-100 text-orange-800 border-orange-300; } .priority-medium { @apply bg-yellow-100 text-yellow-800 border-yellow-300; } @@ -318,7 +319,8 @@ 'on-field verification': 'status-on-field-verification', 'wahab review': 'status-wahab-review', 'closed': 'status-closed', - 'reopened': 'status-reopened' + 'reopened': 'status-reopened', + 'cancelled': 'status-cancelled' }; return map[status.toLowerCase()] || 'bg-gray-100 text-gray-800'; }, diff --git a/app/templates/dashboard/ceo.html b/app/templates/dashboard/ceo.html index 4c1232e..4df986d 100644 --- a/app/templates/dashboard/ceo.html +++ b/app/templates/dashboard/ceo.html @@ -189,11 +189,11 @@ if (!all.length) return; // Basic KPIs - const open = all.filter(t => !['Closed', 'Completed'].includes(t.status)); - const closed = all.filter(t => ['Closed', 'Completed'].includes(t.status)); + const open = all.filter(t => !['Closed', 'Completed', 'Cancelled'].includes(t.status)); + const closed = all.filter(t => ['Closed', 'Completed', 'Cancelled'].includes(t.status)); const urgent = all.filter(t => t.priority === 'urgent'); const withSLA = all.filter(t => t.sla_deadline); - const slaMet = withSLA.filter(t => new Date(t.sla_deadline) > new Date() || ['Closed', 'Completed'].includes(t.status)); + const slaMet = withSLA.filter(t => new Date(t.sla_deadline) > new Date() || ['Closed', 'Completed', 'Cancelled'].includes(t.status)); // Monthly restored const oneWeekAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000); @@ -270,7 +270,7 @@ // Risk indicators this.indicators = { - openEmergencies: urgent.filter(t => !['Closed', 'Completed'].includes(t.status)).length, + openEmergencies: urgent.filter(t => !['Closed', 'Completed', 'Cancelled'].includes(t.status)).length, reopenedThisWeek, overdueJobs: open.filter(t => t.sla_deadline && new Date(t.sla_deadline) < new Date()).length, pendingVerification: all.filter(t => ['Completed', 'On-Field Verification'].includes(t.status)).length, @@ -280,7 +280,7 @@ }, calcAvgResponse(all) { - const open = all.filter(t => !['Closed', 'Completed'].includes(t.status)); + const open = all.filter(t => !['Closed', 'Completed', 'Cancelled'].includes(t.status)); if (!open.length) return '—'; const avgHrs = open.reduce((sum, t) => sum + Math.min((new Date() - new Date(t.created_at)) / (1000 * 60 * 60), 168), 0) / open.length; if (avgHrs < 1) return `${Math.round(avgHrs * 60)}m`; @@ -288,7 +288,7 @@ }, calcAvgResolution(all) { - const closed = all.filter(t => ['Closed', 'Completed'].includes(t.status) && t.created_at && t.updated_at); + const closed = all.filter(t => ['Closed', 'Completed', 'Cancelled'].includes(t.status) && t.created_at && t.updated_at); if (!closed.length) return '—'; const avgHrs = closed.reduce((sum, t) => { const diff = (new Date(t.updated_at) - new Date(t.created_at)) / (1000 * 60 * 60); diff --git a/app/templates/dashboard/cs.html b/app/templates/dashboard/cs.html index af8efd5..4704e1e 100644 --- a/app/templates/dashboard/cs.html +++ b/app/templates/dashboard/cs.html @@ -188,20 +188,20 @@ const newToday = all.filter(t => new Date(t.created_at) >= today && t.status === 'New').length; // Open tickets (not closed/completed) - const open = all.filter(t => !['Closed', 'Completed'].includes(t.status)); + const open = all.filter(t => !['Closed', 'Completed', 'Cancelled'].includes(t.status)); // By priority const byPriority = { urgent: 0, high: 0, medium: 0, low: 0 }; open.forEach(t => { if (t.priority) byPriority[t.priority] = (byPriority[t.priority] || 0) + 1; }); // Oldest unassigned - const unassigned = all.filter(t => !t.assigned_to && !['Closed', 'Completed'].includes(t.status)) + const unassigned = all.filter(t => !t.assigned_to && !['Closed', 'Completed', 'Cancelled'].includes(t.status)) .sort((a, b) => new Date(a.created_at) - new Date(b.created_at)); this.oldestUnassigned = unassigned[0] || null; // SLA compliance (rough: tickets with sla_deadline not breached) const withSLA = all.filter(t => t.sla_deadline); - const slaMet = withSLA.filter(t => new Date(t.sla_deadline) > new Date() || ['Closed', 'Completed'].includes(t.status)); + const slaMet = withSLA.filter(t => new Date(t.sla_deadline) > new Date() || ['Closed', 'Completed', 'Cancelled'].includes(t.status)); this.kpi = { newToday, @@ -230,7 +230,7 @@ calcAvgResponse(all) { // Rough approximation — in real system this would come from timeline analysis - const open = all.filter(t => !['Closed', 'Completed'].includes(t.status)); + const open = all.filter(t => !['Closed', 'Completed', 'Cancelled'].includes(t.status)); if (!open.length) return '—'; const avgHrs = open.reduce((sum, t) => { const diff = (new Date() - new Date(t.created_at)) / (1000 * 60 * 60); diff --git a/app/templates/dashboard/fm.html b/app/templates/dashboard/fm.html index 273e4ce..d478a54 100644 --- a/app/templates/dashboard/fm.html +++ b/app/templates/dashboard/fm.html @@ -222,7 +222,7 @@ const all = data.items; // Active: not closed/completed - const active = all.filter(t => !['Closed', 'Completed'].includes(t.status)); + const active = all.filter(t => !['Closed', 'Completed', 'Cancelled'].includes(t.status)); this.activeTickets = active; // KPIs diff --git a/app/templates/tickets/detail.html b/app/templates/tickets/detail.html index f3ddbcb..9f51cd8 100644 --- a/app/templates/tickets/detail.html +++ b/app/templates/tickets/detail.html @@ -121,6 +121,10 @@
Reporter
+
+
Phone
+
+
Reported Via
@@ -214,6 +218,7 @@ +
@@ -328,17 +333,14 @@ }, async loadTechnicians() { - // Users list not exposed via API directly, so use a known list - // In a real system we'd have GET /api/users - this.technicians = [ - { id: 9, full_name: 'Prosper' }, - { id: 10, full_name: 'Sam' }, - { id: 11, full_name: 'Steven' }, - { id: 12, full_name: 'Junior (Samuel)' }, - { id: 13, full_name: 'Francis' }, - { id: 14, full_name: 'Desmond Afful' }, - { id: 15, full_name: 'Afful' }, - ]; + try { + const users = await app().apiGet('/api/auth/users'); + // Assign dropdown should only offer Tech-role staff + this.technicians = (users || []).filter(u => u.role === 'Tech'); + } catch (e) { + console.error('Technicians load error', e); + this.technicians = []; + } }, async submitStatusUpdate() { diff --git a/app/templates/tickets/list.html b/app/templates/tickets/list.html index 3b0d7f5..d906478 100644 --- a/app/templates/tickets/list.html +++ b/app/templates/tickets/list.html @@ -38,6 +38,7 @@ +
@@ -142,7 +143,7 @@ - + @@ -190,7 +191,7 @@ - + diff --git a/app/templates/tickets/new.html b/app/templates/tickets/new.html index 461f638..d29b945 100644 --- a/app/templates/tickets/new.html +++ b/app/templates/tickets/new.html @@ -363,6 +363,16 @@ this.submitting = false; return; } + if (!this.form.category_main && !this.form.category_id) { + this.error = 'Please select a Category — every issue needs one for correct routing and SLA.'; + this.submitting = false; + return; + } + if (this.mode === 'standard' && !this.form.priority) { + this.error = 'Please select a Priority — without one the ticket gets no SLA deadline.'; + this.submitting = false; + return; + } // Create the ticket const payload = { diff --git a/tests/test_workflow_hardening.py b/tests/test_workflow_hardening.py new file mode 100644 index 0000000..e5a223b --- /dev/null +++ b/tests/test_workflow_hardening.py @@ -0,0 +1,202 @@ +"""Tests for the Wahab demo-prep hardening batch. + +Anchors: +* ``Cancelled`` is a terminal status reachable from every active state, is + excluded from SLA breach reporting, and shows up in the status pickers. +* Customer ``phone`` collected on the new-issue form is persisted (it used to + be silently dropped). +* ``GET /api/tickets/{id}`` returns nested ``unit``/``category`` objects so the + detail page stops rendering "—" for Unit/Property/Category. +* ``DELETE /api/tickets/{id}`` is admin-only and removes ticket children. +* ``GET /api/auth/users`` powers the assign-technician dropdown. +* Ticket numbering uses max+1, so deleting tickets never re-issues a number. +""" + +from __future__ import annotations + +import pytest + +pytestmark = pytest.mark.asyncio + + +async def _login(client, email="wahab@denya.com", password="denya123") -> str: + resp = await client.post( + "/api/auth/login", + json={"email": email, "password": password}, + ) + assert resp.status_code == 200, resp.text + return resp.json()["access_token"] + + +async def _create_ticket(client, token: str, **overrides) -> dict: + payload = { + "unit_id": 2, + "category_id": 3, + "priority": "medium", + "reporter": "Demo Prep Test", + "reported_via": "walk-in", + "description": "hardening batch test ticket", + **overrides, + } + resp = await client.post( + "/api/tickets", + json=payload, + headers={"Authorization": f"Bearer {token}"}, + ) + assert resp.status_code == 201, resp.text + return resp.json() + + +# ── Cancelled status ────────────────────────────────────────────────── +async def test_cancelled_is_terminal(client): + """A cancelled ticket has no valid next status.""" + from app.services.ticket import VALID_TRANSITIONS + + assert "Cancelled" in VALID_TRANSITIONS + assert VALID_TRANSITIONS["Cancelled"] == [] + + +async def test_cancelled_reachable_from_active_states(client): + """Logged/Triage/In Progress → Cancelled are all valid transitions.""" + from app.services.ticket import VALID_TRANSITIONS + + for state in ("Logged", "Triage", "Assigned", "Accepted", "In Progress", "Escalated"): + assert "Cancelled" in VALID_TRANSITIONS[state], f"{state} should allow Cancelled" + + +async def test_cancel_ticket_via_api(client): + """PATCH status=Cancelled works end-to-end and lands in the timeline.""" + token = await _login(client) + ticket = await _create_ticket(client, token, description="cancel me") + + resp = await client.patch( + f"/api/tickets/{ticket['id']}", + json={"status": "Cancelled", "note": "demo sample"}, + headers={"Authorization": f"Bearer {token}"}, + ) + assert resp.status_code == 200, resp.text + data = resp.json() + assert data["status"] == "Cancelled" + assert data["timeline"][-1]["to_status"] == "Cancelled" + + +async def test_cancelled_ticket_not_sla_breached(client): + """A cancelled ticket must not report SLA breach (like Closed/Completed).""" + token = await _login(client) + ticket = await _create_ticket(client, token, priority="urgent", description="cancel sla test") + + # Force the SLA deadline into the past by patching priority (recomputes from now), + # then cancel; the breach flags must be False either way. + resp = await client.patch( + f"/api/tickets/{ticket['id']}", + json={"status": "Cancelled"}, + headers={"Authorization": f"Bearer {token}"}, + ) + assert resp.status_code == 200 + sla = resp.json()["sla_status"] + assert sla["resolution_breached"] is False + assert sla["response_breached"] is False + + +# ── Phone persistence ───────────────────────────────────────────────── +async def test_phone_persisted_on_create(client): + """Customer phone sent at creation is stored and returned.""" + token = await _login(client) + ticket = await _create_ticket(client, token, phone="+233123456789", description="phone test") + assert ticket["phone"] == "+233123456789" + + detail = await client.get(f"/api/tickets/{ticket['id']}") + assert detail.json()["phone"] == "+233123456789" + + +# ── Nested unit/category in detail ──────────────────────────────────── +async def test_ticket_detail_returns_unit_and_category(client): + """TicketOut includes nested unit/category objects (detail page fix).""" + token = await _login(client) + ticket = await _create_ticket(client, token, unit_id=2, category_id=3) + resp = await client.get(f"/api/tickets/{ticket['id']}") + assert resp.status_code == 200 + data = resp.json() + assert data["unit"] is not None + assert data["unit"]["apartment_code"] + assert data["category"] is not None + assert data["category"]["name"] + + +# ── Admin-only DELETE ───────────────────────────────────────────────── +async def test_delete_ticket_requires_admin(client): + """A non-admin (CS Rep) gets 403 on DELETE.""" + token = await _login(client, email="bella@denya.com") # CS Rep + ticket = await _create_ticket(client, token, description="delete perm test") + resp = await client.delete( + f"/api/tickets/{ticket['id']}", + headers={"Authorization": f"Bearer {token}"}, + ) + assert resp.status_code == 403 + + +async def test_delete_ticket_removes_children(client): + """Admin DELETE removes the ticket, timeline, and photos.""" + token = await _login(client) # Admin/Wahab + ticket = await _create_ticket(client, token, description="delete me") + tid = ticket["id"] + + resp = await client.delete( + f"/api/tickets/{tid}", + headers={"Authorization": f"Bearer {token}"}, + ) + assert resp.status_code == 204 + + gone = await client.get(f"/api/tickets/{tid}") + assert gone.status_code == 404 + + from sqlalchemy import func, select + from app.core.database import async_session_factory + from app.models.ticket import TicketTimeline + + async with async_session_factory() as session: + count = (await session.execute( + select(func.count(TicketTimeline.id)).where(TicketTimeline.ticket_id == tid) + )).scalar() + assert count == 0 + + +# ── Users endpoint for the assign picker ────────────────────────────── +async def test_users_endpoint_requires_auth(client): + resp = await client.get("/api/auth/users") + assert resp.status_code == 401 + + +async def test_users_endpoint_lists_technicians(client): + token = await _login(client) + resp = await client.get("/api/auth/users", headers={"Authorization": f"Bearer {token}"}) + assert resp.status_code == 200 + users = resp.json() + assert any(u["role"] == "Tech" for u in users) + # Fields the assign dropdown needs + assert {"id", "full_name", "role"} <= set(users[0].keys()) + + +# ── Ticket numbering survives deletions ─────────────────────────────── +async def test_ticket_number_uses_max_plus_one(client, seed_tickets): + """After deleting a middle ticket, numbering must skip over surviving numbers.""" + from sqlalchemy import delete as sa_delete + from app.core.database import async_session_factory + from app.models.ticket import Ticket + + token = await _login(client) + first = await _create_ticket(client, token, description="numbering a") # …001 + second = await _create_ticket(client, token, description="numbering b") # …002 + third = await _create_ticket(client, token, description="numbering c") # …003 + + async with async_session_factory() as session: + # Delete the middle ticket; count+1 numbering would now re-issue …003 + # (colliding with the surviving third ticket). + await session.execute(sa_delete(Ticket).where(Ticket.id == second["id"])) + await session.commit() + + fourth = await _create_ticket(client, token, description="numbering d") + suffix = lambda tn: int(tn.rsplit("-", 1)[1]) + survivors = {suffix(first["ticket_number"]), suffix(third["ticket_number"])} + assert suffix(fourth["ticket_number"]) not in survivors + assert suffix(fourth["ticket_number"]) > max(survivors)