Author SHA1 Message Date
root 2e995ee758 no-mistakes(review): Add pytest pythonpath config so tests resolve app imports 2026-07-31 10:18:52 +00:00
root d4ef96f17c no-mistakes(document): docs: add pytest suite to AGENTS.md structure and commands 2026-07-31 10:09:54 +00:00
root d074f347dc no-mistakes(review): Add pytest-asyncio dev dep and deterministic pagination tiebreaker 2026-07-31 09:57:56 +00:00
root 47be148240 fix(dashboard): paginate CEO dashboard ticket fetch to respect API cap
The CEO dashboard requested /api/tickets?page_size=500, but the API caps
page_size at 200 (le=200 in app/routers/tickets.py), so the request
returned 422 and every KPI/chart rendered zeros.

- ceo.html: fetch all tickets by looping pages of page_size=200 until
  total items are collected (with a safety bound), keeping KPIs accurate
  as volume grows past 200.
- tests: add test suite anchoring the pagination contract — page_size=500
  returns 422, page_size=200 returns items/total/page/page_size, and a
  page loop collects every ticket without duplicates.
- pyproject: enable pytest-asyncio auto mode and tests/ discovery.
- .gitignore: un-ignore committed tests/test_*.py.
2026-07-31 09:53:35 +00:00
root f84021bc14 Merge remote-tracking branch 'origin/fm/denya-onecare-s3' 2026-07-25 00:19:59 +00:00
root cf55576d10 feat: Denya Developers brand theming
- Brand colors: primary #0d2b18 (deep forest green), accent #c8a96e (gold), bg #faf8f5 (cream), text #1a1a1a
- Nav bg #0d2b18 with gold logo mark and white text
- Denya Developers + OneCare branding in nav header
- Gold accent borders, hover states, and active tab highlights
- Toast/success use brand green shades
- Body background cream (#faf8f5)
2026-07-25 00:19:59 +00:00
root c9f5ac4380 feat: Denya Developers brand theming
- Brand colors: primary #0d2b18 (deep forest green), accent #c8a96e (gold), bg #faf8f5 (cream), text #1a1a1a
- Nav bg #0d2b18 with gold logo mark and white text
- Denya Developers + OneCare branding in nav header
- Gold accent borders, hover states, and active tab highlights
- Toast/success use brand green shades
- Body background cream (#faf8f5)
2026-07-23 19:46:08 +00:00
8 changed files with 203 additions and 53 deletions
+1
View File
@@ -6,6 +6,7 @@ __pycache__/
.venv/
uploads/
test_*.py
!tests/test_*.py
venv/
*.egg-info/
dist/
+2
View File
@@ -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
+1 -1
View File
@@ -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).options(selectinload(Ticket.assigned_technician))
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())
+54 -47
View File
@@ -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"/>
<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>
<span>Denya OneCare</span>
</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';
+16 -4
View File
@@ -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));
+6
View File
@@ -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",
]
+66
View File
@@ -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
+56
View File
@@ -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)