From 1dd88a141ed9c429a0bda1088d30b880d9e535a1 Mon Sep 17 00:00:00 2001 From: root Date: Sun, 2 Aug 2026 14:06:30 +0000 Subject: [PATCH 1/4] =?UTF-8?q?feat:=20Wahab=20demo=20prep=20=E2=80=94=20C?= =?UTF-8?q?ancelled=20status,=20phone=20persistence,=20admin=20delete,=20u?= =?UTF-8?q?sers=20picker,=20detail-page=20fixes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add Cancelled as terminal status reachable from all active states; exclude from SLA breach reporting and dashboard active counts; add to status pickers - Persist customer phone on ticket create/update (was silently dropped); add tickets.phone migration + legacy self-heal guard - Add admin-only DELETE /api/tickets/{id} (removes timeline/photos/escalations) - Add GET /api/auth/users for the assign-technician dropdown (was hardcoded) - TicketOut now returns nested unit/category so the detail page stops showing '—' for Unit/Property/Category - Ticket numbering uses max+1 so deletions never re-issue a number - New-issue form: require Category and (standard mode) Priority client-side - Tests: 11 new cases covering cancellation, SLA exemption, phone, delete, users endpoint, numbering --- AGENTS.md | 9 +- ...d3b4_tickets_phone_and_cancelled_status.py | 40 ++++ app/main.py | 10 + app/models/ticket.py | 3 +- app/routers/auth.py | 16 ++ app/routers/tickets.py | 21 +- app/schemas/ticket.py | 3 + app/services/sla.py | 7 +- app/services/ticket.py | 63 ++++-- app/templates/base.html | 4 +- app/templates/dashboard/ceo.html | 12 +- app/templates/dashboard/cs.html | 8 +- app/templates/dashboard/fm.html | 2 +- app/templates/tickets/detail.html | 24 ++- app/templates/tickets/list.html | 5 +- app/templates/tickets/new.html | 10 + tests/test_workflow_hardening.py | 202 ++++++++++++++++++ 17 files changed, 391 insertions(+), 48 deletions(-) create mode 100644 alembic/versions/c4e8f1a2d3b4_tickets_phone_and_cancelled_status.py create mode 100644 tests/test_workflow_hardening.py 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) From 237284b0123db6ed07b91e5faeea3c48fc93a2da Mon Sep 17 00:00:00 2001 From: root Date: Sun, 2 Aug 2026 14:12:46 +0000 Subject: [PATCH 2/4] no-mistakes(review): Make Cancelled reachable from all active states; wire phone PATCH; exclude cancelled from CEO resolution avg --- app/schemas/ticket.py | 1 + app/services/ticket.py | 10 +++++----- app/templates/dashboard/ceo.html | 2 +- tests/test_workflow_hardening.py | 6 +++++- 4 files changed, 12 insertions(+), 7 deletions(-) diff --git a/app/schemas/ticket.py b/app/schemas/ticket.py index d17737b..e3d9ae0 100644 --- a/app/schemas/ticket.py +++ b/app/schemas/ticket.py @@ -54,6 +54,7 @@ class TicketUpdate(BaseModel): category_id: int | None = None priority: str | None = None reporter: str | None = None + phone: str | None = None reported_via: str | None = None description: str | None = None assigned_to: int | None = None diff --git a/app/services/ticket.py b/app/services/ticket.py index c767756..0c642f1 100644 --- a/app/services/ticket.py +++ b/app/services/ticket.py @@ -18,7 +18,7 @@ from app.services.sla import compute_sla_deadline # ── Status Transition Map ──────────────────────────────────────────── # Keys: current status → list of valid next statuses VALID_TRANSITIONS: dict[str, list[str]] = { - "New": ["Logged"], + "New": ["Logged", "Cancelled"], "Logged": ["Triage", "Closed", "Cancelled"], "Triage": ["Assigned", "Escalated", "Cancelled"], "Assigned": ["Accepted", "Triage", "Cancelled"], @@ -28,11 +28,11 @@ VALID_TRANSITIONS: dict[str, list[str]] = { "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"], + "Completed": ["On-Field Verification", "In Progress", "Cancelled"], + "On-Field Verification": ["Wahab Review", "Completed", "Closed", "Cancelled"], + "Wahab Review": ["Closed", "On-Field Verification", "Cancelled"], "Closed": ["Reopened"], - "Reopened": ["Triage", "Logged"], + "Reopened": ["Triage", "Logged", "Cancelled"], # Terminal: cancelled tickets cannot resume work. "Cancelled": [], } diff --git a/app/templates/dashboard/ceo.html b/app/templates/dashboard/ceo.html index 4df986d..97b7a57 100644 --- a/app/templates/dashboard/ceo.html +++ b/app/templates/dashboard/ceo.html @@ -288,7 +288,7 @@ }, calcAvgResolution(all) { - const closed = all.filter(t => ['Closed', 'Completed', 'Cancelled'].includes(t.status) && t.created_at && t.updated_at); + const closed = all.filter(t => ['Closed', 'Completed'].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/tests/test_workflow_hardening.py b/tests/test_workflow_hardening.py index e5a223b..f879cbb 100644 --- a/tests/test_workflow_hardening.py +++ b/tests/test_workflow_hardening.py @@ -60,7 +60,11 @@ 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"): + for state in ( + "New", "Logged", "Triage", "Assigned", "Accepted", "Travelling", "On Site", + "In Progress", "Waiting Parts", "Escalated", "On-Field Verification", + "Wahab Review", "Reopened", + ): assert "Cancelled" in VALID_TRANSITIONS[state], f"{state} should allow Cancelled" From 6b92c9098c366c1b38d329cced020ba3e4c8a656 Mon Sep 17 00:00:00 2001 From: root Date: Sun, 2 Aug 2026 14:26:48 +0000 Subject: [PATCH 3/4] no-mistakes(review): Guard photo cleanup; add transitions helper and assign auto-advance --- app/routers/tickets.py | 25 +++++++++- app/services/ticket.py | 19 +++++++- app/templates/tickets/detail.html | 32 ++++++------ tests/test_workflow_hardening.py | 81 +++++++++++++++++++++++++++++++ 4 files changed, 140 insertions(+), 17 deletions(-) diff --git a/app/routers/tickets.py b/app/routers/tickets.py index 4bdfaa8..c6405aa 100644 --- a/app/routers/tickets.py +++ b/app/routers/tickets.py @@ -2,6 +2,7 @@ from __future__ import annotations +import logging import uuid from datetime import datetime from pathlib import Path @@ -32,6 +33,8 @@ from app.schemas.ticket import ( from app.services import ticket as ticket_service from app.services.sla import get_sla_status +logger = logging.getLogger(__name__) + router = APIRouter(prefix="/api/tickets", tags=["tickets"]) # Ensure uploads directory exists @@ -224,6 +227,19 @@ async def list_tickets( return TicketListResponse(items=items, total=total, page=page, page_size=page_size) +@router.get("/{ticket_id}/transitions") +async def get_ticket_transitions( + ticket_id: int, + db: Annotated[AsyncSession, Depends(get_db)], +) -> dict: + """Return the valid next statuses for a ticket's current status.""" + ticket = await ticket_service.get_ticket(db, ticket_id) + return { + "current_status": ticket.status, + "transitions": ticket_service.VALID_TRANSITIONS.get(ticket.status, []), + } + + @router.get("/{ticket_id}", response_model=TicketOut) async def get_ticket( ticket_id: int, @@ -266,7 +282,14 @@ async def delete_ticket( for photo in ticket.photos: if photo.photo_url: name = photo.photo_url.rsplit("/", 1)[-1] - (UPLOADS_DIR / name).unlink(missing_ok=True) + try: + (UPLOADS_DIR / name).unlink(missing_ok=True) + except OSError: + logger.warning( + "Could not remove orphaned photo file %s for ticket %s", + name, + ticket_id, + ) # ── Status Transitions (convenience endpoints) ─────────────────────── diff --git a/app/services/ticket.py b/app/services/ticket.py index 0c642f1..a8cf8da 100644 --- a/app/services/ticket.py +++ b/app/services/ticket.py @@ -236,6 +236,7 @@ async def update_ticket( # Handle status transitions separately new_status = data.get("status") old_status = ticket.status + status_changed = new_status is not None and old_status != new_status if new_status is not None: if old_status != new_status: valid_targets = VALID_TRANSITIONS.get(old_status, []) @@ -296,9 +297,25 @@ async def update_ticket( ticket.status = new_status + # Assigning a technician advances pre-Assigned tickets to Assigned with a + # timeline entry; tickets already past Assigned keep their current status. + advanced_to_assigned = False + if "assigned_to" in data and data["assigned_to"] != ticket.assigned_to: + if ticket.status in {"New", "Logged", "Triage"}: + await _log_status_change( + db, + ticket.id, + from_status=ticket.status, + to_status="Assigned", + note=data.get("note") if not status_changed else None, + user_id=user.id if user else None, + ) + ticket.status = "Assigned" + advanced_to_assigned = True + # Handle standalone note (no status change) note_only = data.get("note") - if note_only and not (new_status is not None and old_status != new_status): + if note_only and not status_changed and not advanced_to_assigned: await _log_status_change( db, ticket.id, diff --git a/app/templates/tickets/detail.html b/app/templates/tickets/detail.html index 9f51cd8..ffdce9d 100644 --- a/app/templates/tickets/detail.html +++ b/app/templates/tickets/detail.html @@ -204,21 +204,9 @@
@@ -303,6 +291,7 @@ statusForm: { newStatus: '', note: '' }, statusSubmitting: false, statusError: '', + availableTransitions: [], // Assign form assignForm: { technicianId: '' }, @@ -316,6 +305,7 @@ async init() { await this.loadTicket(); + await this.loadTransitions(); if (app().isFM || app().isAdmin) { await this.loadTechnicians(); } @@ -332,6 +322,16 @@ } }, + async loadTransitions() { + try { + const data = await app().apiGet(`/api/tickets/${this.ticketId}/transitions`); + this.availableTransitions = (data && data.transitions) || []; + } catch (e) { + console.error('Transitions load error', e); + this.availableTransitions = []; + } + }, + async loadTechnicians() { try { const users = await app().apiGet('/api/auth/users'); @@ -356,6 +356,7 @@ const updated = await app().apiPatch(`/api/tickets/${this.ticketId}`, payload); this.ticket = updated; + await this.loadTransitions(); this.showStatusModal = false; this.statusForm = { newStatus: '', note: '' }; app().showToast('Status updated', 'success'); @@ -378,6 +379,7 @@ assigned_to: parseInt(this.assignForm.technicianId) }); this.ticket = updated; + await this.loadTransitions(); this.showAssignModal = false; app().showToast('Technician assigned', 'success'); } catch (e) { diff --git a/tests/test_workflow_hardening.py b/tests/test_workflow_hardening.py index f879cbb..fad9a85 100644 --- a/tests/test_workflow_hardening.py +++ b/tests/test_workflow_hardening.py @@ -181,6 +181,87 @@ async def test_users_endpoint_lists_technicians(client): assert {"id", "full_name", "role"} <= set(users[0].keys()) +# ── Transitions helper (status dropdown) ───────────────────────────── +async def test_transitions_helper_returns_valid_targets(client): + """GET /api/tickets/{id}/transitions returns the valid next statuses.""" + from app.services.ticket import VALID_TRANSITIONS + + token = await _login(client) + ticket = await _create_ticket(client, token, description="transitions helper") # status: Logged + + resp = await client.get(f"/api/tickets/{ticket['id']}/transitions") + assert resp.status_code == 200, resp.text + data = resp.json() + assert data["current_status"] == "Logged" + assert set(data["transitions"]) == set(VALID_TRANSITIONS["Logged"]) + + +async def test_transitions_helper_terminal_is_empty(client): + """A cancelled ticket exposes no selectable next statuses.""" + token = await _login(client) + ticket = await _create_ticket(client, token, description="terminal transitions") + resp = await client.patch( + f"/api/tickets/{ticket['id']}", + json={"status": "Cancelled"}, + headers={"Authorization": f"Bearer {token}"}, + ) + assert resp.status_code == 200 + + data = (await client.get(f"/api/tickets/{ticket['id']}/transitions")).json() + assert data["current_status"] == "Cancelled" + assert data["transitions"] == [] + + +# ── Assign auto-advance ─────────────────────────────────────────────── +async def _first_tech_id(client, token: str) -> int: + users = (await client.get("/api/auth/users", headers={"Authorization": f"Bearer {token}"})).json() + return next(u["id"] for u in users if u["role"] == "Tech") + + +async def test_assign_auto_advances_to_assigned(client): + """Assigning a pre-Assigned ticket advances it to Assigned with a timeline entry.""" + token = await _login(client) + ticket = await _create_ticket(client, token, description="assign advance") # status: Logged + tech_id = await _first_tech_id(client, token) + + resp = await client.patch( + f"/api/tickets/{ticket['id']}", + json={"assigned_to": tech_id}, + headers={"Authorization": f"Bearer {token}"}, + ) + assert resp.status_code == 200, resp.text + data = resp.json() + assert data["status"] == "Assigned" + assert data["assigned_to"] == tech_id + assert data["timeline"][-1]["to_status"] == "Assigned" + + +async def test_assign_does_not_regress_past_assigned(client): + """Re-assigning a ticket already past Assigned leaves its status untouched.""" + token = await _login(client) + ticket = await _create_ticket(client, token, description="no regression") + + for step in ("Triage", "Assigned", "Accepted"): + resp = await client.patch( + f"/api/tickets/{ticket['id']}", + json={"status": step}, + headers={"Authorization": f"Bearer {token}"}, + ) + assert resp.status_code == 200, resp.text + + users = (await client.get("/api/auth/users", headers={"Authorization": f"Bearer {token}"})).json() + techs = [u["id"] for u in users if u["role"] == "Tech"] + other_tech = techs[1] if len(techs) > 1 else techs[0] + + resp = await client.patch( + f"/api/tickets/{ticket['id']}", + json={"assigned_to": other_tech}, + headers={"Authorization": f"Bearer {token}"}, + ) + assert resp.status_code == 200, resp.text + assert resp.json()["status"] == "Accepted" + + # ── 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 bf361d76bc3b84b9ab99f8fc1e23275b81b8302a Mon Sep 17 00:00:00 2001 From: root Date: Sun, 2 Aug 2026 14:37:37 +0000 Subject: [PATCH 4/4] no-mistakes(document): Docs synced for Cancelled status; lint cleaned --- AGENTS.md | 5 +++-- PRD.md | 6 ++++-- app/core/security.py | 1 - app/schemas/auth.py | 2 +- app/services/auth.py | 2 -- tests/test_workflow_hardening.py | 5 ++++- 6 files changed, 12 insertions(+), 9 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 4e247d7..a3cdd37 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -74,8 +74,9 @@ Frontend: Alpine.js (CDN) + Tailwind CSS (CDN). Auth state in localStorage. Role |--------|------|------|-------------| | POST | `/api/tickets` | Bearer | Create ticket (auto-number PAV-YYYY-NNNNN) | | 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) | +| GET | `/api/tickets/{id}` | No | Get ticket detail with timeline, photos, SLA status, nested unit/category, phone | +| GET | `/api/tickets/{id}/transitions` | No | Valid next statuses for the ticket's current status (drives the detail-page status picker) | +| PATCH | `/api/tickets/{id}` | Bearer | Update ticket (validates status transitions; assigning a technician auto-advances New/Logged/Triage to Assigned) | | 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 | diff --git a/PRD.md b/PRD.md index 132ffa0..fff1fe0 100644 --- a/PRD.md +++ b/PRD.md @@ -120,7 +120,7 @@ Close Ticket Archive → Executive Reporting ``` -### Detailed Ticket Lifecycle (15 Statuses) +### Detailed Ticket Lifecycle (16 Statuses) | # | Status | Description | Who Sets | |---|--------|-------------|----------| @@ -139,6 +139,7 @@ Archive → Executive Reporting | 13 | **Wahab Review** | Final QA oversight and closure approval | Wahab | | 14 | **Closed** | Ticket officially closed | Wahab / Nicholas | | 15 | **Reopened** | Issue resurfaces within 7 days | Agent / System | +| 16 | **Cancelled** | Ticket withdrawn (test/scratch cleanup, guest no-show); terminal — reachable from any active state, excluded from SLA breach reporting and dashboard active counts | Any staff (via dashboard) | ### Step-by-Step Detail @@ -154,9 +155,10 @@ Archive → Executive Reporting 9. **Wahab Review** — Wahab does final QA oversight in dashboard → status → Wahab Review → approves or sends back 10. **Auto Follow-up** (48 hours later — internal only) — System flags ticket for Wahab/Nicholas: `Issue PAV-001 (505E WC) — resolved 48h ago. Any follow-up needed?` → If issue resurfaces → auto-reopens, escalates to Ama -### Reopening & Reassignment +### Reopening, Reassignment & Cancellation - **Reopening:** If a verified-closed issue resurfaces within 7 days, the gatekeeper can Reopen it. A reopened issue auto-escalates to Ama. +- **Cancellation:** Any ticket can be cancelled from an active state (terminal; cannot resume work). Cancelled tickets are excluded from SLA breach reporting and dashboard active counts. - **Reassignment:** A technician who cannot complete a job can request: `#reassign PAV-001 [technician name]` → Nicholas receives notification and approves/reassigns in dashboard. --- diff --git a/app/core/security.py b/app/core/security.py index c93eff6..6e0657d 100644 --- a/app/core/security.py +++ b/app/core/security.py @@ -3,7 +3,6 @@ from __future__ import annotations from datetime import datetime, timedelta, timezone -from functools import wraps from typing import Annotated, Any, Callable import bcrypt diff --git a/app/schemas/auth.py b/app/schemas/auth.py index cf2b704..3febf54 100644 --- a/app/schemas/auth.py +++ b/app/schemas/auth.py @@ -2,7 +2,7 @@ from __future__ import annotations -from pydantic import BaseModel, EmailStr +from pydantic import BaseModel class RegisterRequest(BaseModel): diff --git a/app/services/auth.py b/app/services/auth.py index de505a7..9bed864 100644 --- a/app/services/auth.py +++ b/app/services/auth.py @@ -3,11 +3,9 @@ from __future__ import annotations from fastapi import HTTPException, status -from jose import JWTError, jwt from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession -from app.core.config import settings from app.core.security import ( create_access_token, create_refresh_token, diff --git a/tests/test_workflow_hardening.py b/tests/test_workflow_hardening.py index fad9a85..8a157a0 100644 --- a/tests/test_workflow_hardening.py +++ b/tests/test_workflow_hardening.py @@ -281,7 +281,10 @@ async def test_ticket_number_uses_max_plus_one(client, seed_tickets): await session.commit() fourth = await _create_ticket(client, token, description="numbering d") - suffix = lambda tn: int(tn.rsplit("-", 1)[1]) + + def suffix(tn: str) -> int: + return 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)