Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2e995ee758 | ||
|
|
d4ef96f17c | ||
|
|
d074f347dc | ||
|
|
47be148240 | ||
|
|
f84021bc14 | ||
|
|
cf55576d10 | ||
|
|
c9f5ac4380 |
@@ -6,6 +6,7 @@ __pycache__/
|
|||||||
.venv/
|
.venv/
|
||||||
uploads/
|
uploads/
|
||||||
test_*.py
|
test_*.py
|
||||||
|
!tests/test_*.py
|
||||||
venv/
|
venv/
|
||||||
*.egg-info/
|
*.egg-info/
|
||||||
dist/
|
dist/
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ app/
|
|||||||
├── services/ # Business logic (auth, seed, ticket, sla)
|
├── services/ # Business logic (auth, seed, ticket, sla)
|
||||||
└── routers/ # FastAPI route handlers
|
└── routers/ # FastAPI route handlers
|
||||||
alembic/ # Database migrations
|
alembic/ # Database migrations
|
||||||
|
tests/ # pytest suite; conftest.py swaps DATABASE_URL to a temp SQLite
|
||||||
uploads/ # Photo uploads (created at runtime)
|
uploads/ # Photo uploads (created at runtime)
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -29,6 +30,7 @@ uploads/ # Photo uploads (created at runtime)
|
|||||||
|
|
||||||
- `alembic upgrade head` — apply migrations
|
- `alembic upgrade head` — apply migrations
|
||||||
- `alembic revision --autogenerate -m "msg"` — new migration
|
- `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
|
## Seed data
|
||||||
|
|
||||||
|
|||||||
@@ -196,7 +196,7 @@ async def list_tickets(
|
|||||||
|
|
||||||
# Paginate
|
# Paginate
|
||||||
offset = (page - 1) * page_size
|
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)
|
result = await db.execute(query)
|
||||||
tickets = list(result.scalars().all())
|
tickets = list(result.scalars().all())
|
||||||
|
|||||||
+55
-48
@@ -12,17 +12,23 @@
|
|||||||
extend: {
|
extend: {
|
||||||
colors: {
|
colors: {
|
||||||
denya: {
|
denya: {
|
||||||
50: '#eff6ff',
|
50: '#e8f0ea',
|
||||||
100: '#dbeafe',
|
100: '#c5d9cb',
|
||||||
200: '#bfdbfe',
|
200: '#9ebfaa',
|
||||||
300: '#93c5fd',
|
300: '#74a589',
|
||||||
400: '#60a5fa',
|
400: '#4d8c69',
|
||||||
500: '#3b82f6',
|
500: '#2d734d',
|
||||||
600: '#2563eb',
|
600: '#1d5a3a',
|
||||||
700: '#1d4ed8',
|
700: '#0d2b18',
|
||||||
800: '#1e40af',
|
800: '#0a2012',
|
||||||
900: '#1e3a8a',
|
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-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; }
|
||||||
.priority-low { @apply bg-green-100 text-green-800 border-green-300; }
|
.priority-low { @apply bg-green-100 text-green-800 border-green-300; }
|
||||||
|
.brand-gradient { background: linear-gradient(135deg, #0d2b18 0%, #1a3d24 100%); }
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</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 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="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
<div class="flex items-center justify-between h-16">
|
<div class="flex items-center justify-between h-16">
|
||||||
<!-- Left side -->
|
<!-- Left side -->
|
||||||
<div class="flex items-center space-x-4">
|
<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">
|
<a href="/dashboard/cs" class="flex items-center space-x-3">
|
||||||
<svg class="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<!-- Denya Developers Logo Mark -->
|
||||||
<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"/>
|
<div class="w-9 h-9 bg-gold rounded-lg flex items-center justify-center shadow-sm">
|
||||||
</svg>
|
<svg class="w-5 h-5 text-[#0d2b18]" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24">
|
||||||
<span>Denya OneCare</span>
|
<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>
|
</a>
|
||||||
<!-- CS Links -->
|
<!-- Nav Links -->
|
||||||
<template x-if="isCS">
|
<template x-if="isCS">
|
||||||
<div class="hidden md:flex space-x-1 ml-6">
|
<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="/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" :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" 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-600 hover:text-gray-900 hover:bg-gray-50">Create Issue</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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<!-- FM Links -->
|
|
||||||
<template x-if="isFM">
|
<template x-if="isFM">
|
||||||
<div class="hidden md:flex space-x-1 ml-6">
|
<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="/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" :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" 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-600 hover:text-gray-900 hover:bg-gray-50">Create Issue</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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<!-- CEO/Director Links -->
|
|
||||||
<template x-if="isExecutive">
|
<template x-if="isExecutive">
|
||||||
<div class="hidden md:flex space-x-1 ml-6">
|
<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="/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" :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="/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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
<!-- Right side -->
|
<!-- Right side -->
|
||||||
<div class="flex items-center space-x-4">
|
<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>
|
<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-red-600 hover:text-red-800 hover:bg-red-50 rounded-md transition-colors">
|
<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
|
Logout
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -99,19 +110,19 @@
|
|||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<!-- Mobile Nav (CS) -->
|
<!-- Mobile Nav -->
|
||||||
<div class="md:hidden border-b bg-white" x-show="isLoggedIn" x-cloak>
|
<div class="md:hidden border-b bg-[#0d2b18] border-denya-800" x-show="isLoggedIn" x-cloak>
|
||||||
<template x-if="isCS || isFM">
|
<template x-if="isCS || isFM">
|
||||||
<div class="flex overflow-x-auto px-4 py-2 space-x-2">
|
<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="/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-50 text-denya-700' : 'text-gray-500'">Issues</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-500">New Issue</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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<template x-if="isExecutive">
|
<template x-if="isExecutive">
|
||||||
<div class="flex overflow-x-auto px-4 py-2 space-x-2">
|
<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="/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-500">Issues</a>
|
<a href="/tickets" class="px-3 py-1.5 rounded text-sm font-medium whitespace-nowrap text-gray-300">Issues</a>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
@@ -122,13 +133,13 @@
|
|||||||
</main>
|
</main>
|
||||||
|
|
||||||
<!-- Loading Overlay -->
|
<!-- 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 x-show="loading" class="fixed inset-0 bg-[#0d2b18]/40 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">
|
<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-600" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
<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>
|
<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>
|
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
|
||||||
</svg>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -136,7 +147,7 @@
|
|||||||
<div class="fixed bottom-4 right-4 z-50 space-y-2">
|
<div class="fixed bottom-4 right-4 z-50 space-y-2">
|
||||||
<template x-for="toast in toasts" :key="toast.id">
|
<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"
|
<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)">
|
x-init="setTimeout(() => { toasts = toasts.filter(t => t.id !== toast.id) }, toast.duration || 4000)">
|
||||||
<span x-text="toast.message"></span>
|
<span x-text="toast.message"></span>
|
||||||
</div>
|
</div>
|
||||||
@@ -164,12 +175,10 @@
|
|||||||
get isAdmin() { return ['Admin/Jerome', 'Admin/Wahab'].includes(this.user.role) },
|
get isAdmin() { return ['Admin/Jerome', 'Admin/Wahab'].includes(this.user.role) },
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
// Redirect to login if not logged in (skip for login page)
|
|
||||||
if (!this.isLoggedIn && this.currentPath !== '/login') {
|
if (!this.isLoggedIn && this.currentPath !== '/login') {
|
||||||
window.location.href = '/login';
|
window.location.href = '/login';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Verify token on load
|
|
||||||
if (this.isLoggedIn) {
|
if (this.isLoggedIn) {
|
||||||
this.fetchMe();
|
this.fetchMe();
|
||||||
}
|
}
|
||||||
@@ -209,7 +218,7 @@
|
|||||||
opts.body = JSON.stringify(body);
|
opts.body = JSON.stringify(body);
|
||||||
} else if (body instanceof FormData) {
|
} else if (body instanceof FormData) {
|
||||||
opts.body = body;
|
opts.body = body;
|
||||||
delete opts.headers['Content-Type']; // Let browser set multipart boundary
|
delete opts.headers['Content-Type'];
|
||||||
opts.headers = { 'Authorization': `Bearer ${this.token}` };
|
opts.headers = { 'Authorization': `Bearer ${this.token}` };
|
||||||
}
|
}
|
||||||
const res = await fetch(url, opts);
|
const res = await fetch(url, opts);
|
||||||
@@ -253,13 +262,11 @@
|
|||||||
localStorage.setItem('refresh_token', data.refresh_token);
|
localStorage.setItem('refresh_token', data.refresh_token);
|
||||||
this.token = data.access_token;
|
this.token = data.access_token;
|
||||||
|
|
||||||
// Fetch user info
|
|
||||||
const me = await this.apiGet('/api/auth/me');
|
const me = await this.apiGet('/api/auth/me');
|
||||||
this.user = me;
|
this.user = me;
|
||||||
localStorage.setItem('user', JSON.stringify(me));
|
localStorage.setItem('user', JSON.stringify(me));
|
||||||
this.isLoggedIn = true;
|
this.isLoggedIn = true;
|
||||||
|
|
||||||
// Role-based redirect
|
|
||||||
const role = me.role;
|
const role = me.role;
|
||||||
if (['CS Rep', 'CS Manager'].includes(role)) {
|
if (['CS Rep', 'CS Manager'].includes(role)) {
|
||||||
window.location.href = '/dashboard/cs';
|
window.location.href = '/dashboard/cs';
|
||||||
|
|||||||
@@ -171,10 +171,22 @@
|
|||||||
|
|
||||||
async loadData() {
|
async loadData() {
|
||||||
try {
|
try {
|
||||||
const allData = await app().apiGet('/api/tickets?page_size=500');
|
// Fetch ALL tickets via pagination. The API caps page_size at 200
|
||||||
if (!allData?.items) return;
|
// (app/routers/tickets.py), so a single page_size=500 request returns 422
|
||||||
const all = allData.items;
|
// and the dashboard renders empty KPIs. Loop pages until we have `total`
|
||||||
const total = allData.total || all.length;
|
// 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
|
// Basic KPIs
|
||||||
const open = all.filter(t => !['Closed', 'Completed'].includes(t.status));
|
const open = all.filter(t => !['Closed', 'Completed'].includes(t.status));
|
||||||
|
|||||||
@@ -22,8 +22,14 @@ build-backend = "setuptools.build_meta"
|
|||||||
[tool.setuptools.packages.find]
|
[tool.setuptools.packages.find]
|
||||||
include = ["app*"]
|
include = ["app*"]
|
||||||
|
|
||||||
|
[tool.pytest.ini_options]
|
||||||
|
asyncio_mode = "auto"
|
||||||
|
testpaths = ["tests"]
|
||||||
|
pythonpath = ["."]
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
dev = [
|
dev = [
|
||||||
"pytest>=8.0",
|
"pytest>=8.0",
|
||||||
|
"pytest-asyncio>=0.24",
|
||||||
"httpx>=0.27.0",
|
"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