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 |
|
| POST | `/api/auth/refresh` | Token | Refresh tokens |
|
||||||
| GET | `/api/auth/me` | Bearer | Current user |
|
| GET | `/api/auth/me` | Bearer | Current user |
|
||||||
| GET | `/api/auth/admin-only` | Admin/Jerome, Admin/Wahab | RBAC demo endpoint |
|
| 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 |
|
| POST | `/api/whatsapp/mock` | No | Mock WhatsApp |
|
||||||
| GET | `/api/whatsapp/mock-log` | No | Recent mock submissions |
|
| 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` | 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 |
|
| GET | `/api/tickets/{id}` | No | Get ticket detail with timeline, photos, SLA status |
|
||||||
| PATCH | `/api/tickets/{id}` | Bearer | Update ticket (validates status transitions) |
|
| 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 |
|
| POST | `/api/tickets/{id}/status` | Bearer | Change status with note |
|
||||||
| GET | `/api/tickets/{id}/sla` | No | Check SLA breach status |
|
| GET | `/api/tickets/{id}/sla` | No | Check SLA breach status |
|
||||||
| POST | `/api/tickets/{id}/photos` | Bearer | Upload photos (multipart, is_before param) |
|
| 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)
|
## Ticket System (Sprint 2)
|
||||||
|
|
||||||
### Status Lifecycle (15 statuses)
|
### 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
|
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.
|
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")
|
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)")
|
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(
|
result = await conn.execute(
|
||||||
text(
|
text(
|
||||||
"UPDATE categories SET name = 'Missing Item' "
|
"UPDATE categories SET name = 'Missing Item' "
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ class Ticket(Base):
|
|||||||
comment=(
|
comment=(
|
||||||
"New, Logged, Triage, Assigned, Accepted, Travelling, On Site, "
|
"New, Logged, Triage, Assigned, Accepted, Travelling, On Site, "
|
||||||
"In Progress, Waiting Parts, Escalated, Completed, "
|
"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)
|
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",
|
comment="urgent, high, medium, low",
|
||||||
)
|
)
|
||||||
reporter: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
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(
|
reported_via: Mapped[str | None] = mapped_column(
|
||||||
String(20),
|
String(20),
|
||||||
nullable=True,
|
nullable=True,
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ from __future__ import annotations
|
|||||||
from typing import Annotated
|
from typing import Annotated
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends
|
from fastapi import APIRouter, Depends
|
||||||
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.core.database import get_db
|
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 = 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)
|
@router.post("/register", response_model=UserOut, status_code=201)
|
||||||
async def register(
|
async def register(
|
||||||
body: RegisterRequest,
|
body: RegisterRequest,
|
||||||
|
|||||||
+20
-1
@@ -12,7 +12,7 @@ from sqlalchemy import select
|
|||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.core.database import get_db
|
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.category import Category
|
||||||
from app.models.ticket import Ticket, TicketPhoto
|
from app.models.ticket import Ticket, TicketPhoto
|
||||||
from app.models.unit import Unit
|
from app.models.unit import Unit
|
||||||
@@ -250,6 +250,25 @@ async def update_ticket(
|
|||||||
return 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) ───────────────────────
|
# ── Status Transitions (convenience endpoints) ───────────────────────
|
||||||
@router.post("/{ticket_id}/status", response_model=TicketOut)
|
@router.post("/{ticket_id}/status", response_model=TicketOut)
|
||||||
async def change_ticket_status(
|
async def change_ticket_status(
|
||||||
|
|||||||
@@ -95,6 +95,7 @@ class TicketBrief(BaseModel):
|
|||||||
assigned_to: int | None = None
|
assigned_to: int | None = None
|
||||||
assigned_technician_name: str | None = None
|
assigned_technician_name: str | None = None
|
||||||
reporter: str | None = None
|
reporter: str | None = None
|
||||||
|
phone: str | None = None
|
||||||
description: str | None = None
|
description: str | None = None
|
||||||
sla_deadline: datetime | None = None
|
sla_deadline: datetime | None = None
|
||||||
reopen_count: int = 0
|
reopen_count: int = 0
|
||||||
@@ -113,6 +114,8 @@ class TicketOut(TicketBrief):
|
|||||||
customer_rating: int | None = None
|
customer_rating: int | None = None
|
||||||
timeline: list[TicketTimelineOut] = []
|
timeline: list[TicketTimelineOut] = []
|
||||||
photos: list[TicketPhotoOut] = []
|
photos: list[TicketPhotoOut] = []
|
||||||
|
unit: UnitOut | None = None
|
||||||
|
category: CategoryOut | None = None
|
||||||
sla_status: dict | 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
|
return datetime.now(timezone.utc) > deadline
|
||||||
|
|
||||||
|
|
||||||
|
TERMINAL_STATUSES = {"Closed", "Completed", "Cancelled"}
|
||||||
|
|
||||||
|
|
||||||
def is_sla_breached(ticket: Ticket) -> bool:
|
def is_sla_breached(ticket: Ticket) -> bool:
|
||||||
"""Return True if the ticket's resolution SLA deadline has passed."""
|
"""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
|
return False
|
||||||
deadline = ticket.sla_deadline
|
deadline = ticket.sla_deadline
|
||||||
if deadline.tzinfo is None:
|
if deadline.tzinfo is None:
|
||||||
@@ -88,7 +91,7 @@ async def get_sla_status(ticket: Ticket) -> dict:
|
|||||||
rd = resolution_deadline
|
rd = resolution_deadline
|
||||||
if rd.tzinfo is None:
|
if rd.tzinfo is None:
|
||||||
rd = rd.replace(tzinfo=timezone.utc)
|
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 {
|
return {
|
||||||
"priority": ticket.priority,
|
"priority": ticket.priority,
|
||||||
|
|||||||
+46
-17
@@ -6,11 +6,11 @@ from datetime import datetime, timezone
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from fastapi import HTTPException, status
|
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.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy.orm import selectinload
|
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.unit import Unit
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
from app.services.sla import compute_sla_deadline
|
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
|
# Keys: current status → list of valid next statuses
|
||||||
VALID_TRANSITIONS: dict[str, list[str]] = {
|
VALID_TRANSITIONS: dict[str, list[str]] = {
|
||||||
"New": ["Logged"],
|
"New": ["Logged"],
|
||||||
"Logged": ["Triage", "Closed"],
|
"Logged": ["Triage", "Closed", "Cancelled"],
|
||||||
"Triage": ["Assigned", "Escalated"],
|
"Triage": ["Assigned", "Escalated", "Cancelled"],
|
||||||
"Assigned": ["Accepted", "Triage"],
|
"Assigned": ["Accepted", "Triage", "Cancelled"],
|
||||||
"Accepted": ["Travelling", "Triage"],
|
"Accepted": ["Travelling", "Triage", "Cancelled"],
|
||||||
"Travelling": ["On Site", "Triage"],
|
"Travelling": ["On Site", "Triage", "Cancelled"],
|
||||||
"On Site": ["In Progress", "Triage"],
|
"On Site": ["In Progress", "Triage", "Cancelled"],
|
||||||
"In Progress": ["Waiting Parts", "Escalated", "Completed"],
|
"In Progress": ["Waiting Parts", "Escalated", "Completed", "Cancelled"],
|
||||||
"Waiting Parts": ["In Progress", "Escalated"],
|
"Waiting Parts": ["In Progress", "Escalated", "Cancelled"],
|
||||||
"Escalated": ["Triage", "In Progress", "Completed", "Closed"],
|
"Escalated": ["Triage", "In Progress", "Completed", "Closed", "Cancelled"],
|
||||||
"Completed": ["On-Field Verification", "In Progress"],
|
"Completed": ["On-Field Verification", "In Progress"],
|
||||||
"On-Field Verification": ["Wahab Review", "Completed", "Closed"],
|
"On-Field Verification": ["Wahab Review", "Completed", "Closed"],
|
||||||
"Wahab Review": ["Closed", "On-Field Verification"],
|
"Wahab Review": ["Closed", "On-Field Verification"],
|
||||||
"Closed": ["Reopened"],
|
"Closed": ["Reopened"],
|
||||||
"Reopened": ["Triage", "Logged"],
|
"Reopened": ["Triage", "Logged"],
|
||||||
|
# Terminal: cancelled tickets cannot resume work.
|
||||||
|
"Cancelled": [],
|
||||||
}
|
}
|
||||||
|
|
||||||
REOPEN_WINDOW_DAYS = 7
|
REOPEN_WINDOW_DAYS = 7
|
||||||
@@ -41,15 +43,25 @@ SLA_ACK_USER = "Ama"
|
|||||||
|
|
||||||
# ── Helpers ──────────────────────────────────────────────────────────
|
# ── Helpers ──────────────────────────────────────────────────────────
|
||||||
async def _generate_ticket_number(db: AsyncSession) -> str:
|
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
|
year = datetime.now(timezone.utc).year
|
||||||
prefix = f"PAV-{year}-"
|
prefix = f"PAV-{year}-"
|
||||||
# Count existing tickets this year
|
|
||||||
result = await db.execute(
|
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
|
max_number = result.scalar()
|
||||||
return f"{prefix}{count + 1:05d}"
|
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(
|
async def _log_status_change(
|
||||||
@@ -110,6 +122,7 @@ async def create_ticket(
|
|||||||
category_id=data.get("category_id"),
|
category_id=data.get("category_id"),
|
||||||
priority=priority,
|
priority=priority,
|
||||||
reporter=data.get("reporter") or data.get("customer_name"),
|
reporter=data.get("reporter") or data.get("customer_name"),
|
||||||
|
phone=data.get("phone"),
|
||||||
reported_via=data.get("reported_via"),
|
reported_via=data.get("reported_via"),
|
||||||
description=data.get("description"),
|
description=data.get("description"),
|
||||||
assigned_to=data.get("assigned_to"),
|
assigned_to=data.get("assigned_to"),
|
||||||
@@ -296,7 +309,7 @@ async def update_ticket(
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Update other fields
|
# 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"):
|
"description", "assigned_to", "eta", "cost", "parts_used"):
|
||||||
if field in data:
|
if field in data:
|
||||||
setattr(ticket, field, data[field])
|
setattr(ticket, field, data[field])
|
||||||
@@ -310,3 +323,19 @@ async def update_ticket(
|
|||||||
return 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-wahab-review { @apply bg-violet-100 text-violet-800; }
|
||||||
.status-closed { @apply bg-gray-200 text-gray-600; }
|
.status-closed { @apply bg-gray-200 text-gray-600; }
|
||||||
.status-reopened { @apply bg-pink-100 text-pink-800; }
|
.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-urgent { @apply bg-red-100 text-red-800 border-red-300; }
|
||||||
.priority-high { @apply bg-orange-100 text-orange-800 border-orange-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; }
|
.priority-medium { @apply bg-yellow-100 text-yellow-800 border-yellow-300; }
|
||||||
@@ -318,7 +319,8 @@
|
|||||||
'on-field verification': 'status-on-field-verification',
|
'on-field verification': 'status-on-field-verification',
|
||||||
'wahab review': 'status-wahab-review',
|
'wahab review': 'status-wahab-review',
|
||||||
'closed': 'status-closed',
|
'closed': 'status-closed',
|
||||||
'reopened': 'status-reopened'
|
'reopened': 'status-reopened',
|
||||||
|
'cancelled': 'status-cancelled'
|
||||||
};
|
};
|
||||||
return map[status.toLowerCase()] || 'bg-gray-100 text-gray-800';
|
return map[status.toLowerCase()] || 'bg-gray-100 text-gray-800';
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -189,11 +189,11 @@
|
|||||||
if (!all.length) return;
|
if (!all.length) return;
|
||||||
|
|
||||||
// Basic KPIs
|
// Basic KPIs
|
||||||
const open = 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'].includes(t.status));
|
const closed = all.filter(t => ['Closed', 'Completed', 'Cancelled'].includes(t.status));
|
||||||
const urgent = all.filter(t => t.priority === 'urgent');
|
const urgent = all.filter(t => t.priority === 'urgent');
|
||||||
const withSLA = all.filter(t => t.sla_deadline);
|
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
|
// Monthly restored
|
||||||
const oneWeekAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
|
const oneWeekAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
|
||||||
@@ -270,7 +270,7 @@
|
|||||||
|
|
||||||
// Risk indicators
|
// Risk indicators
|
||||||
this.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,
|
reopenedThisWeek,
|
||||||
overdueJobs: open.filter(t => t.sla_deadline && new Date(t.sla_deadline) < new Date()).length,
|
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,
|
pendingVerification: all.filter(t => ['Completed', 'On-Field Verification'].includes(t.status)).length,
|
||||||
@@ -280,7 +280,7 @@
|
|||||||
},
|
},
|
||||||
|
|
||||||
calcAvgResponse(all) {
|
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 '—';
|
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;
|
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`;
|
if (avgHrs < 1) return `${Math.round(avgHrs * 60)}m`;
|
||||||
@@ -288,7 +288,7 @@
|
|||||||
},
|
},
|
||||||
|
|
||||||
calcAvgResolution(all) {
|
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 '—';
|
if (!closed.length) return '—';
|
||||||
const avgHrs = closed.reduce((sum, t) => {
|
const avgHrs = closed.reduce((sum, t) => {
|
||||||
const diff = (new Date(t.updated_at) - new Date(t.created_at)) / (1000 * 60 * 60);
|
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;
|
const newToday = all.filter(t => new Date(t.created_at) >= today && t.status === 'New').length;
|
||||||
|
|
||||||
// Open tickets (not closed/completed)
|
// 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
|
// By priority
|
||||||
const byPriority = { urgent: 0, high: 0, medium: 0, low: 0 };
|
const byPriority = { urgent: 0, high: 0, medium: 0, low: 0 };
|
||||||
open.forEach(t => { if (t.priority) byPriority[t.priority] = (byPriority[t.priority] || 0) + 1; });
|
open.forEach(t => { if (t.priority) byPriority[t.priority] = (byPriority[t.priority] || 0) + 1; });
|
||||||
|
|
||||||
// Oldest unassigned
|
// 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));
|
.sort((a, b) => new Date(a.created_at) - new Date(b.created_at));
|
||||||
this.oldestUnassigned = unassigned[0] || null;
|
this.oldestUnassigned = unassigned[0] || null;
|
||||||
|
|
||||||
// SLA compliance (rough: tickets with sla_deadline not breached)
|
// SLA compliance (rough: tickets with sla_deadline not breached)
|
||||||
const withSLA = all.filter(t => t.sla_deadline);
|
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 = {
|
this.kpi = {
|
||||||
newToday,
|
newToday,
|
||||||
@@ -230,7 +230,7 @@
|
|||||||
|
|
||||||
calcAvgResponse(all) {
|
calcAvgResponse(all) {
|
||||||
// Rough approximation — in real system this would come from timeline analysis
|
// 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 '—';
|
if (!open.length) return '—';
|
||||||
const avgHrs = open.reduce((sum, t) => {
|
const avgHrs = open.reduce((sum, t) => {
|
||||||
const diff = (new Date() - new Date(t.created_at)) / (1000 * 60 * 60);
|
const diff = (new Date() - new Date(t.created_at)) / (1000 * 60 * 60);
|
||||||
|
|||||||
@@ -222,7 +222,7 @@
|
|||||||
const all = data.items;
|
const all = data.items;
|
||||||
|
|
||||||
// Active: not closed/completed
|
// 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;
|
this.activeTickets = active;
|
||||||
|
|
||||||
// KPIs
|
// KPIs
|
||||||
|
|||||||
@@ -121,6 +121,10 @@
|
|||||||
<dt class="text-sm text-gray-500">Reporter</dt>
|
<dt class="text-sm text-gray-500">Reporter</dt>
|
||||||
<dd class="text-sm font-medium text-gray-900" x-text="ticket.reporter || '—'"></dd>
|
<dd class="text-sm font-medium text-gray-900" x-text="ticket.reporter || '—'"></dd>
|
||||||
</div>
|
</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">
|
<div class="flex justify-between">
|
||||||
<dt class="text-sm text-gray-500">Reported Via</dt>
|
<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>
|
<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="Wahab Review">Wahab Review</option>
|
||||||
<option value="Closed">Closed</option>
|
<option value="Closed">Closed</option>
|
||||||
<option value="Reopened">Reopened</option>
|
<option value="Reopened">Reopened</option>
|
||||||
|
<option value="Cancelled">Cancelled</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@@ -328,17 +333,14 @@
|
|||||||
},
|
},
|
||||||
|
|
||||||
async loadTechnicians() {
|
async loadTechnicians() {
|
||||||
// Users list not exposed via API directly, so use a known list
|
try {
|
||||||
// In a real system we'd have GET /api/users
|
const users = await app().apiGet('/api/auth/users');
|
||||||
this.technicians = [
|
// Assign dropdown should only offer Tech-role staff
|
||||||
{ id: 9, full_name: 'Prosper' },
|
this.technicians = (users || []).filter(u => u.role === 'Tech');
|
||||||
{ id: 10, full_name: 'Sam' },
|
} catch (e) {
|
||||||
{ id: 11, full_name: 'Steven' },
|
console.error('Technicians load error', e);
|
||||||
{ id: 12, full_name: 'Junior (Samuel)' },
|
this.technicians = [];
|
||||||
{ id: 13, full_name: 'Francis' },
|
}
|
||||||
{ id: 14, full_name: 'Desmond Afful' },
|
|
||||||
{ id: 15, full_name: 'Afful' },
|
|
||||||
];
|
|
||||||
},
|
},
|
||||||
|
|
||||||
async submitStatusUpdate() {
|
async submitStatusUpdate() {
|
||||||
|
|||||||
@@ -38,6 +38,7 @@
|
|||||||
<option value="Wahab Review">Wahab Review</option>
|
<option value="Wahab Review">Wahab Review</option>
|
||||||
<option value="Closed">Closed</option>
|
<option value="Closed">Closed</option>
|
||||||
<option value="Reopened">Reopened</option>
|
<option value="Reopened">Reopened</option>
|
||||||
|
<option value="Cancelled">Cancelled</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<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="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="formatDate(ticket.created_at)"></td>
|
||||||
<td class="px-5 py-3">
|
<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 x-text="formatDateShort(ticket.sla_deadline)"></span>
|
||||||
</span>
|
</span>
|
||||||
<span x-show="!ticket.sla_deadline" class="text-xs text-gray-300">—</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="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="formatDate(ticket.created_at)"></td>
|
||||||
<td class="px-5 py-3">
|
<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 x-text="formatDateShort(ticket.sla_deadline)"></span>
|
||||||
</span>
|
</span>
|
||||||
<span x-show="!ticket.sla_deadline" class="text-xs text-gray-300">—</span>
|
<span x-show="!ticket.sla_deadline" class="text-xs text-gray-300">—</span>
|
||||||
|
|||||||
@@ -363,6 +363,16 @@
|
|||||||
this.submitting = false;
|
this.submitting = false;
|
||||||
return;
|
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
|
// Create the ticket
|
||||||
const payload = {
|
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