feat: Wahab demo prep — Cancelled status, phone persistence, admin delete, users picker, detail-page fixes
- 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
This commit is contained in:
@@ -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.
|
||||
|
||||
|
||||
@@ -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')
|
||||
+10
@@ -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' "
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
+20
-1
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
+5
-2
@@ -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,
|
||||
|
||||
+46
-17
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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';
|
||||
},
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
@@ -214,6 +218,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>
|
||||
@@ -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() {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user