Merge pull request 'Wahab demo prep — workflow hardening, Cancelled status, ticket cleanup support' (#5) from fm/denya-wahab-cleanup-hardening into main

This commit was merged in pull request #5.
This commit is contained in:
2026-08-02 15:01:24 +00:00
21 changed files with 549 additions and 75 deletions
+10 -4
View File
@@ -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 |
@@ -73,8 +74,10 @@ 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 |
| POST | `/api/tickets/{id}/photos` | Bearer | Upload photos (multipart, is_before param) |
@@ -91,8 +94,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.
+4 -2
View File
@@ -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.
---
@@ -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')
-1
View File
@@ -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
+10
View File
@@ -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' "
+2 -1
View File
@@ -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,
+16
View File
@@ -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,
+43 -1
View File
@@ -2,6 +2,7 @@
from __future__ import annotations
import logging
import uuid
from datetime import datetime
from pathlib import Path
@@ -12,7 +13,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
@@ -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,
@@ -250,6 +266,32 @@ 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]
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) ───────────────────────
@router.post("/{ticket_id}/status", response_model=TicketOut)
async def change_ticket_status(
+1 -1
View File
@@ -2,7 +2,7 @@
from __future__ import annotations
from pydantic import BaseModel, EmailStr
from pydantic import BaseModel
class RegisterRequest(BaseModel):
+4
View File
@@ -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
@@ -95,6 +96,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 +115,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
-2
View File
@@ -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,
+5 -2
View File
@@ -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,
+69 -23
View File
@@ -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
@@ -18,21 +18,23 @@ 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"],
"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"],
"Completed": ["On-Field Verification", "In Progress"],
"On-Field Verification": ["Wahab Review", "Completed", "Closed"],
"Wahab Review": ["Closed", "On-Field Verification"],
"New": ["Logged", "Cancelled"],
"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", "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": [],
}
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"),
@@ -223,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, [])
@@ -283,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,
@@ -296,7 +326,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 +340,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
+3 -1
View File
@@ -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';
},
+5 -5
View File
@@ -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`;
+4 -4
View File
@@ -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);
+1 -1
View File
@@ -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
+29 -25
View File
@@ -121,6 +121,10 @@
<dt class="text-sm text-gray-500">Reporter</dt>
<dd class="text-sm font-medium text-gray-900" x-text="ticket.reporter || '—'"></dd>
</div>
<div class="flex justify-between" x-show="ticket.phone">
<dt class="text-sm text-gray-500">Phone</dt>
<dd class="text-sm font-medium text-gray-900" x-text="ticket.phone"></dd>
</div>
<div class="flex justify-between">
<dt class="text-sm text-gray-500">Reported Via</dt>
<dd class="text-sm font-medium text-gray-900 capitalize" x-text="ticket.reported_via || '—'"></dd>
@@ -200,20 +204,9 @@
<label class="block text-sm font-medium text-gray-700 mb-1">Current: <span class="font-bold" x-text="ticket.status"></span></label>
<select x-model="statusForm.newStatus" class="w-full px-4 py-2.5 rounded-lg border border-gray-300 focus:ring-2 focus:ring-denya-500 outline-none">
<option value="">Select new status</option>
<option value="Logged">Logged</option>
<option value="Triage">Triage</option>
<option value="Assigned">Assigned</option>
<option value="Accepted">Accepted</option>
<option value="Travelling">Travelling</option>
<option value="On Site">On Site</option>
<option value="In Progress">In Progress</option>
<option value="Waiting Parts">Waiting Parts</option>
<option value="Escalated">Escalated</option>
<option value="Completed">Completed</option>
<option value="On-Field Verification">On-Field Verification</option>
<option value="Wahab Review">Wahab Review</option>
<option value="Closed">Closed</option>
<option value="Reopened">Reopened</option>
<template x-for="target in availableTransitions" :key="target">
<option :value="target" x-text="target"></option>
</template>
</select>
</div>
<div>
@@ -298,6 +291,7 @@
statusForm: { newStatus: '', note: '' },
statusSubmitting: false,
statusError: '',
availableTransitions: [],
// Assign form
assignForm: { technicianId: '' },
@@ -311,6 +305,7 @@
async init() {
await this.loadTicket();
await this.loadTransitions();
if (app().isFM || app().isAdmin) {
await this.loadTechnicians();
}
@@ -327,18 +322,25 @@
}
},
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() {
// 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() {
@@ -354,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');
@@ -376,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) {
+3 -2
View File
@@ -38,6 +38,7 @@
<option value="Wahab Review">Wahab Review</option>
<option value="Closed">Closed</option>
<option value="Reopened">Reopened</option>
<option value="Cancelled">Cancelled</option>
</select>
</div>
<div>
@@ -142,7 +143,7 @@
<td class="px-5 py-3 text-gray-500 text-xs" x-text="ticket.assigned_technician_name || '—'"></td>
<td class="px-5 py-3 text-gray-500 text-xs" x-text="formatDate(ticket.created_at)"></td>
<td class="px-5 py-3">
<span x-show="ticket.sla_deadline" class="text-xs" :class="new Date(ticket.sla_deadline) < new Date() && !['Closed','Completed'].includes(ticket.status) ? 'text-red-600 font-medium' : 'text-gray-400'">
<span x-show="ticket.sla_deadline" class="text-xs" :class="new Date(ticket.sla_deadline) < new Date() && !['Closed','Completed','Cancelled'].includes(ticket.status) ? 'text-red-600 font-medium' : 'text-gray-400'">
<span x-text="formatDateShort(ticket.sla_deadline)"></span>
</span>
<span x-show="!ticket.sla_deadline" class="text-xs text-gray-300"></span>
@@ -190,7 +191,7 @@
<td class="px-5 py-3 text-gray-500 text-xs" x-text="ticket.assigned_technician_name || '—'"></td>
<td class="px-5 py-3 text-gray-500 text-xs" x-text="formatDate(ticket.created_at)"></td>
<td class="px-5 py-3">
<span x-show="ticket.sla_deadline" class="text-xs" :class="new Date(ticket.sla_deadline) < new Date() && !['Closed','Completed'].includes(ticket.status) ? 'text-red-600 font-medium' : 'text-gray-400'">
<span x-show="ticket.sla_deadline" class="text-xs" :class="new Date(ticket.sla_deadline) < new Date() && !['Closed','Completed','Cancelled'].includes(ticket.status) ? 'text-red-600 font-medium' : 'text-gray-400'">
<span x-text="formatDateShort(ticket.sla_deadline)"></span>
</span>
<span x-show="!ticket.sla_deadline" class="text-xs text-gray-300"></span>
+10
View File
@@ -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 = {
+290
View File
@@ -0,0 +1,290 @@
"""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 (
"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"
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())
# ── 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 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")
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)