Compare commits
15
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2e995ee758 | ||
|
|
d4ef96f17c | ||
|
|
d074f347dc | ||
|
|
47be148240 | ||
|
|
f84021bc14 | ||
|
|
cf55576d10 | ||
|
|
4afdc36765 | ||
|
|
c9f5ac4380 | ||
|
|
3920cf14a1 | ||
|
|
01e28bbcc7 | ||
|
|
ef2297ea87 | ||
|
|
a9c17d0703 | ||
|
|
5560496653 | ||
|
|
7fab1ede51 | ||
|
|
3dc383e62d |
@@ -6,6 +6,7 @@ __pycache__/
|
||||
.venv/
|
||||
uploads/
|
||||
test_*.py
|
||||
!tests/test_*.py
|
||||
venv/
|
||||
*.egg-info/
|
||||
dist/
|
||||
|
||||
@@ -22,6 +22,7 @@ app/
|
||||
├── services/ # Business logic (auth, seed, ticket, sla)
|
||||
└── routers/ # FastAPI route handlers
|
||||
alembic/ # Database migrations
|
||||
tests/ # pytest suite; conftest.py swaps DATABASE_URL to a temp SQLite
|
||||
uploads/ # Photo uploads (created at runtime)
|
||||
```
|
||||
|
||||
@@ -29,6 +30,7 @@ uploads/ # Photo uploads (created at runtime)
|
||||
|
||||
- `alembic upgrade head` — apply migrations
|
||||
- `alembic revision --autogenerate -m "msg"` — new migration
|
||||
- `pytest` — run the API test suite (tests/; pagination contract anchored in tests/test_tickets_pagination.py)
|
||||
|
||||
## Seed data
|
||||
|
||||
|
||||
@@ -31,6 +31,12 @@ class Settings(BaseSettings):
|
||||
# ── CORS ─────────────────────────────────────────────────────────
|
||||
CORS_ORIGINS: str = "*"
|
||||
|
||||
# ── WhatsApp ─────────────────────────────────────────────────────
|
||||
WHATSAPP_PHONE_NUMBER_ID: str = ""
|
||||
WHATSAPP_ACCESS_TOKEN: str = ""
|
||||
WHATSAPP_VERIFY_TOKEN: str = ""
|
||||
META_GRAPH_BASE: str = "https://graph.facebook.com/v18.0"
|
||||
|
||||
# ── Paths ────────────────────────────────────────────────────────
|
||||
BASE_DIR: Path = Path(__file__).resolve().parent.parent.parent
|
||||
|
||||
|
||||
@@ -70,6 +70,10 @@ class Ticket(Base):
|
||||
photos = relationship("TicketPhoto", back_populates="ticket")
|
||||
escalations = relationship("Escalation", back_populates="ticket")
|
||||
|
||||
@property
|
||||
def assigned_technician_name(self) -> str | None:
|
||||
return self.assigned_technician.full_name if self.assigned_technician else None
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<Ticket {self.ticket_number} ({self.status})>"
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""WhatsApp log model for mock endpoint."""
|
||||
"""WhatsApp log model for inbound webhook messages."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -14,10 +14,12 @@ class WhatsAppLog(Base):
|
||||
__tablename__ = "whatsapp_log"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||
command: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
from_number: Mapped[str | None] = mapped_column(String(50), nullable=True)
|
||||
from_number: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||
message_text: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
wa_message_id: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||
ticket_id: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
ticket_number: Mapped[str | None] = mapped_column(String(30), nullable=True)
|
||||
received_at: Mapped[datetime] = mapped_column(DateTime, nullable=False)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<WhatsAppLog {self.id}: {self.command[:50]}>"
|
||||
return f"<WhatsAppLog {self.id}: from={self.from_number} ticket={self.ticket_number}>"
|
||||
|
||||
@@ -2,8 +2,6 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, Request
|
||||
|
||||
@@ -16,6 +16,7 @@ from app.core.database import get_db
|
||||
from app.core.security import get_current_user
|
||||
from app.models.category import Category
|
||||
from app.models.ticket import Ticket, TicketPhoto
|
||||
from app.models.unit import Unit
|
||||
from app.models.user import User
|
||||
from app.schemas.ticket import (
|
||||
CategoryOut,
|
||||
@@ -27,6 +28,7 @@ from app.schemas.ticket import (
|
||||
TicketOut,
|
||||
TicketPhotoOut,
|
||||
TicketUpdate,
|
||||
UnitOut,
|
||||
)
|
||||
from app.services import ticket as ticket_service
|
||||
from app.services.sla import get_sla_status
|
||||
@@ -38,6 +40,20 @@ UPLOADS_DIR = settings.BASE_DIR / "uploads"
|
||||
UPLOADS_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
# ── Units ──────────────────────────────────────────────────────────
|
||||
@router.get("/units", response_model=list[UnitOut])
|
||||
async def list_units(
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
property_filter: str | None = Query(None, alias="property"),
|
||||
) -> list[Unit]:
|
||||
"""List all units, optionally filtered by property (East/West)."""
|
||||
query = select(Unit).order_by(Unit.apartment_code)
|
||||
if property_filter:
|
||||
query = query.where(Unit.property == property_filter)
|
||||
result = await db.execute(query)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
# ── Categories ──────────────────────────────────────────────────────
|
||||
async def _build_category_tree(db: AsyncSession, parent_id: int | None = None) -> list[CategoryTreeOut]:
|
||||
"""Build a nested category tree."""
|
||||
|
||||
+139
-18
@@ -1,44 +1,165 @@
|
||||
"""Mock WhatsApp endpoint for testing command parsing."""
|
||||
"""WhatsApp webhook handler — Meta Graph API integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
import httpx
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
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.models.whatsapp_log import WhatsAppLog
|
||||
from app.schemas.whatsapp import MockWhatsAppLogEntry, MockWhatsAppRequest, MockWhatsAppResponse
|
||||
from app.schemas.whatsapp import (
|
||||
MetaWebhookRequest,
|
||||
MockWhatsAppLogEntry,
|
||||
WebhookVerificationResponse,
|
||||
WhatsAppReplyResponse,
|
||||
)
|
||||
from app.services.ticket import create_ticket
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/whatsapp", tags=["whatsapp"])
|
||||
|
||||
REPLY_TEMPLATE = (
|
||||
"Thank you for contacting Denya OneCare. "
|
||||
"Your ticket number is {ticket_number}. "
|
||||
"We will get back to you soon."
|
||||
)
|
||||
|
||||
@router.post("/mock", response_model=MockWhatsAppResponse)
|
||||
async def mock_whatsapp(
|
||||
body: MockWhatsAppRequest,
|
||||
|
||||
# ── Meta Graph API helpers ──────────────────────────────────────────
|
||||
async def send_whatsapp_reply(
|
||||
to_phone: str,
|
||||
text: str,
|
||||
) -> WhatsAppReplyResponse:
|
||||
"""Send a text message via Meta Graph API."""
|
||||
url = f"{settings.META_GRAPH_BASE}/{settings.WHATSAPP_PHONE_NUMBER_ID}/messages"
|
||||
headers = {
|
||||
"Authorization": f"Bearer {settings.WHATSAPP_ACCESS_TOKEN}",
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
}
|
||||
data = {
|
||||
"messaging_product": "whatsapp",
|
||||
"to": to_phone,
|
||||
"type": "text",
|
||||
"text": {"body": text},
|
||||
}
|
||||
# Encode nested dict as JSON string for form data (Meta requirement)
|
||||
data["text"] = '{"body":' + f'"{text}"' + "}"
|
||||
try:
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.post(url, headers=headers, data=data, timeout=15.0)
|
||||
response.raise_for_status()
|
||||
return WhatsAppReplyResponse(success=True, message="Reply sent")
|
||||
except httpx.HTTPError as exc:
|
||||
logger.error("Failed to send WhatsApp reply: %s", exc)
|
||||
return WhatsAppReplyResponse(success=False, message=str(exc))
|
||||
|
||||
|
||||
# ── Webhook endpoint ────────────────────────────────────────────────
|
||||
@router.post("/webhook")
|
||||
async def whatsapp_webhook(
|
||||
body: MetaWebhookRequest,
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
) -> MockWhatsAppResponse:
|
||||
"""Accept a mock WhatsApp command and log it."""
|
||||
log = WhatsAppLog(
|
||||
command=body.command,
|
||||
from_number=body.from_number,
|
||||
ticket_id=body.ticket_id,
|
||||
received_at=datetime.now(timezone.utc),
|
||||
)
|
||||
db.add(log)
|
||||
await db.flush()
|
||||
return MockWhatsAppResponse()
|
||||
hub_verify_token: str | None = Query(None, alias="hub.verify_token"),
|
||||
mode: str | None = Query(None),
|
||||
hub_challenge: str | None = Query(None),
|
||||
) -> dict:
|
||||
"""Handle incoming WhatsApp webhook from Meta."""
|
||||
|
||||
# ── Verification GET request (Meta sends this on webhook setup) ──
|
||||
if mode and hub_challenge:
|
||||
if hub_verify_token != settings.WHATSAPP_VERIFY_TOKEN:
|
||||
return {"error": "Verify token mismatch"}
|
||||
return WebhookVerificationResponse(challenge=hub_challenge).model_dump()
|
||||
|
||||
# ── Process inbound messages ────────────────────────────────────
|
||||
if not body.entry:
|
||||
return {"status": "no entry"}
|
||||
|
||||
for entry in body.entry:
|
||||
if not entry.changes:
|
||||
continue
|
||||
|
||||
for change in entry.changes:
|
||||
if not change.message or not change.message.text:
|
||||
logger.info("Non-text message received, skipping")
|
||||
continue
|
||||
|
||||
message_text = change.message.text.text
|
||||
sender = change.message.from_field
|
||||
wa_msg_id = change.message.id or change.id
|
||||
|
||||
# ── Create ticket ──────────────────────────────────────
|
||||
try:
|
||||
ticket = await create_ticket(
|
||||
db,
|
||||
data={
|
||||
"description": message_text,
|
||||
"reporter": sender,
|
||||
"reported_via": "WhatsApp",
|
||||
},
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.error("Failed to create ticket: %s", exc)
|
||||
# Still log the message even if ticket creation fails
|
||||
log = WhatsAppLog(
|
||||
from_number=sender,
|
||||
message_text=message_text,
|
||||
wa_message_id=wa_msg_id,
|
||||
ticket_id=None,
|
||||
ticket_number=None,
|
||||
received_at=datetime.now(timezone.utc),
|
||||
)
|
||||
db.add(log)
|
||||
await db.flush()
|
||||
return {"status": "ticket creation failed, message logged"}
|
||||
|
||||
# ── Store WhatsApp log ─────────────────────────────────
|
||||
log = WhatsAppLog(
|
||||
from_number=sender,
|
||||
message_text=message_text,
|
||||
wa_message_id=wa_msg_id,
|
||||
ticket_id=ticket.id,
|
||||
ticket_number=ticket.ticket_number,
|
||||
received_at=datetime.now(timezone.utc),
|
||||
)
|
||||
db.add(log)
|
||||
await db.flush()
|
||||
|
||||
# ── Send auto-reply ────────────────────────────────────
|
||||
reply_text = REPLY_TEMPLATE.format(ticket_number=ticket.ticket_number)
|
||||
reply_result = await send_whatsapp_reply(sender, reply_text)
|
||||
if not reply_result.success:
|
||||
logger.warning(
|
||||
"Auto-reply failed for ticket %s: %s",
|
||||
ticket.ticket_number,
|
||||
reply_result.message,
|
||||
)
|
||||
|
||||
return {
|
||||
"status": "processed",
|
||||
"ticket_id": ticket.id,
|
||||
"ticket_number": ticket.ticket_number,
|
||||
"reply_sent": reply_result.success,
|
||||
}
|
||||
|
||||
return {"status": "no messages"}
|
||||
|
||||
|
||||
# ── Legacy debug endpoint ──────────────────────────────────────────
|
||||
@router.get("/mock-log", response_model=list[MockWhatsAppLogEntry])
|
||||
async def mock_whatsapp_log(
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
limit: int = 50,
|
||||
) -> list[MockWhatsAppLogEntry]:
|
||||
"""Return recent mock WhatsApp submissions."""
|
||||
"""Return recent WhatsApp webhook submissions for debugging."""
|
||||
result = await db.execute(
|
||||
select(WhatsAppLog).order_by(WhatsAppLog.received_at.desc()).limit(limit)
|
||||
)
|
||||
|
||||
@@ -8,6 +8,17 @@ from decimal import Decimal
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
# ── Unit ─────────────────────────────────────────────────────────────
|
||||
class UnitOut(BaseModel):
|
||||
id: int
|
||||
property: str
|
||||
apartment_code: str
|
||||
building: str | None = None
|
||||
floor: int | None = None
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
# ── Category ─────────────────────────────────────────────────────────
|
||||
class CategoryOut(BaseModel):
|
||||
id: int
|
||||
@@ -32,6 +43,8 @@ class TicketCreate(BaseModel):
|
||||
reported_via: str | None = None # whatsapp, phone, walk-in, qr, agent
|
||||
description: str | None = None
|
||||
assigned_to: int | None = None
|
||||
customer_name: str | None = None
|
||||
phone: str | None = None
|
||||
|
||||
|
||||
class TicketUpdate(BaseModel):
|
||||
@@ -46,6 +59,7 @@ class TicketUpdate(BaseModel):
|
||||
eta: datetime | None = None
|
||||
cost: Decimal | None = None
|
||||
parts_used: str | None = None
|
||||
note: str | None = None
|
||||
|
||||
|
||||
class TicketTimelineOut(BaseModel):
|
||||
@@ -78,6 +92,7 @@ class TicketBrief(BaseModel):
|
||||
unit_id: int | None = None
|
||||
category_id: int | None = None
|
||||
assigned_to: int | None = None
|
||||
assigned_technician_name: str | None = None
|
||||
reporter: str | None = None
|
||||
description: str | None = None
|
||||
sla_deadline: datetime | None = None
|
||||
|
||||
+61
-12
@@ -1,10 +1,69 @@
|
||||
"""Pydantic schemas for mock WhatsApp endpoint."""
|
||||
"""Pydantic schemas for Meta WhatsApp webhook."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
# ── Meta Webhook Verification ────────────────────────────────────────
|
||||
class WebhookVerificationResponse(BaseModel):
|
||||
challenge: str
|
||||
|
||||
|
||||
# ── Inbound Message Parsing ─────────────────────────────────────────
|
||||
class MessageText(BaseModel):
|
||||
text: str
|
||||
|
||||
|
||||
class Message(BaseModel):
|
||||
from_field: str = Field(alias="from")
|
||||
id: str | None = None
|
||||
text: MessageText | None = None
|
||||
type: str | None = None
|
||||
|
||||
|
||||
class Contact(BaseModel):
|
||||
wa_id: str | None = None
|
||||
profile: dict | None = None
|
||||
|
||||
|
||||
class EntryMessagesItem(BaseModel):
|
||||
id: str | None = None
|
||||
message: Message | None = None
|
||||
contacts: list[Contact] | None = None
|
||||
timestamp: str | None = None
|
||||
|
||||
|
||||
class Entry(BaseModel):
|
||||
id: str | None = None
|
||||
changes: list[EntryMessagesItem] | None = None
|
||||
metadata: dict | None = None
|
||||
|
||||
|
||||
class MetaWebhookRequest(BaseModel):
|
||||
object: str | None = None
|
||||
entry: list[Entry] | None = None
|
||||
|
||||
|
||||
# ── Auto-reply ──────────────────────────────────────────────────────
|
||||
class WhatsAppReplyResponse(BaseModel):
|
||||
success: bool
|
||||
message: str
|
||||
|
||||
|
||||
# ── Legacy mock schemas (kept for /mock-log endpoint) ───────────────
|
||||
class MockWhatsAppLogEntry(BaseModel):
|
||||
id: int
|
||||
from_number: str
|
||||
message_text: str
|
||||
wa_message_id: str | None
|
||||
ticket_id: int | None
|
||||
ticket_number: str | None
|
||||
received_at: datetime
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class MockWhatsAppRequest(BaseModel):
|
||||
@@ -16,13 +75,3 @@ class MockWhatsAppRequest(BaseModel):
|
||||
class MockWhatsAppResponse(BaseModel):
|
||||
status: str = "received"
|
||||
message: str = "Command logged successfully"
|
||||
|
||||
|
||||
class MockWhatsAppLogEntry(BaseModel):
|
||||
id: int
|
||||
command: str
|
||||
from_number: str | None
|
||||
ticket_id: int | None
|
||||
received_at: datetime
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
+15
-3
@@ -109,7 +109,7 @@ async def create_ticket(
|
||||
unit_id=data.get("unit_id"),
|
||||
category_id=data.get("category_id"),
|
||||
priority=priority,
|
||||
reporter=data.get("reporter"),
|
||||
reporter=data.get("reporter") or data.get("customer_name"),
|
||||
reported_via=data.get("reported_via"),
|
||||
description=data.get("description"),
|
||||
assigned_to=data.get("assigned_to"),
|
||||
@@ -196,7 +196,7 @@ async def list_tickets(
|
||||
|
||||
# Paginate
|
||||
offset = (page - 1) * page_size
|
||||
query = query.order_by(Ticket.created_at.desc()).offset(offset).limit(page_size)
|
||||
query = query.order_by(Ticket.created_at.desc(), Ticket.id.desc()).offset(offset).limit(page_size).options(selectinload(Ticket.assigned_technician))
|
||||
|
||||
result = await db.execute(query)
|
||||
tickets = list(result.scalars().all())
|
||||
@@ -214,8 +214,8 @@ async def update_ticket(
|
||||
|
||||
# Handle status transitions separately
|
||||
new_status = data.get("status")
|
||||
old_status = ticket.status
|
||||
if new_status is not None:
|
||||
old_status = ticket.status
|
||||
if old_status != new_status:
|
||||
valid_targets = VALID_TRANSITIONS.get(old_status, [])
|
||||
if new_status not in valid_targets:
|
||||
@@ -275,6 +275,18 @@ async def update_ticket(
|
||||
|
||||
ticket.status = new_status
|
||||
|
||||
# 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):
|
||||
await _log_status_change(
|
||||
db,
|
||||
ticket.id,
|
||||
from_status=ticket.status,
|
||||
to_status=ticket.status,
|
||||
note=note_only,
|
||||
user_id=user.id if user else None,
|
||||
)
|
||||
|
||||
# Update other fields
|
||||
for field in ("unit_id", "category_id", "priority", "reporter", "reported_via",
|
||||
"description", "assigned_to", "eta", "cost", "parts_used"):
|
||||
|
||||
+55
-48
@@ -12,17 +12,23 @@
|
||||
extend: {
|
||||
colors: {
|
||||
denya: {
|
||||
50: '#eff6ff',
|
||||
100: '#dbeafe',
|
||||
200: '#bfdbfe',
|
||||
300: '#93c5fd',
|
||||
400: '#60a5fa',
|
||||
500: '#3b82f6',
|
||||
600: '#2563eb',
|
||||
700: '#1d4ed8',
|
||||
800: '#1e40af',
|
||||
900: '#1e3a8a',
|
||||
}
|
||||
50: '#e8f0ea',
|
||||
100: '#c5d9cb',
|
||||
200: '#9ebfaa',
|
||||
300: '#74a589',
|
||||
400: '#4d8c69',
|
||||
500: '#2d734d',
|
||||
600: '#1d5a3a',
|
||||
700: '#0d2b18',
|
||||
800: '#0a2012',
|
||||
900: '#07150c',
|
||||
},
|
||||
gold: {
|
||||
DEFAULT: '#c8a96e',
|
||||
light: '#e8d5a8',
|
||||
dark: '#a88a4e',
|
||||
},
|
||||
cream: '#faf8f5',
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -49,49 +55,54 @@
|
||||
.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-low { @apply bg-green-100 text-green-800 border-green-300; }
|
||||
.brand-gradient { background: linear-gradient(135deg, #0d2b18 0%, #1a3d24 100%); }
|
||||
</style>
|
||||
</head>
|
||||
<body class="bg-gray-50 min-h-screen" x-data="app()" x-init="init()">
|
||||
<body class="bg-cream min-h-screen text-[#1a1a1a]" x-data="app()" x-init="init()">
|
||||
<!-- Nav Bar -->
|
||||
<nav class="bg-white border-b border-gray-200 shadow-sm sticky top-0 z-50" x-show="isLoggedIn" x-cloak>
|
||||
<nav class="bg-[#0d2b18] border-b border-denya-800 shadow-lg sticky top-0 z-50" x-show="isLoggedIn" x-cloak>
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="flex items-center justify-between h-16">
|
||||
<!-- Left side -->
|
||||
<div class="flex items-center space-x-4">
|
||||
<a href="/dashboard/cs" class="flex items-center space-x-2 text-denya-700 font-bold text-lg">
|
||||
<svg class="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4"/>
|
||||
</svg>
|
||||
<span>Denya OneCare</span>
|
||||
<a href="/dashboard/cs" class="flex items-center space-x-3">
|
||||
<!-- Denya Developers Logo Mark -->
|
||||
<div class="w-9 h-9 bg-gold rounded-lg flex items-center justify-center shadow-sm">
|
||||
<svg class="w-5 h-5 text-[#0d2b18]" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="flex flex-col">
|
||||
<span class="text-white font-bold text-base leading-tight">Denya Developers</span>
|
||||
<span class="text-gold text-xs leading-tight font-medium">OneCare</span>
|
||||
</div>
|
||||
</a>
|
||||
<!-- CS Links -->
|
||||
<!-- Nav Links -->
|
||||
<template x-if="isCS">
|
||||
<div class="hidden md:flex space-x-1 ml-6">
|
||||
<a href="/dashboard/cs" class="px-3 py-2 rounded-md text-sm font-medium" :class="currentPath === '/dashboard/cs' ? 'bg-denya-50 text-denya-700' : 'text-gray-600 hover:text-gray-900 hover:bg-gray-50'">Dashboard</a>
|
||||
<a href="/tickets" class="px-3 py-2 rounded-md text-sm font-medium" :class="currentPath.startsWith('/tickets') ? 'bg-denya-50 text-denya-700' : 'text-gray-600 hover:text-gray-900 hover:bg-gray-50'">All Issues</a>
|
||||
<a href="/tickets/new" class="px-3 py-2 rounded-md text-sm font-medium text-gray-600 hover:text-gray-900 hover:bg-gray-50">Create Issue</a>
|
||||
<a href="/dashboard/cs" class="px-3 py-2 rounded-md text-sm font-medium transition-colors" :class="currentPath === '/dashboard/cs' ? 'bg-denya-800 text-gold' : 'text-gray-300 hover:text-white hover:bg-denya-800/50'">Dashboard</a>
|
||||
<a href="/tickets" class="px-3 py-2 rounded-md text-sm font-medium transition-colors" :class="currentPath.startsWith('/tickets') && !currentPath.endsWith('/new') ? 'bg-denya-800 text-gold' : 'text-gray-300 hover:text-white hover:bg-denya-800/50'">All Issues</a>
|
||||
<a href="/tickets/new" class="px-3 py-2 rounded-md text-sm font-medium text-gray-300 hover:text-white hover:bg-denya-800/50 transition-colors">Create Issue</a>
|
||||
</div>
|
||||
</template>
|
||||
<!-- FM Links -->
|
||||
<template x-if="isFM">
|
||||
<div class="hidden md:flex space-x-1 ml-6">
|
||||
<a href="/dashboard/fm" class="px-3 py-2 rounded-md text-sm font-medium" :class="currentPath === '/dashboard/fm' ? 'bg-denya-50 text-denya-700' : 'text-gray-600 hover:text-gray-900 hover:bg-gray-50'">Dashboard</a>
|
||||
<a href="/tickets" class="px-3 py-2 rounded-md text-sm font-medium" :class="currentPath.startsWith('/tickets') ? 'bg-denya-50 text-denya-700' : 'text-gray-600 hover:text-gray-900 hover:bg-gray-50'">All Issues</a>
|
||||
<a href="/tickets/new" class="px-3 py-2 rounded-md text-sm font-medium text-gray-600 hover:text-gray-900 hover:bg-gray-50">Create Issue</a>
|
||||
<a href="/dashboard/fm" class="px-3 py-2 rounded-md text-sm font-medium transition-colors" :class="currentPath === '/dashboard/fm' ? 'bg-denya-800 text-gold' : 'text-gray-300 hover:text-white hover:bg-denya-800/50'">Dashboard</a>
|
||||
<a href="/tickets" class="px-3 py-2 rounded-md text-sm font-medium transition-colors" :class="currentPath.startsWith('/tickets') && !currentPath.endsWith('/new') ? 'bg-denya-800 text-gold' : 'text-gray-300 hover:text-white hover:bg-denya-800/50'">All Issues</a>
|
||||
<a href="/tickets/new" class="px-3 py-2 rounded-md text-sm font-medium text-gray-300 hover:text-white hover:bg-denya-800/50 transition-colors">Create Issue</a>
|
||||
</div>
|
||||
</template>
|
||||
<!-- CEO/Director Links -->
|
||||
<template x-if="isExecutive">
|
||||
<div class="hidden md:flex space-x-1 ml-6">
|
||||
<a href="/dashboard/ceo" class="px-3 py-2 rounded-md text-sm font-medium" :class="currentPath === '/dashboard/ceo' ? 'bg-denya-50 text-denya-700' : 'text-gray-600 hover:text-gray-900 hover:bg-gray-50'">Dashboard</a>
|
||||
<a href="/tickets" class="px-3 py-2 rounded-md text-sm font-medium" :class="currentPath.startsWith('/tickets') ? 'bg-denya-50 text-denya-700' : 'text-gray-600 hover:text-gray-900 hover:bg-gray-50'">Issues</a>
|
||||
<a href="/dashboard/ceo" class="px-3 py-2 rounded-md text-sm font-medium transition-colors" :class="currentPath === '/dashboard/ceo' ? 'bg-denya-800 text-gold' : 'text-gray-300 hover:text-white hover:bg-denya-800/50'">Dashboard</a>
|
||||
<a href="/tickets" class="px-3 py-2 rounded-md text-sm font-medium transition-colors" :class="currentPath.startsWith('/tickets') ? 'bg-denya-800 text-gold' : 'text-gray-300 hover:text-white hover:bg-denya-800/50'">Issues</a>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<!-- Right side -->
|
||||
<div class="flex items-center space-x-4">
|
||||
<span class="text-sm text-gray-600 hidden md:block" x-text="`${user.full_name} (${user.role})`"></span>
|
||||
<button @click="logout()" class="px-3 py-1.5 text-sm text-red-600 hover:text-red-800 hover:bg-red-50 rounded-md transition-colors">
|
||||
<span class="text-sm text-gray-300 hidden md:block" x-text="`${user.full_name} (${user.role})`"></span>
|
||||
<button @click="logout()" class="px-3 py-1.5 text-sm text-gold hover:text-gold-light hover:bg-denya-800/50 rounded-md transition-colors border border-denya-600">
|
||||
Logout
|
||||
</button>
|
||||
</div>
|
||||
@@ -99,19 +110,19 @@
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- Mobile Nav (CS) -->
|
||||
<div class="md:hidden border-b bg-white" x-show="isLoggedIn" x-cloak>
|
||||
<!-- Mobile Nav -->
|
||||
<div class="md:hidden border-b bg-[#0d2b18] border-denya-800" x-show="isLoggedIn" x-cloak>
|
||||
<template x-if="isCS || isFM">
|
||||
<div class="flex overflow-x-auto px-4 py-2 space-x-2">
|
||||
<a href="/dashboard/cs" class="px-3 py-1.5 rounded text-sm font-medium whitespace-nowrap" :class="currentPath === '/dashboard/cs' ? 'bg-denya-50 text-denya-700' : 'text-gray-500'" x-text="isCS ? 'CS Dashboard' : 'FM Dashboard'"></a>
|
||||
<a href="/tickets" class="px-3 py-1.5 rounded text-sm font-medium whitespace-nowrap" :class="currentPath.startsWith('/tickets') && !currentPath.endsWith('/new') ? 'bg-denya-50 text-denya-700' : 'text-gray-500'">Issues</a>
|
||||
<a href="/tickets/new" class="px-3 py-1.5 rounded text-sm font-medium whitespace-nowrap text-gray-500">New Issue</a>
|
||||
<a href="/dashboard/cs" class="px-3 py-1.5 rounded text-sm font-medium whitespace-nowrap" :class="currentPath === '/dashboard/cs' ? 'bg-denya-800 text-gold' : 'text-gray-300'">Dashboard</a>
|
||||
<a href="/tickets" class="px-3 py-1.5 rounded text-sm font-medium whitespace-nowrap" :class="currentPath.startsWith('/tickets') && !currentPath.endsWith('/new') ? 'bg-denya-800 text-gold' : 'text-gray-300'">Issues</a>
|
||||
<a href="/tickets/new" class="px-3 py-1.5 rounded text-sm font-medium whitespace-nowrap text-gray-300">New Issue</a>
|
||||
</div>
|
||||
</template>
|
||||
<template x-if="isExecutive">
|
||||
<div class="flex overflow-x-auto px-4 py-2 space-x-2">
|
||||
<a href="/dashboard/ceo" class="px-3 py-1.5 rounded text-sm font-medium whitespace-nowrap" :class="currentPath === '/dashboard/ceo' ? 'bg-denya-50 text-denya-700' : 'text-gray-500'">CEO Dashboard</a>
|
||||
<a href="/tickets" class="px-3 py-1.5 rounded text-sm font-medium whitespace-nowrap text-gray-500">Issues</a>
|
||||
<a href="/dashboard/ceo" class="px-3 py-1.5 rounded text-sm font-medium whitespace-nowrap" :class="currentPath === '/dashboard/ceo' ? 'bg-denya-800 text-gold' : 'text-gray-300'">CEO Dashboard</a>
|
||||
<a href="/tickets" class="px-3 py-1.5 rounded text-sm font-medium whitespace-nowrap text-gray-300">Issues</a>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
@@ -122,13 +133,13 @@
|
||||
</main>
|
||||
|
||||
<!-- Loading Overlay -->
|
||||
<div x-show="loading" class="fixed inset-0 bg-black bg-opacity-30 z-50 flex items-center justify-center" x-cloak>
|
||||
<div class="bg-white rounded-lg p-6 flex items-center space-x-3 shadow-xl">
|
||||
<svg class="animate-spin h-6 w-6 text-denya-600" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<div x-show="loading" class="fixed inset-0 bg-[#0d2b18]/40 z-50 flex items-center justify-center" x-cloak>
|
||||
<div class="bg-white rounded-xl p-6 flex items-center space-x-3 shadow-2xl border border-denya-200">
|
||||
<svg class="animate-spin h-6 w-6 text-denya-700" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
|
||||
</svg>
|
||||
<span class="text-gray-700 font-medium" x-text="loadingMessage || 'Loading...'"></span>
|
||||
<span class="text-denya-800 font-medium" x-text="loadingMessage || 'Loading...'"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -136,7 +147,7 @@
|
||||
<div class="fixed bottom-4 right-4 z-50 space-y-2">
|
||||
<template x-for="toast in toasts" :key="toast.id">
|
||||
<div class="px-4 py-3 rounded-lg shadow-lg text-white text-sm font-medium transition-all duration-300"
|
||||
:class="{'bg-green-600': toast.type === 'success', 'bg-red-600': toast.type === 'error', 'bg-blue-600': toast.type === 'info', 'bg-yellow-600': toast.type === 'warning'}"
|
||||
:class="{'bg-denya-600': toast.type === 'success', 'bg-red-600': toast.type === 'error', 'bg-denya-500': toast.type === 'info', 'bg-gold': toast.type === 'warning'}"
|
||||
x-init="setTimeout(() => { toasts = toasts.filter(t => t.id !== toast.id) }, toast.duration || 4000)">
|
||||
<span x-text="toast.message"></span>
|
||||
</div>
|
||||
@@ -164,12 +175,10 @@
|
||||
get isAdmin() { return ['Admin/Jerome', 'Admin/Wahab'].includes(this.user.role) },
|
||||
|
||||
init() {
|
||||
// Redirect to login if not logged in (skip for login page)
|
||||
if (!this.isLoggedIn && this.currentPath !== '/login') {
|
||||
window.location.href = '/login';
|
||||
return;
|
||||
}
|
||||
// Verify token on load
|
||||
if (this.isLoggedIn) {
|
||||
this.fetchMe();
|
||||
}
|
||||
@@ -209,7 +218,7 @@
|
||||
opts.body = JSON.stringify(body);
|
||||
} else if (body instanceof FormData) {
|
||||
opts.body = body;
|
||||
delete opts.headers['Content-Type']; // Let browser set multipart boundary
|
||||
delete opts.headers['Content-Type'];
|
||||
opts.headers = { 'Authorization': `Bearer ${this.token}` };
|
||||
}
|
||||
const res = await fetch(url, opts);
|
||||
@@ -253,13 +262,11 @@
|
||||
localStorage.setItem('refresh_token', data.refresh_token);
|
||||
this.token = data.access_token;
|
||||
|
||||
// Fetch user info
|
||||
const me = await this.apiGet('/api/auth/me');
|
||||
this.user = me;
|
||||
localStorage.setItem('user', JSON.stringify(me));
|
||||
this.isLoggedIn = true;
|
||||
|
||||
// Role-based redirect
|
||||
const role = me.role;
|
||||
if (['CS Rep', 'CS Manager'].includes(role)) {
|
||||
window.location.href = '/dashboard/cs';
|
||||
|
||||
@@ -171,10 +171,22 @@
|
||||
|
||||
async loadData() {
|
||||
try {
|
||||
const allData = await app().apiGet('/api/tickets?page_size=500');
|
||||
if (!allData?.items) return;
|
||||
const all = allData.items;
|
||||
const total = allData.total || all.length;
|
||||
// Fetch ALL tickets via pagination. The API caps page_size at 200
|
||||
// (app/routers/tickets.py), so a single page_size=500 request returns 422
|
||||
// and the dashboard renders empty KPIs. Loop pages until we have `total`
|
||||
// tickets so KPIs stay accurate as volume grows past 200.
|
||||
const all = [];
|
||||
const pageSize = 200;
|
||||
let page = 1;
|
||||
let total = Infinity;
|
||||
while (all.length < total && page <= 1000) { // 1000-page safety bound
|
||||
const allData = await app().apiGet(`/api/tickets?page=${page}&page_size=${pageSize}`);
|
||||
if (!allData?.items || !allData.items.length) break;
|
||||
all.push(...allData.items);
|
||||
total = allData.total ?? all.length;
|
||||
page += 1;
|
||||
}
|
||||
if (!all.length) return;
|
||||
|
||||
// Basic KPIs
|
||||
const open = all.filter(t => !['Closed', 'Completed'].includes(t.status));
|
||||
@@ -218,15 +230,22 @@
|
||||
});
|
||||
this.charts.monthlyMax = Math.max(...this.charts.monthlyTrend.map(m => m.count), 1);
|
||||
|
||||
// By property
|
||||
// By property — load units once and compute from ticket data
|
||||
const propData = { east: 0, west: 0 };
|
||||
all.forEach(t => { /* would need unit join — estimate from ticket IDs */ });
|
||||
try {
|
||||
const east = await app().apiGet('/api/tickets?property=East&page_size=1');
|
||||
const west = await app().apiGet('/api/tickets?property=West&page_size=1');
|
||||
propData.east = east?.total || 0;
|
||||
propData.west = west?.total || 0;
|
||||
} catch (e) { /* api may not support property filter directly */ }
|
||||
const units = await app().apiGet('/api/tickets/units');
|
||||
if (units && units.length > 0) {
|
||||
const unitPropertyMap = {};
|
||||
units.forEach(u => { unitPropertyMap[u.id] = u.property; });
|
||||
all.forEach(t => {
|
||||
if (t.unit_id && unitPropertyMap[t.unit_id]) {
|
||||
const p = unitPropertyMap[t.unit_id].toLowerCase();
|
||||
if (p === 'east') propData.east++;
|
||||
else if (p === 'west') propData.west++;
|
||||
}
|
||||
});
|
||||
}
|
||||
} catch (e) { console.error('Property stats error', e); }
|
||||
this.charts.byProperty = { east: propData.east, west: propData.west, max: Math.max(propData.east, propData.west, 1) };
|
||||
|
||||
// By category — use categories endpoint
|
||||
|
||||
@@ -203,16 +203,13 @@
|
||||
// Emergency
|
||||
this.emergencyCount = active.filter(t => t.priority === 'urgent').length;
|
||||
|
||||
// East vs West — we need full tickets with unit info
|
||||
// For now, estimate from overall data or show placeholder
|
||||
// We'll load again with property filter or just use total
|
||||
// East vs West — compute from loaded tickets using unit map
|
||||
try {
|
||||
const east = await app().apiGet('/api/tickets?property=East&page_size=1');
|
||||
const west = await app().apiGet('/api/tickets?property=West&page_size=1');
|
||||
const eastTotal = east?.total || 0;
|
||||
const westTotal = west?.total || 0;
|
||||
this.kpi.eastJobs = eastTotal;
|
||||
this.kpi.westJobs = westTotal;
|
||||
const units = await app().apiGet('/api/tickets/units');
|
||||
const unitMap = {};
|
||||
if (units) units.forEach(u => { unitMap[u.id] = u.property; });
|
||||
this.kpi.eastJobs = active.filter(t => t.unit_id && unitMap[t.unit_id] === 'East').length;
|
||||
this.kpi.westJobs = active.filter(t => t.unit_id && unitMap[t.unit_id] === 'West').length;
|
||||
} catch (e) { console.error('Property stats error', e); }
|
||||
|
||||
// Tech workload (simulated from assigned_to counts)
|
||||
@@ -231,11 +228,11 @@
|
||||
under24h: active.filter(t => (now - new Date(t.created_at)) < 24 * 60 * 60 * 1000).length,
|
||||
oneToTwoDays: active.filter(t => {
|
||||
const diff = (now - new Date(t.created_at)) / (1000 * 60 * 60 * 24);
|
||||
return diff >= 1 && diff < 2;
|
||||
return diff >= 1 && diff < 3;
|
||||
}).length,
|
||||
threeToFiveDays: active.filter(t => {
|
||||
const diff = (now - new Date(t.created_at)) / (1000 * 60 * 60 * 24);
|
||||
return diff >= 2 && diff < 5;
|
||||
return diff >= 3 && diff < 5;
|
||||
}).length,
|
||||
overFiveDays: active.filter(t => (now - new Date(t.created_at)) / (1000 * 60 * 60 * 24) >= 5).length,
|
||||
};
|
||||
|
||||
@@ -115,7 +115,7 @@
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<dt class="text-sm text-gray-500">Assigned To</dt>
|
||||
<dd class="text-sm font-medium text-gray-900" x-text="ticket.assigned_technician?.full_name || 'Unassigned'"></dd>
|
||||
<dd class="text-sm font-medium text-gray-900" x-text="ticket.assigned_technician_name || 'Unassigned'"></dd>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<dt class="text-sm text-gray-500">Reporter</dt>
|
||||
@@ -311,7 +311,7 @@
|
||||
|
||||
async init() {
|
||||
await this.loadTicket();
|
||||
if (this.isFM || this.isAdmin) {
|
||||
if (app().isFM || app().isAdmin) {
|
||||
await this.loadTechnicians();
|
||||
}
|
||||
},
|
||||
@@ -328,20 +328,17 @@
|
||||
},
|
||||
|
||||
async loadTechnicians() {
|
||||
// Load all users and filter for Tech role
|
||||
try {
|
||||
const me = await app().apiGet('/api/auth/me');
|
||||
// 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: 8, full_name: 'Prosper' },
|
||||
{ id: 9, full_name: 'Sam' },
|
||||
{ id: 10, full_name: 'Steven' },
|
||||
{ id: 11, full_name: 'Junior (Samuel)' },
|
||||
{ id: 12, full_name: 'Francis' },
|
||||
{ id: 13, full_name: 'Desmond Afful' },
|
||||
];
|
||||
} catch (e) { console.error('Tech load error', e); }
|
||||
// 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' },
|
||||
];
|
||||
},
|
||||
|
||||
async submitStatusUpdate() {
|
||||
@@ -392,9 +389,8 @@
|
||||
if (!this.noteForm.note.trim()) return;
|
||||
this.noteSubmitting = true;
|
||||
try {
|
||||
// Use status update endpoint to add a note without changing status
|
||||
const updated = await app().apiPost(`/api/tickets/${this.ticketId}/status`, {
|
||||
status: this.ticket.status,
|
||||
// Use PATCH endpoint which now supports note-only updates
|
||||
const updated = await app().apiPatch(`/api/tickets/${this.ticketId}`, {
|
||||
note: this.noteForm.note
|
||||
});
|
||||
this.ticket = updated;
|
||||
@@ -402,7 +398,7 @@
|
||||
this.noteForm.note = '';
|
||||
app().showToast('Note added', 'success');
|
||||
} catch (e) {
|
||||
app().showToast(e.message, 'error');
|
||||
// No need to show error here — api() base method already shows it
|
||||
} finally {
|
||||
this.noteSubmitting = false;
|
||||
}
|
||||
|
||||
@@ -104,7 +104,7 @@
|
||||
<option value="walk-in">Walk-in</option>
|
||||
<option value="whatsapp">WhatsApp</option>
|
||||
<option value="agent">Agent</option>
|
||||
<option value="qr">QR Code</option>
|
||||
<!-- QR Code excluded per Sprint 3 scope -->
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
@@ -196,17 +196,12 @@
|
||||
this.form.apartment_code = '';
|
||||
this.units = [];
|
||||
if (!this.form.property) return;
|
||||
// Units aren't directly exposed via API, so we'll construct from known patterns
|
||||
const prefix = this.form.property === 'East' ? 'E' : 'W';
|
||||
const units = [];
|
||||
const buildings = ['A', 'B', 'C', 'D', 'E', 'F'];
|
||||
for (let floor = 1; floor <= 10; floor++) {
|
||||
for (const bld of buildings) {
|
||||
const code = `${floor}0${bld}${prefix}`;
|
||||
units.push({ id: code, apartment_code: code });
|
||||
}
|
||||
try {
|
||||
const data = await app().apiGet(`/api/tickets/units?property=${encodeURIComponent(this.form.property)}`);
|
||||
this.units = data || [];
|
||||
} catch (e) {
|
||||
console.error('Units load error', e);
|
||||
}
|
||||
this.units = units;
|
||||
},
|
||||
|
||||
handlePhotos(e) {
|
||||
@@ -240,13 +235,19 @@
|
||||
return;
|
||||
}
|
||||
|
||||
// Resolve unit_id from selected apartment_code
|
||||
const selectedUnit = this.units.find(u => u.apartment_code === this.form.apartment_code);
|
||||
|
||||
// Create the ticket
|
||||
const payload = {
|
||||
description: this.form.description,
|
||||
priority: this.form.priority || null,
|
||||
reporter: this.form.reporter || this.user.full_name,
|
||||
reporter: this.form.reporter || this.form.customer_name || app().user.full_name,
|
||||
reported_via: this.form.reported_via || 'walk-in',
|
||||
category_id: this.form.category_id ? parseInt(this.form.category_id) : (this.form.category_main ? parseInt(this.form.category_main) : null),
|
||||
unit_id: selectedUnit ? selectedUnit.id : null,
|
||||
customer_name: this.form.customer_name || null,
|
||||
phone: this.form.phone || null,
|
||||
};
|
||||
|
||||
const ticket = await app().apiPost('/api/tickets', payload);
|
||||
|
||||
@@ -22,8 +22,14 @@ build-backend = "setuptools.build_meta"
|
||||
[tool.setuptools.packages.find]
|
||||
include = ["app*"]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
asyncio_mode = "auto"
|
||||
testpaths = ["tests"]
|
||||
pythonpath = ["."]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"pytest>=8.0",
|
||||
"pytest-asyncio>=0.24",
|
||||
"httpx>=0.27.0",
|
||||
]
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
"""Shared fixtures for the Denya OneCare test suite.
|
||||
|
||||
Sets DATABASE_URL to an isolated temp SQLite file BEFORE importing any app
|
||||
module (the engine is created at import time), then provisions tables and
|
||||
seed data per test.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
_TMP_DIR = tempfile.mkdtemp(prefix="denya-test-")
|
||||
os.environ["DATABASE_URL"] = f"sqlite+aiosqlite:///{_TMP_DIR}/test.db"
|
||||
|
||||
import pytest_asyncio
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
|
||||
from app.core.database import Base, async_session_factory, engine
|
||||
from app.main import app
|
||||
from app.models.ticket import Ticket
|
||||
from app.services.seed import seed_categories, seed_units, seed_users
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def client():
|
||||
"""Async test client with a fresh, seeded database per test."""
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
async with async_session_factory() as session:
|
||||
await seed_users(session)
|
||||
await session.commit()
|
||||
# json_path=None → built-in fallback units (apartment_mapping.json is not committed)
|
||||
await seed_units(session, json_path=None)
|
||||
await session.commit()
|
||||
await seed_categories(session)
|
||||
await session.commit()
|
||||
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as c:
|
||||
yield c
|
||||
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.drop_all)
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def seed_tickets():
|
||||
"""Insert `n` tickets directly into the DB; returns the count inserted."""
|
||||
|
||||
async def _seed(n: int) -> int:
|
||||
async with async_session_factory() as session:
|
||||
for i in range(n):
|
||||
session.add(
|
||||
Ticket(
|
||||
ticket_number=f"PAV-TEST-{i:05d}",
|
||||
status="Logged",
|
||||
priority="medium",
|
||||
description=f"Test ticket {i}",
|
||||
)
|
||||
)
|
||||
await session.commit()
|
||||
return n
|
||||
|
||||
return _seed
|
||||
@@ -0,0 +1,56 @@
|
||||
"""Tests anchoring ticket list pagination behavior.
|
||||
|
||||
The CEO dashboard (app/templates/dashboard/ceo.html) previously requested
|
||||
`page_size=500`; the API caps page_size at 200 (`le=200` in
|
||||
app/routers/tickets.py), so that request returned 422 and the dashboard
|
||||
rendered empty KPIs. These tests pin the API contract the frontend now
|
||||
relies on: page_size=200 + page loops that collect every ticket.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
pytestmark = pytest.mark.asyncio
|
||||
|
||||
|
||||
async def test_page_size_over_cap_returns_422(client):
|
||||
"""Requests above the page_size cap must be rejected (the original bug)."""
|
||||
resp = await client.get("/api/tickets", params={"page_size": 500})
|
||||
assert resp.status_code == 422
|
||||
|
||||
|
||||
async def test_page_size_at_cap_returns_items_and_total(client, seed_tickets):
|
||||
"""page_size=200 is the max legal value and returns the full response shape."""
|
||||
await seed_tickets(14)
|
||||
resp = await client.get("/api/tickets", params={"page": 1, "page_size": 200})
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["total"] == 14
|
||||
assert len(data["items"]) == 14
|
||||
assert data["page"] == 1
|
||||
assert data["page_size"] == 200
|
||||
|
||||
|
||||
async def test_paginated_loop_collects_all_tickets(client, seed_tickets):
|
||||
"""The frontend's page loop (page_size=200 until total reached) collects every ticket."""
|
||||
total_seeded = await seed_tickets(450) # 3 pages of 200
|
||||
|
||||
collected: list[dict] = []
|
||||
total = float("inf")
|
||||
page = 1
|
||||
page_size = 200
|
||||
while len(collected) < total and page <= 1000:
|
||||
resp = await client.get("/api/tickets", params={"page": page, "page_size": page_size})
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["items"], "expected a non-empty page"
|
||||
collected.extend(data["items"])
|
||||
total = data["total"] or len(collected)
|
||||
page += 1
|
||||
|
||||
assert total == total_seeded
|
||||
assert len(collected) == total_seeded
|
||||
# No duplicate tickets across pages
|
||||
ids = [t["id"] for t in collected]
|
||||
assert len(set(ids)) == len(ids)
|
||||
Reference in New Issue
Block a user