Author SHA1 Message Date
root fb0c1784d0 no-mistakes(review): Extend ensure_legacy_schema to add tickets.reported_at with backfill 2026-08-03 09:45:41 +00:00
root e0a7479c3e feat: backdated reported date for old active tickets
Wahab can now enter historical tickets that keep their true reported date:

- Add nullable tickets.reported_at (DateTime) via alembic d5e0f2a1c3b4;
  backfill existing rows from created_at so nothing shows empty.
- TicketCreate.reported_at (optional) is persisted by create_ticket and
  defaults to now when omitted, so existing create behavior is unchanged.
- New-issue form gains a 'Reported Date' date picker (defaults to today,
  past dates allowed, future blocked) and sends reported_at in the payload.
- List and detail pages show 'Reported' next to 'Created' (date-only,
  labeled) so backdated tickets are obvious; SLA deadline is unchanged and
  still runs from creation time so backfilling never instantly breaches.
- Tests: backdated create persists + is exposed on list/detail; omitted
  reported_at defaults to now; SLA window unchanged; full datetime round trip.
2026-08-03 09:40:23 +00:00
abiba-bot 30c0c01c59 Merge pull request 'Wahab demo prep — workflow hardening, Cancelled status, ticket cleanup support' (#5) from fm/denya-wahab-cleanup-hardening into main 2026-08-02 15:01:24 +00:00
root bf361d76bc no-mistakes(document): Docs synced for Cancelled status; lint cleaned 2026-08-02 14:37:37 +00:00
root 6b92c9098c no-mistakes(review): Guard photo cleanup; add transitions helper and assign auto-advance 2026-08-02 14:26:48 +00:00
root 237284b012 no-mistakes(review): Make Cancelled reachable from all active states; wire phone PATCH; exclude cancelled from CEO resolution avg 2026-08-02 14:12:46 +00:00
root 1dd88a141e 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
2026-08-02 14:06:30 +00:00
abiba-bot ffed595dbd Merge pull request 'Sprint A Wahab review batch — categories, property hierarchy, priority grouping' (#4) from fm/denya-wahab-sprint-a into main 2026-08-02 13:36:31 +00:00
23 changed files with 774 additions and 78 deletions
+15 -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,16 @@ 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
- `reported_at` (nullable DateTime, alembic `d5e0f2a1c3b4`) records a ticket's original reported date;
`TicketCreate.reported_at` lets Admin/Wahab enter backdated tickets that stay active. It defaults to
now when omitted (migration backfilled existing rows from `created_at`). SLA deadlines run from
`created_at`, not `reported_at` — backfilling history never instantly breaches a ticket.
### 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')
@@ -0,0 +1,47 @@
"""tickets.reported_at column — original reported date for backdated tickets
Revision ID: d5e0f2a1c3b4
Revises: c4e8f1a2d3b4
Create Date: 2026-08-03 00:00:00.000000
Backdated-ticket support (Wahab demo): one schema change.
* Add ``tickets.reported_at`` (nullable DateTime) so historical/backfilled
tickets keep their true report date instead of inheriting today's
``created_at``.
* Backfill existing rows with their ``created_at`` value so no ticket shows
an empty reported date after the upgrade. The service layer also defaults
new tickets without a ``reported_at`` to now, so the column is effectively
always populated from here on.
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'd5e0f2a1c3b4'
down_revision: Union[str, Sequence[str], None] = 'c4e8f1a2d3b4'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Add tickets.reported_at (nullable) and backfill from created_at."""
op.add_column(
'tickets',
sa.Column(
'reported_at',
sa.DateTime(),
nullable=True,
comment='Original reported date; backdated/backfilled tickets keep their true report date',
),
)
# Backfill: every existing ticket was reported when it was created.
op.execute('UPDATE tickets SET reported_at = created_at WHERE reported_at IS NULL')
def downgrade() -> None:
"""Drop tickets.reported_at."""
op.drop_column('tickets', 'reported_at')
-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
+18
View File
@@ -28,6 +28,24 @@ 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)")
if "reported_at" not in ticket_columns:
await conn.execute(
text("ALTER TABLE tickets ADD COLUMN reported_at DATETIME")
)
await conn.execute(
text("UPDATE tickets SET reported_at = created_at WHERE reported_at IS NULL")
)
logger.info("Added missing tickets.reported_at column (legacy database)")
result = await conn.execute(
text(
"UPDATE categories SET name = 'Missing Item' "
+7 -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,
@@ -54,6 +55,11 @@ class Ticket(Base):
customer_rating: Mapped[int | None] = mapped_column(Integer, nullable=True)
reopen_count: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
closed_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
reported_at: Mapped[datetime | None] = mapped_column(
DateTime,
nullable=True,
comment="Original reported date. Backdated/backfilled tickets keep their true report date; NULL falls back to created_at.",
)
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), nullable=False)
updated_at: Mapped[datetime] = mapped_column(
DateTime,
+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):
+6
View File
@@ -46,6 +46,7 @@ class TicketCreate(BaseModel):
assigned_to: int | None = None
customer_name: str | None = None
phone: str | None = None
reported_at: datetime | None = None # original report date for backdated/backfilled tickets; defaults to now when omitted
class TicketUpdate(BaseModel):
@@ -54,6 +55,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,9 +97,11 @@ 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
reported_at: datetime | None = None
created_at: datetime
updated_at: datetime
@@ -113,6 +117,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,
+74 -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(
@@ -102,6 +114,10 @@ async def create_ticket(
ticket_number = await _generate_ticket_number(db)
priority = data.get("priority")
sla_deadline = compute_sla_deadline(priority) if priority else None
# Original report date: backdated/backfilled tickets keep their true date;
# when omitted the ticket is considered reported right now. The SLA clock
# is unchanged — deadlines run from creation time, not the reported date.
reported_at = data.get("reported_at") or datetime.now(timezone.utc)
ticket = Ticket(
ticket_number=ticket_number,
@@ -110,9 +126,11 @@ 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"),
reported_at=reported_at,
sla_deadline=sla_deadline,
)
db.add(ticket)
@@ -223,6 +241,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 +302,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 +331,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 +345,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
+38 -26
View File
@@ -23,7 +23,11 @@
<span class="px-3 py-1 rounded-full text-sm font-medium" :class="statusClass(ticket.status)" x-text="ticket.status"></span>
<span class="px-3 py-1 rounded text-sm font-medium" :class="priorityClass(ticket.priority)" x-text="priorityBadge(ticket.priority)"></span>
</div>
<p class="text-gray-500 mt-1">Created <span x-text="formatDate(ticket.created_at)"></span></p>
<p class="text-gray-500 mt-1">Created <span x-text="formatDate(ticket.created_at)"></span>
<template x-if="ticket.reported_at && formatDateShort(ticket.reported_at) !== formatDateShort(ticket.created_at)">
<span> · Reported <span class="font-medium text-gray-600" x-text="formatDateShort(ticket.reported_at)"></span></span>
</template>
</p>
</div>
<div class="flex items-center space-x-2">
<button @click="showStatusModal = true" class="px-4 py-2 bg-denya-600 text-white rounded-lg hover:bg-denya-700 transition text-sm font-medium">Update Status</button>
@@ -121,10 +125,18 @@
<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>
</div>
<div class="flex justify-between">
<dt class="text-sm text-gray-500">Reported</dt>
<dd class="text-sm font-medium text-gray-900" x-text="ticket.reported_at ? formatDateShort(ticket.reported_at) : formatDateShort(ticket.created_at)"></dd>
</div>
<div class="flex justify-between">
<dt class="text-sm text-gray-500">Priority</dt>
<dd><span class="px-2 py-0.5 rounded text-xs font-medium" :class="priorityClass(ticket.priority)" x-text="priorityBadge(ticket.priority)"></span></dd>
@@ -200,20 +212,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 +299,7 @@
statusForm: { newStatus: '', note: '' },
statusSubmitting: false,
statusError: '',
availableTransitions: [],
// Assign form
assignForm: { technicianId: '' },
@@ -311,6 +313,7 @@
async init() {
await this.loadTicket();
await this.loadTransitions();
if (app().isFM || app().isAdmin) {
await this.loadTechnicians();
}
@@ -327,18 +330,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 +364,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 +387,7 @@
assigned_to: parseInt(this.assignForm.technicianId)
});
this.ticket = updated;
await this.loadTransitions();
this.showAssignModal = false;
app().showToast('Technician assigned', 'success');
} catch (e) {
+8 -3
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>
@@ -129,6 +130,7 @@
<th class="px-5 py-3 text-left cursor-pointer hover:text-gray-900" @click="sortBy('created_at')">
Created <span x-show="sortField === 'created_at'" x-text="sortDir === 'asc' ? '↑' : '↓'"></span>
</th>
<th class="px-5 py-3 text-left">Reported</th>
<th class="px-5 py-3 text-left">SLA</th>
</tr>
</thead>
@@ -141,8 +143,9 @@
<td class="px-5 py-3 text-gray-600 max-w-xs truncate" x-text="ticket.description || ''"></td>
<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 text-gray-500 text-xs" x-text="formatDateShort(ticket.reported_at || 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>
@@ -150,7 +153,7 @@
</tr>
</template>
<tr x-show="!tickets.length && !loading">
<td colspan="7" class="px-5 py-16 text-center text-gray-400">
<td colspan="8" class="px-5 py-16 text-center text-gray-400">
<svg class="w-12 h-12 mx-auto text-gray-300 mb-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"/></svg>
No tickets match your filters
</td>
@@ -177,6 +180,7 @@
<th class="px-5 py-3 text-left">Description</th>
<th class="px-5 py-3 text-left">Assigned To</th>
<th class="px-5 py-3 text-left">Created</th>
<th class="px-5 py-3 text-left">Reported</th>
<th class="px-5 py-3 text-left">SLA</th>
</tr>
</thead>
@@ -189,8 +193,9 @@
<td class="px-5 py-3 text-gray-600 max-w-xs truncate" x-text="ticket.description || ''"></td>
<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 text-gray-500 text-xs" x-text="formatDateShort(ticket.reported_at || 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>
+31 -1
View File
@@ -154,6 +154,15 @@
</div>
</div>
<!-- Reported date (backdating support) -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Reported Date</label>
<input type="date" x-model="form.reported_date" :max="todayStr" class="w-full px-4 py-2.5 rounded-lg border border-gray-300 focus:ring-2 focus:ring-denya-500 focus:border-transparent outline-none">
<p class="mt-1 text-xs text-gray-400">Defaults to today. Use a past date when entering an old/backlogged issue — it stays active in the normal workflow.</p>
</div>
</div>
<!-- Photo Upload -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Photos (Before)</label>
@@ -205,8 +214,10 @@
priority: '',
description: '',
reporter: '',
reported_via: ''
reported_via: '',
reported_date: ''
},
todayStr: '',
priorityAuto: false,
categories: [],
subCategories: [],
@@ -223,6 +234,14 @@
async init() {
await this.loadCategories();
await this.loadUnits();
// Default reported date to today (local), allow backdating via the date picker
this.todayStr = this.localDateStr(new Date());
if (!this.form.reported_date) this.form.reported_date = this.todayStr;
},
localDateStr(d) {
const offset = d.getTimezoneOffset();
return new Date(d.getTime() - offset * 60000).toISOString().slice(0, 10);
},
// ── Report mode ──────────────────────────────────────────
@@ -363,6 +382,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 = {
@@ -374,6 +403,7 @@
unit_id: this.form.unit.id,
customer_name: this.form.customer_name || null,
phone: this.form.phone || null,
reported_at: this.form.reported_date || null,
};
const ticket = await app().apiPost('/api/tickets', payload);
+118
View File
@@ -0,0 +1,118 @@
"""Tests for backdated reported-date support (Wahab demo).
Anchors:
* A ticket created with a past ``reported_at`` persists that date and it is
exposed on list + detail responses — this is how Wahab enters old tickets
that stay active in the normal workflow.
* A ticket created without ``reported_at`` defaults to "now", so existing
create behavior is unchanged.
* The reported date is metadata only: SLA deadlines still run from creation
time and no age/backdate restriction kicks in.
"""
from __future__ import annotations
from datetime import datetime
import pytest
pytestmark = pytest.mark.asyncio
def _naive(iso: str) -> datetime:
"""Parse an ISO datetime and strip any tz offset for safe comparison."""
dt = datetime.fromisoformat(iso)
return dt.replace(tzinfo=None) if dt.tzinfo is not None else dt
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": "Backdate Test",
"reported_via": "walk-in",
"description": "backdate 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()
async def test_create_with_backdated_reported_at_persists(client):
"""Wahab (Admin/Wahab) can enter an old ticket and its date sticks."""
token = await _login(client)
ticket = await _create_ticket(
client,
token,
reported_at="2026-07-20",
description="Old plumbing issue reported weeks ago",
)
assert ticket["reported_at"] is not None
assert ticket["reported_at"].startswith("2026-07-20")
# Still an active ticket in the normal workflow — no age restriction.
assert ticket["status"] in {"New", "Logged"}
# Detail endpoint exposes the reported date.
detail = await client.get(f"/api/tickets/{ticket['id']}")
assert detail.status_code == 200
assert detail.json()["reported_at"].startswith("2026-07-20")
# List endpoint exposes it too.
listing = await client.get("/api/tickets")
assert listing.status_code == 200
listed = next(t for t in listing.json()["items"] if t["id"] == ticket["id"])
assert listed["reported_at"].startswith("2026-07-20")
async def test_create_without_reported_at_defaults_to_now(client):
"""Omitting reported_at behaves exactly as before: reported == created."""
token = await _login(client)
ticket = await _create_ticket(client, token, description="normal today ticket")
assert ticket["reported_at"] is not None
reported = _naive(ticket["reported_at"])
created = _naive(ticket["created_at"])
assert abs((reported - created).total_seconds()) < 60
async def test_reported_at_does_not_shift_sla_deadline(client):
"""SLA computation is unchanged: deadlines run from creation time."""
token = await _login(client)
ticket = await _create_ticket(
client,
token,
priority="urgent",
reported_at="2026-01-01",
description="old urgent ticket",
)
assert ticket["sla_deadline"] is not None
created = _naive(ticket["created_at"])
deadline = _naive(ticket["sla_deadline"])
hours = (deadline - created).total_seconds() / 3600
assert 3.5 <= hours <= 4.5 # urgent → 4 h resolution window from creation
async def test_reported_at_round_trips_full_datetime(client):
"""A precise datetime (not just a date) survives the round trip."""
token = await _login(client)
reported = "2026-07-20T14:30:00"
ticket = await _create_ticket(client, token, reported_at=reported, description="datetime round trip")
assert ticket["reported_at"] is not None
parsed = _naive(ticket["reported_at"])
assert parsed.date().isoformat() == "2026-07-20"
assert parsed.hour == 14 and parsed.minute == 30
+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)