Author SHA1 Message Date
root 344d555529 no-mistakes(document): Fix stale seed.py docstring and remove unused imports across 3 files 2026-07-23 17:42:54 +00:00
root 1120081760 no-mistakes(review): Removed silently-discarded note field from TicketUpdate schema 2026-07-23 17:36:32 +00:00
root 57b18a27e3 no-mistakes(review): Removed redundant or 0 guard on reopen_count increment 2026-07-23 17:33:19 +00:00
root a27b1856cc no-mistakes(review): All F01-F09 findings verified as properly fixed in the codebase 2026-07-23 17:24:57 +00:00
root 2de3c8514c no-mistakes(review): Fix orphaned photo files and add upload content-type validation 2026-07-23 17:21:46 +00:00
root 31ae78ec07 no-mistakes(review): Fix redundant OR, missing note field, dead code, and redundant subquery in ticket service/schema 2026-07-23 17:18:13 +00:00
root 65622de7a6 Sprint 2: Ticket Engine + SLA + Photo Uploads
- Ticket CRUD with full 15-status lifecycle enforcement
- Auto-generated ticket numbers (PAV-YYYY-NNNNN)
- Status transitions validated and auto-logged to timeline
- Reopen with 7-day window + auto-escalation to Ama
- SLA engine: priority-based deadlines and breach detection
- Photo uploads to uploads/ with multipart support
- Category system seeded from PRD §7 taxonomy (20 top-level)
- Flat and tree category listing endpoints
- SLA status check endpoint (/api/tickets/{id}/sla)
- Paginated ticket listing with filters
2026-07-23 17:13:12 +00:00
abiba-bot 06bcdb0392 Sprint 1: Foundation — FastAPI scaffold, auth, DB schema, mock WhatsApp, Docker 2026-07-23 17:09:02 +00:00
root 955b59945c no-mistakes(test): Fix Docker build: add build-system config and scope package discovery to app* 2026-07-23 12:25:10 +00:00
root a8745c79c9 no-mistakes(document): Fix AGENTS.md: 17 users, admin-only endpoint, correct RBAC example 2026-07-23 12:16:56 +00:00
root 4b5d3e4ac1 no-mistakes(review): Remove redundant pip install for alembic and aiosqlite from Dockerfile 2026-07-23 12:12:43 +00:00
root 1677498a8e feat: add admin-only RBAC demo endpoint with require_roles 2026-07-23 12:04:35 +00:00
root 32a4c50b23 feat: Sprint 1 foundation - FastAPI scaffold, DB schema, auth, seed data, mock WhatsApp, Docker 2026-07-23 12:04:14 +00:00
38 changed files with 2345 additions and 0 deletions
+11
View File
@@ -0,0 +1,11 @@
__pycache__/
*.py[cod]
*.db
*.sqlite3
.env
.venv/
uploads/
test_*.py
venv/
*.egg-info/
dist/
+109
View File
@@ -0,0 +1,109 @@
# Denya OneCare — Agent memory
Denya OneCare is a centralized maintenance/issue tracking system for Pavilion Accra (FastAPI + SQLite + Alpine.js + Twilio).
## Quick start
```bash
# Start the app
docker-compose up
# Or directly
uvicorn app.main:app --reload
```
## Project structure
```
app/
├── core/ # config, database, security (JWT + bcrypt + RBAC)
├── models/ # SQLAlchemy ORM models
├── schemas/ # Pydantic request/response schemas
├── services/ # Business logic (auth, seed, ticket, sla)
└── routers/ # FastAPI route handlers
alembic/ # Database migrations
uploads/ # Photo uploads (created at runtime)
```
## Commands
- `alembic upgrade head` — apply migrations
- `alembic revision --autogenerate -m "msg"` — new migration
## Seed data
Users, units, and categories are auto-seeded on first startup via lifespan hook:
- 17 users covering all roles (Admin/Jerome, Admin/Wahab, CS Rep, CS Manager, FM Dispatcher, Tech, CEO, Director)
- 120 apartment units (East/West, 10 floors × 6 apts per wing)
- Default password for all seed users: `denya123`
- Units load from `apartment_mapping.json` if present, else built-in fallback
- Categories: 20 top-level (10 Maintenance, 6 CS, 4 Emergency) with sub-categories, seeded from `app/services/seed.py::SEED_CATEGORIES_DATA`
## Key API endpoints
### Auth & Health
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| GET | `/health` | No | Health check |
| POST | `/api/auth/register` | No | Create user |
| POST | `/api/auth/login` | No | Get JWT tokens |
| POST | `/api/auth/refresh` | Token | Refresh tokens |
| GET | `/api/auth/me` | Bearer | Current user |
| GET | `/api/auth/admin-only` | Admin/Jerome, Admin/Wahab | RBAC demo endpoint |
| POST | `/api/whatsapp/mock` | No | Mock WhatsApp |
| GET | `/api/whatsapp/mock-log` | No | Recent mock submissions |
### Tickets (Sprint 2)
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| POST | `/api/tickets` | Bearer | Create ticket (auto-number PAV-YYYY-NNNNN) |
| GET | `/api/tickets` | No | List tickets (filter: status, priority, property, category_id, assigned_to, date_from, date_to) |
| GET | `/api/tickets/{id}` | No | Get ticket detail with timeline, photos, SLA status |
| PATCH | `/api/tickets/{id}` | Bearer | Update ticket (validates status transitions) |
| POST | `/api/tickets/{id}/status` | Bearer | Change status with note |
| GET | `/api/tickets/{id}/sla` | No | Check SLA breach status |
| POST | `/api/tickets/{id}/photos` | Bearer | Upload photos (multipart, is_before param) |
| GET | `/api/tickets/{id}/photos` | No | List photos |
| GET | `/api/tickets/categories` | No | Category tree (optional `?type=` filter) |
| GET | `/api/tickets/categories/flat` | No | Flat category list |
## Auth
- JWT access (30min) + refresh (7d) tokens
- Roles: CS Rep, CS Manager, FM Dispatcher, Admin/Jerome, Admin/Wahab, Tech, CEO, Director
- Use `require_roles("Admin/Jerome", "Admin/Wahab")` dependency for RBAC
- `sub` claim holds string user ID
## Ticket System (Sprint 2)
### Status Lifecycle (15 statuses)
New → Logged → Triage → Assigned → Accepted → Travelling → On Site → In Progress → Waiting Parts → Escalated → Completed → On-Field Verification → Wahab Review → Closed → Reopened
Valid transitions defined in `app/services/ticket.py::VALID_TRANSITIONS`. Invalid transitions return 400.
### SLA Engine
Defined in `app/services/sla.py`. Priority-based targets:
- Urgent: respond 15min, resolve 4h
- High: respond 30min, resolve 24h
- Medium: respond 4h, resolve 72h (3d)
- Low: respond 24h, resolve 168h (7d)
`sla_deadline` auto-calculated on ticket creation. SLA status check at `GET /api/tickets/{id}/sla`.
### Ticket Number Format
`PAV-YYYY-NNNNN` — sequential per year (e.g., PAV-2026-00001).
### Photo Uploads
Stored under `uploads/` with UUID filenames. Static-files mounted at `/uploads/`. Multipart POST with `is_before` query param.
## Database
SQLite via aiosqlite with async SQLAlchemy 2.0. Alembic for migrations.
Tables: users, units, categories, tickets, ticket_timeline, ticket_photos, escalations, whatsapp_log
## Maintaining this file
Keep this file for knowledge useful to almost every future agent session in this project.
Do not repeat what the codebase already shows; point to the authoritative file or command instead.
Prefer rewriting or pruning existing entries over appending new ones.
When updating this file, preserve this bar for all agents and keep entries concise.
Symlink
+1
View File
@@ -0,0 +1 @@
AGENTS.md
+23
View File
@@ -0,0 +1,23 @@
FROM python:3.12-slim
WORKDIR /app
# Install system dependencies
RUN apt-get update && apt-get install -y --no-install-recommends \
gcc \
&& rm -rf /var/lib/apt/lists/*
# Copy project files
COPY pyproject.toml .
COPY alembic.ini .
COPY alembic/ alembic/
COPY app/ app/
# Install Python dependencies
RUN pip install --no-cache-dir .
# Expose port
EXPOSE 8000
# Run with uvicorn
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
+149
View File
@@ -0,0 +1,149 @@
# A generic, single database configuration.
[alembic]
# path to migration scripts.
# this is typically a path given in POSIX (e.g. forward slashes)
# format, relative to the token %(here)s which refers to the location of this
# ini file
script_location = %(here)s/alembic
# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
# Uncomment the line below if you want the files to be prepended with date and time
# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file
# for all available tokens
# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
# Or organize into date-based subdirectories (requires recursive_version_locations = true)
# file_template = %%(year)d/%%(month).2d/%%(day).2d_%%(hour).2d%%(minute).2d_%%(second).2d_%%(rev)s_%%(slug)s
# sys.path path, will be prepended to sys.path if present.
# defaults to the current working directory. for multiple paths, the path separator
# is defined by "path_separator" below.
prepend_sys_path = .
# timezone to use when rendering the date within the migration file
# as well as the filename.
# If specified, requires the tzdata library which can be installed by adding
# `alembic[tz]` to the pip requirements.
# string value is passed to ZoneInfo()
# leave blank for localtime
# timezone =
# max length of characters to apply to the "slug" field
# truncate_slug_length = 40
# set to 'true' to run the environment during
# the 'revision' command, regardless of autogenerate
# revision_environment = false
# set to 'true' to allow .pyc and .pyo files without
# a source .py file to be detected as revisions in the
# versions/ directory
# sourceless = false
# version location specification; This defaults
# to <script_location>/versions. When using multiple version
# directories, initial revisions must be specified with --version-path.
# The path separator used here should be the separator specified by "path_separator"
# below.
# version_locations = %(here)s/bar:%(here)s/bat:%(here)s/alembic/versions
# path_separator; This indicates what character is used to split lists of file
# paths, including version_locations and prepend_sys_path within configparser
# files such as alembic.ini.
# The default rendered in new alembic.ini files is "os", which uses os.pathsep
# to provide os-dependent path splitting.
#
# Note that in order to support legacy alembic.ini files, this default does NOT
# take place if path_separator is not present in alembic.ini. If this
# option is omitted entirely, fallback logic is as follows:
#
# 1. Parsing of the version_locations option falls back to using the legacy
# "version_path_separator" key, which if absent then falls back to the legacy
# behavior of splitting on spaces and/or commas.
# 2. Parsing of the prepend_sys_path option falls back to the legacy
# behavior of splitting on spaces, commas, or colons.
#
# Valid values for path_separator are:
#
# path_separator = :
# path_separator = ;
# path_separator = space
# path_separator = newline
#
# Use os.pathsep. Default configuration used for new projects.
path_separator = os
# set to 'true' to search source files recursively
# in each "version_locations" directory
# new in Alembic version 1.10
# recursive_version_locations = false
# the output encoding used when revision files
# are written from script.py.mako
# output_encoding = utf-8
# database URL. This is consumed by the user-maintained env.py script only.
# other means of configuring database URLs may be customized within the env.py
# file.
sqlalchemy.url = driver://user:pass@localhost/dbname
[post_write_hooks]
# post_write_hooks defines scripts or Python functions that are run
# on newly generated revision scripts. See the documentation for further
# detail and examples
# format using "black" - use the console_scripts runner, against the "black" entrypoint
# hooks = black
# black.type = console_scripts
# black.entrypoint = black
# black.options = -l 79 REVISION_SCRIPT_FILENAME
# lint with attempts to fix using "ruff" - use the module runner, against the "ruff" module
# hooks = ruff
# ruff.type = module
# ruff.module = ruff
# ruff.options = check --fix REVISION_SCRIPT_FILENAME
# Alternatively, use the exec runner to execute a binary found on your PATH
# hooks = ruff
# ruff.type = exec
# ruff.executable = ruff
# ruff.options = check --fix REVISION_SCRIPT_FILENAME
# Logging configuration. This is also consumed by the user-maintained
# env.py script only.
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARNING
handlers = console
qualname =
[logger_sqlalchemy]
level = WARNING
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S
+1
View File
@@ -0,0 +1 @@
Generic single-database configuration with an async dbapi.
+70
View File
@@ -0,0 +1,70 @@
import asyncio
from logging.config import fileConfig
from sqlalchemy import pool
from sqlalchemy.engine import Connection
from sqlalchemy.ext.asyncio import async_engine_from_config
from alembic import context
# Alembic Config object
config = context.config
# Set up logging
if config.config_file_name is not None:
fileConfig(config.config_file_name)
# ── Import all models so Alembic can detect them ─────────────────────
from app.core.database import Base # noqa: E402
from app.models.user import User # noqa: E402, F401
from app.models.unit import Unit # noqa: E402, F401
from app.models.category import Category # noqa: E402, F401
from app.models.ticket import Ticket, TicketTimeline, TicketPhoto, Escalation # noqa: E402, F401
from app.models.whatsapp_log import WhatsAppLog # noqa: E402, F401
target_metadata = Base.metadata
# ── Database URL from our settings ──────────────────────────────────
from app.core.config import settings as app_settings # noqa: E402
config.set_main_option("sqlalchemy.url", app_settings.DATABASE_URL)
def run_migrations_offline() -> None:
"""Run migrations in 'offline' mode."""
url = config.get_main_option("sqlalchemy.url")
context.configure(
url=url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
)
with context.begin_transaction():
context.run_migrations()
def do_run_migrations(connection: Connection) -> None:
context.configure(connection=connection, target_metadata=target_metadata)
with context.begin_transaction():
context.run_migrations()
async def run_async_migrations() -> None:
connectable = async_engine_from_config(
config.get_section(config.config_ini_section, {}),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
async with connectable.connect() as connection:
await connection.run_sync(do_run_migrations)
await connectable.dispose()
def run_migrations_online() -> None:
asyncio.run(run_async_migrations())
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()
+28
View File
@@ -0,0 +1,28 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision: str = ${repr(up_revision)}
down_revision: Union[str, Sequence[str], None] = ${repr(down_revision)}
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
def upgrade() -> None:
"""Upgrade schema."""
${upgrades if upgrades else "pass"}
def downgrade() -> None:
"""Downgrade schema."""
${downgrades if downgrades else "pass"}
@@ -0,0 +1,135 @@
"""initial schema
Revision ID: 795c6b95f637
Revises:
Create Date: 2026-07-23 12:01:33.135357
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '795c6b95f637'
down_revision: Union[str, Sequence[str], None] = None
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('categories',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('type', sa.String(length=20), nullable=False, comment='maintenance, cs, emergency'),
sa.Column('name', sa.String(length=100), nullable=False),
sa.Column('parent_id', sa.Integer(), nullable=True),
sa.Column('sla_urgency', sa.String(length=10), nullable=True, comment='urgent, high, medium, low'),
sa.ForeignKeyConstraint(['parent_id'], ['categories.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_table('units',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('property', sa.String(length=10), nullable=False, comment='East or West'),
sa.Column('apartment_code', sa.String(length=20), nullable=False),
sa.Column('building', sa.String(length=100), nullable=True),
sa.Column('floor', sa.Integer(), nullable=True),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_units_apartment_code'), 'units', ['apartment_code'], unique=True)
op.create_table('users',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('email', sa.String(length=255), nullable=False),
sa.Column('password_hash', sa.String(length=255), nullable=False),
sa.Column('full_name', sa.String(length=255), nullable=False),
sa.Column('phone', sa.String(length=50), nullable=True),
sa.Column('role', sa.String(length=50), nullable=False, comment='CS Rep, CS Manager, FM Dispatcher, Admin/Jerome, Admin/Wahab, Tech, CEO, Director'),
sa.Column('active', sa.Boolean(), nullable=False),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_users_email'), 'users', ['email'], unique=True)
op.create_table('whatsapp_log',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('command', sa.Text(), nullable=False),
sa.Column('from_number', sa.String(length=50), nullable=True),
sa.Column('ticket_id', sa.Integer(), nullable=True),
sa.Column('received_at', sa.DateTime(), nullable=False),
sa.PrimaryKeyConstraint('id')
)
op.create_table('tickets',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('ticket_number', sa.String(length=20), nullable=False, comment='Format: PAV-YYYY-NNNNN'),
sa.Column('status', sa.String(length=30), nullable=False, comment='New, Logged, Triage, Assigned, Accepted, Travelling, On Site, In Progress, Waiting Parts, Escalated, Completed, On-Field Verification, Wahab Review, Closed, Reopened'),
sa.Column('unit_id', sa.Integer(), nullable=True),
sa.Column('category_id', sa.Integer(), nullable=True),
sa.Column('priority', sa.String(length=10), nullable=True, comment='urgent, high, medium, low'),
sa.Column('reporter', sa.String(length=255), nullable=True),
sa.Column('reported_via', sa.String(length=20), nullable=True, comment='whatsapp, phone, walk-in, qr, agent'),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('assigned_to', sa.Integer(), nullable=True),
sa.Column('sla_deadline', sa.DateTime(), nullable=True),
sa.Column('eta', sa.DateTime(), nullable=True),
sa.Column('cost', sa.Numeric(precision=10, scale=2), nullable=True),
sa.Column('parts_used', sa.Text(), nullable=True),
sa.Column('customer_rating', sa.Integer(), nullable=True),
sa.Column('reopen_count', sa.Integer(), nullable=False),
sa.Column('closed_at', sa.DateTime(), nullable=True),
sa.Column('created_at', sa.DateTime(), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
sa.Column('updated_at', sa.DateTime(), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
sa.ForeignKeyConstraint(['assigned_to'], ['users.id'], ),
sa.ForeignKeyConstraint(['category_id'], ['categories.id'], ),
sa.ForeignKeyConstraint(['unit_id'], ['units.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_tickets_ticket_number'), 'tickets', ['ticket_number'], unique=True)
op.create_table('escalations',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('ticket_id', sa.Integer(), nullable=False),
sa.Column('escalated_to', sa.Integer(), nullable=False),
sa.Column('reason', sa.Text(), nullable=True),
sa.Column('resolved_at', sa.DateTime(), nullable=True),
sa.Column('created_at', sa.DateTime(), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
sa.ForeignKeyConstraint(['escalated_to'], ['users.id'], ),
sa.ForeignKeyConstraint(['ticket_id'], ['tickets.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_table('ticket_photos',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('ticket_id', sa.Integer(), nullable=False),
sa.Column('photo_url', sa.String(length=500), nullable=False),
sa.Column('is_before', sa.Boolean(), nullable=False),
sa.ForeignKeyConstraint(['ticket_id'], ['tickets.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_table('ticket_timeline',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('ticket_id', sa.Integer(), nullable=False),
sa.Column('from_status', sa.String(length=30), nullable=True),
sa.Column('to_status', sa.String(length=30), nullable=True),
sa.Column('note', sa.Text(), nullable=True),
sa.Column('user_id', sa.Integer(), nullable=True),
sa.Column('created_at', sa.DateTime(), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
sa.ForeignKeyConstraint(['ticket_id'], ['tickets.id'], ),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
sa.PrimaryKeyConstraint('id')
)
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table('ticket_timeline')
op.drop_table('ticket_photos')
op.drop_table('escalations')
op.drop_index(op.f('ix_tickets_ticket_number'), table_name='tickets')
op.drop_table('tickets')
op.drop_table('whatsapp_log')
op.drop_index(op.f('ix_users_email'), table_name='users')
op.drop_table('users')
op.drop_index(op.f('ix_units_apartment_code'), table_name='units')
op.drop_table('units')
op.drop_table('categories')
# ### end Alembic commands ###
View File
View File
+38
View File
@@ -0,0 +1,38 @@
"""Application configuration via Pydantic-settings environment variables."""
from __future__ import annotations
from pathlib import Path
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(
env_file=".env",
env_file_encoding="utf-8",
case_sensitive=False,
extra="ignore",
)
# ── App ──────────────────────────────────────────────────────────
APP_NAME: str = "Denya OneCare"
DEBUG: bool = False
# ── Database ─────────────────────────────────────────────────────
DATABASE_URL: str = "sqlite+aiosqlite:///./denya_onecare.db"
# ── Auth ─────────────────────────────────────────────────────────
SECRET_KEY: str = "change-me-in-production-use-a-real-secret"
ALGORITHM: str = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES: int = 30
REFRESH_TOKEN_EXPIRE_MINUTES: int = 60 * 24 * 7 # 7 days
# ── CORS ─────────────────────────────────────────────────────────
CORS_ORIGINS: str = "*"
# ── Paths ────────────────────────────────────────────────────────
BASE_DIR: Path = Path(__file__).resolve().parent.parent.parent
settings = Settings()
+33
View File
@@ -0,0 +1,33 @@
"""Database engine, session factory, and Base."""
from __future__ import annotations
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from sqlalchemy.orm import DeclarativeBase
from app.core.config import settings
engine = create_async_engine(settings.DATABASE_URL, echo=settings.DEBUG)
async_session_factory = async_sessionmaker(
engine,
class_=AsyncSession,
expire_on_commit=False,
)
class Base(DeclarativeBase):
pass
async def get_db() -> AsyncSession: # type: ignore[misc]
"""FastAPI dependency that yields an async DB session."""
async with async_session_factory() as session:
try:
yield session
await session.commit()
except Exception:
await session.rollback()
raise
finally:
await session.close()
+115
View File
@@ -0,0 +1,115 @@
"""Password hashing, JWT token creation / verification, and RBAC."""
from __future__ import annotations
from datetime import datetime, timedelta, timezone
from functools import wraps
from typing import Annotated, Any, Callable
import bcrypt
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from jose import JWTError, jwt
from jose.exceptions import JWTClaimsError
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.user import User
bearer_scheme = HTTPBearer(auto_error=False)
# ── Password helpers ─────────────────────────────────────────────────
def hash_password(password: str) -> str:
"""Return bcrypt hash of *password*."""
salt = bcrypt.gensalt()
return bcrypt.hashpw(password.encode("utf-8"), salt).decode("utf-8")
def verify_password(plain: str, hashed: str) -> bool:
"""Return True if *plain* matches *hashed*."""
return bcrypt.checkpw(plain.encode("utf-8"), hashed.encode("utf-8"))
# ── JWT helpers ──────────────────────────────────────────────────────
def create_access_token(data: dict[str, Any], expires_delta: timedelta | None = None) -> str:
"""Create a signed JWT access token."""
to_encode = data.copy()
expire = datetime.now(timezone.utc) + (expires_delta or timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES))
to_encode.update({"exp": expire, "type": "access"})
return jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
def create_refresh_token(data: dict[str, Any]) -> str:
"""Create a signed JWT refresh token."""
to_encode = data.copy()
expire = datetime.now(timezone.utc) + timedelta(minutes=settings.REFRESH_TOKEN_EXPIRE_MINUTES)
to_encode.update({"exp": expire, "type": "refresh"})
return jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
def decode_token(token: str) -> dict[str, Any]:
"""Decode and validate a JWT token. Raises 401 on failure."""
try:
payload = jwt.decode(
token,
settings.SECRET_KEY,
algorithms=[settings.ALGORITHM],
options={"verify_sub": False},
)
return payload
except (JWTError, JWTClaimsError):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid or expired token",
)
# ── Dependencies ─────────────────────────────────────────────────────
async def get_current_user(
credentials: Annotated[HTTPAuthorizationCredentials | None, Depends(bearer_scheme)],
db: Annotated[AsyncSession, Depends(get_db)],
) -> User:
"""Return the authenticated User from the Bearer token."""
if credentials is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Not authenticated",
)
payload = decode_token(credentials.credentials)
raw_sub = payload.get("sub")
if raw_sub is None:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token payload")
try:
user_id = int(raw_sub)
except (ValueError, TypeError):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token payload")
result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
if user is None or not user.active:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found or inactive")
return user
def require_roles(*roles: str) -> Callable:
"""Decorator for route dependencies that enforces one of the given roles.
Usage::
@router.get("/admin-only")
async def admin_endpoint(current_user: Annotated[User, Depends(require_roles("Admin/Jerome"))]):
...
"""
async def role_checker(current_user: Annotated[User, Depends(get_current_user)]) -> User:
if current_user.role not in roles:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"Role '{current_user.role}' not in {roles}",
)
return current_user
return role_checker
+63
View File
@@ -0,0 +1,63 @@
"""Denya OneCare — FastAPI application entry point."""
from __future__ import annotations
import logging
from contextlib import asynccontextmanager
from pathlib import Path
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from app.core.config import settings
from app.core.database import Base, async_session_factory, engine
from app.routers import auth, health, tickets, whatsapp
from app.services.seed import seed_categories, seed_units, seed_users
logger = logging.getLogger(__name__)
@asynccontextmanager
async def lifespan(app: FastAPI):
"""Initialise database and seed data on startup."""
logger.info("Starting Denya OneCare …")
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()
await seed_units(session, json_path=str(settings.BASE_DIR / "apartment_mapping.json"))
await session.commit()
await seed_categories(session)
await session.commit()
yield
await engine.dispose()
logger.info("Denya OneCare stopped.")
app = FastAPI(
title=settings.APP_NAME,
version="0.1.0",
lifespan=lifespan,
)
# ── CORS ─────────────────────────────────────────────────────────────
app.add_middleware(
CORSMiddleware,
allow_origins=settings.CORS_ORIGINS.split(",") if settings.CORS_ORIGINS != "*" else ["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# ── Static files (uploads) ───────────────────────────────────────────
uploads_dir = Path(settings.BASE_DIR / "uploads")
uploads_dir.mkdir(parents=True, exist_ok=True)
app.mount("/uploads", StaticFiles(directory=str(uploads_dir)), name="uploads")
# ── Routers ──────────────────────────────────────────────────────────
app.include_router(health.router)
app.include_router(auth.router)
app.include_router(whatsapp.router)
app.include_router(tickets.router)
+7
View File
@@ -0,0 +1,7 @@
"""Import all models so SQLAlchemy can resolve string-based relationships."""
from app.models.category import Category # noqa: F401
from app.models.ticket import Escalation, Ticket, TicketPhoto, TicketTimeline # noqa: F401
from app.models.unit import Unit # noqa: F401
from app.models.user import User # noqa: F401
from app.models.whatsapp_log import WhatsAppLog # noqa: F401
+37
View File
@@ -0,0 +1,37 @@
"""Ticket category model with self-referencing parent for sub-categories."""
from __future__ import annotations
from sqlalchemy import ForeignKey, Integer, String
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.core.database import Base
class Category(Base):
__tablename__ = "categories"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
type: Mapped[str] = mapped_column(
String(20),
nullable=False,
comment="maintenance, cs, emergency",
)
name: Mapped[str] = mapped_column(String(100), nullable=False)
parent_id: Mapped[int | None] = mapped_column(
Integer,
ForeignKey("categories.id"),
nullable=True,
)
sla_urgency: Mapped[str | None] = mapped_column(
String(10),
nullable=True,
comment="urgent, high, medium, low",
)
# self-referencing relationship
children: Mapped[list[Category]] = relationship("Category", back_populates="parent", cascade="all, delete-orphan")
parent: Mapped[Category | None] = relationship("Category", back_populates="children", remote_side="Category.id")
def __repr__(self) -> str:
return f"<Category {self.type}:{self.name}>"
+123
View File
@@ -0,0 +1,123 @@
"""Ticket, TicketTimeline, TicketPhoto, and Escalation models."""
from __future__ import annotations
from datetime import datetime
from decimal import Decimal
from sqlalchemy import DateTime, ForeignKey, Integer, Numeric, String, Text, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.core.database import Base
class Ticket(Base):
__tablename__ = "tickets"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
ticket_number: Mapped[str] = mapped_column(
String(20),
unique=True,
nullable=False,
index=True,
comment="Format: PAV-YYYY-NNNNN",
)
status: Mapped[str] = mapped_column(
String(30),
nullable=False,
default="New",
comment=(
"New, Logged, Triage, Assigned, Accepted, Travelling, On Site, "
"In Progress, Waiting Parts, Escalated, Completed, "
"On-Field Verification, Wahab Review, Closed, Reopened"
),
)
unit_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("units.id"), nullable=True)
category_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("categories.id"), nullable=True)
priority: Mapped[str | None] = mapped_column(
String(10),
nullable=True,
comment="urgent, high, medium, low",
)
reporter: Mapped[str | None] = mapped_column(String(255), nullable=True)
reported_via: Mapped[str | None] = mapped_column(
String(20),
nullable=True,
comment="whatsapp, phone, walk-in, qr, agent",
)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
assigned_to: Mapped[int | None] = mapped_column(Integer, ForeignKey("users.id"), nullable=True)
sla_deadline: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
eta: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
cost: Mapped[Decimal | None] = mapped_column(Numeric(10, 2), nullable=True)
parts_used: Mapped[str | None] = mapped_column(Text, nullable=True)
customer_rating: Mapped[int | None] = mapped_column(Integer, nullable=True)
reopen_count: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
closed_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), nullable=False)
updated_at: Mapped[datetime] = mapped_column(
DateTime,
server_default=func.now(),
onupdate=func.now(),
nullable=False,
)
# relationships
unit = relationship("Unit")
category = relationship("Category")
assigned_technician = relationship("User", back_populates="assigned_tickets", foreign_keys=[assigned_to])
timeline = relationship("TicketTimeline", back_populates="ticket", order_by="TicketTimeline.created_at")
photos = relationship("TicketPhoto", back_populates="ticket")
escalations = relationship("Escalation", back_populates="ticket")
def __repr__(self) -> str:
return f"<Ticket {self.ticket_number} ({self.status})>"
class TicketTimeline(Base):
__tablename__ = "ticket_timeline"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
ticket_id: Mapped[int] = mapped_column(Integer, ForeignKey("tickets.id"), nullable=False)
from_status: Mapped[str | None] = mapped_column(String(30), nullable=True)
to_status: Mapped[str | None] = mapped_column(String(30), nullable=True)
note: Mapped[str | None] = mapped_column(Text, nullable=True)
user_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("users.id"), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), nullable=False)
ticket = relationship("Ticket", back_populates="timeline")
user = relationship("User", back_populates="timeline_entries")
def __repr__(self) -> str:
return f"<TicketTimeline {self.ticket_id}: {self.from_status}{self.to_status}>"
class TicketPhoto(Base):
__tablename__ = "ticket_photos"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
ticket_id: Mapped[int] = mapped_column(Integer, ForeignKey("tickets.id"), nullable=False)
photo_url: Mapped[str] = mapped_column(String(500), nullable=False)
is_before: Mapped[bool] = mapped_column(nullable=False, default=True)
ticket = relationship("Ticket", back_populates="photos")
def __repr__(self) -> str:
return f"<TicketPhoto {self.id} ticket={self.ticket_id}>"
class Escalation(Base):
__tablename__ = "escalations"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
ticket_id: Mapped[int] = mapped_column(Integer, ForeignKey("tickets.id"), nullable=False)
escalated_to: Mapped[int] = mapped_column(Integer, ForeignKey("users.id"), nullable=False)
reason: Mapped[str | None] = mapped_column(Text, nullable=True)
resolved_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), nullable=False)
ticket = relationship("Ticket", back_populates="escalations")
escalated_to_user = relationship("User", back_populates="escalations", foreign_keys=[escalated_to])
def __repr__(self) -> str:
return f"<Escalation ticket={self.ticket_id} → user={self.escalated_to}>"
+21
View File
@@ -0,0 +1,21 @@
"""Unit model — an apartment at Pavilion East/West."""
from __future__ import annotations
from sqlalchemy import String
from sqlalchemy.orm import Mapped, mapped_column
from app.core.database import Base
class Unit(Base):
__tablename__ = "units"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
property: Mapped[str] = mapped_column(String(10), nullable=False, comment="East or West")
apartment_code: Mapped[str] = mapped_column(String(20), unique=True, nullable=False, index=True)
building: Mapped[str | None] = mapped_column(String(100), nullable=True)
floor: Mapped[int | None] = mapped_column(nullable=True)
def __repr__(self) -> str:
return f"<Unit {self.apartment_code}>"
+32
View File
@@ -0,0 +1,32 @@
"""User model."""
from __future__ import annotations
from sqlalchemy import Boolean, String
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.core.database import Base
class User(Base):
__tablename__ = "users"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
email: Mapped[str] = mapped_column(String(255), unique=True, nullable=False, index=True)
password_hash: Mapped[str] = mapped_column(String(255), nullable=False)
full_name: Mapped[str] = mapped_column(String(255), nullable=False)
phone: Mapped[str | None] = mapped_column(String(50), nullable=True)
role: Mapped[str] = mapped_column(
String(50),
nullable=False,
comment="CS Rep, CS Manager, FM Dispatcher, Admin/Jerome, Admin/Wahab, Tech, CEO, Director",
)
active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
# relationships
assigned_tickets = relationship("Ticket", back_populates="assigned_technician", foreign_keys="Ticket.assigned_to")
timeline_entries = relationship("TicketTimeline", back_populates="user")
escalations = relationship("Escalation", back_populates="escalated_to_user", foreign_keys="Escalation.escalated_to")
def __repr__(self) -> str:
return f"<User {self.id}:{self.email} ({self.role})>"
+23
View File
@@ -0,0 +1,23 @@
"""WhatsApp log model for mock endpoint."""
from __future__ import annotations
from datetime import datetime
from sqlalchemy import DateTime, Integer, String, Text
from sqlalchemy.orm import Mapped, mapped_column
from app.core.database import Base
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)
ticket_id: Mapped[int | None] = mapped_column(Integer, nullable=True)
received_at: Mapped[datetime] = mapped_column(DateTime, nullable=False)
def __repr__(self) -> str:
return f"<WhatsAppLog {self.id}: {self.command[:50]}>"
View File
+61
View File
@@ -0,0 +1,61 @@
"""Authentication router — register, login, refresh, me."""
from __future__ import annotations
from typing import Annotated
from fastapi import APIRouter, Depends
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.core.security import get_current_user, require_roles
from app.models.user import User
from app.schemas.auth import (
LoginRequest,
RefreshRequest,
RegisterRequest,
TokenResponse,
UserOut,
)
from app.services import auth as auth_service
router = APIRouter(prefix="/api/auth", tags=["auth"])
@router.post("/register", response_model=UserOut, status_code=201)
async def register(
body: RegisterRequest,
db: Annotated[AsyncSession, Depends(get_db)],
) -> User:
return await auth_service.register(db, body)
@router.post("/login", response_model=TokenResponse)
async def login(
body: LoginRequest,
db: Annotated[AsyncSession, Depends(get_db)],
) -> TokenResponse:
access, refresh, _user = await auth_service.login(db, body.email, body.password)
return TokenResponse(access_token=access, refresh_token=refresh)
@router.post("/refresh", response_model=TokenResponse)
async def refresh(
body: RefreshRequest,
db: Annotated[AsyncSession, Depends(get_db)],
) -> TokenResponse:
access, refresh = await auth_service.refresh_access_token(db, body.refresh_token)
return TokenResponse(access_token=access, refresh_token=refresh)
@router.get("/me", response_model=UserOut)
async def me(current_user: Annotated[User, Depends(get_current_user)]) -> User:
return current_user
@router.get("/admin-only", response_model=UserOut)
async def admin_only(
current_user: Annotated[User, Depends(require_roles("Admin/Jerome", "Admin/Wahab"))],
) -> User:
"""Example RBAC-protected endpoint — only Admins can access."""
return current_user
+14
View File
@@ -0,0 +1,14 @@
"""Health-check endpoint."""
from __future__ import annotations
from fastapi import APIRouter
from app.schemas.health import HealthResponse
router = APIRouter(tags=["health"])
@router.get("/health", response_model=HealthResponse)
async def health_check() -> HealthResponse:
return HealthResponse()
+297
View File
@@ -0,0 +1,297 @@
"""Ticket CRUD endpoints, photo uploads, SLA checks, and category listing."""
from __future__ import annotations
import os
import uuid
from datetime import datetime
from pathlib import Path
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, Query, UploadFile, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import settings
from app.core.database import get_db
from app.core.security import get_current_user
from app.models.category import Category
from app.models.ticket import Ticket, TicketPhoto
from app.models.user import User
from app.schemas.ticket import (
CategoryOut,
CategoryTreeOut,
SLAStatusOut,
TicketBrief,
TicketCreate,
TicketListResponse,
TicketOut,
TicketPhotoOut,
TicketUpdate,
)
from app.services import ticket as ticket_service
from app.services.sla import get_sla_status
router = APIRouter(prefix="/api/tickets", tags=["tickets"])
# Ensure uploads directory exists
UPLOADS_DIR = settings.BASE_DIR / "uploads"
UPLOADS_DIR.mkdir(parents=True, exist_ok=True)
# ── Categories ──────────────────────────────────────────────────────
async def _build_category_tree(db: AsyncSession, parent_id: int | None = None) -> list[CategoryTreeOut]:
"""Build a nested category tree."""
result = await db.execute(
select(Category)
.where(Category.parent_id == parent_id)
.order_by(Category.name)
)
categories = result.scalars().all()
tree = []
for cat in categories:
children = await _build_category_tree(db, cat.id)
tree.append(CategoryTreeOut(
id=cat.id,
type=cat.type,
name=cat.name,
parent_id=cat.parent_id,
sla_urgency=cat.sla_urgency,
children=children,
))
return tree
@router.get("/categories", response_model=list[CategoryTreeOut])
async def list_categories(
db: Annotated[AsyncSession, Depends(get_db)],
type_filter: str | None = Query(None, alias="type"),
) -> list[CategoryTreeOut]:
"""Return the full category tree, optionally filtered by type (maintenance, cs, emergency)."""
if type_filter:
# Return flat list of top-level categories of the given type
result = await db.execute(
select(Category)
.where(Category.parent_id.is_(None), Category.type == type_filter)
.order_by(Category.name)
)
parents = result.scalars().all()
tree = []
for parent in parents:
children_result = await db.execute(
select(Category)
.where(Category.parent_id == parent.id)
.order_by(Category.name)
)
children = children_result.scalars().all()
tree.append(CategoryTreeOut(
id=parent.id,
type=parent.type,
name=parent.name,
parent_id=parent.parent_id,
sla_urgency=parent.sla_urgency,
children=[CategoryOut.model_validate(c) for c in children],
))
return tree
return await _build_category_tree(db)
@router.get("/categories/flat", response_model=list[CategoryOut])
async def list_categories_flat(
db: Annotated[AsyncSession, Depends(get_db)],
type_filter: str | None = Query(None, alias="type"),
) -> list[CategoryOut]:
"""Return a flat list of all categories (no nesting), optionally filtered by type."""
query = select(Category).order_by(Category.type, Category.name)
if type_filter:
query = query.where(Category.type == type_filter)
result = await db.execute(query)
categories = result.scalars().all()
return [CategoryOut.model_validate(c) for c in categories]
# ── Ticket CRUD ──────────────────────────────────────────────────────
@router.post("", response_model=TicketOut, status_code=status.HTTP_201_CREATED)
async def create_ticket(
body: TicketCreate,
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(get_current_user)],
) -> Ticket:
"""Create a new ticket. Auto-generates ticket number PAV-YYYY-NNNNN."""
ticket = await ticket_service.create_ticket(db, body.model_dump(exclude_none=True), user=current_user)
# Reload with relationships
ticket = await ticket_service.get_ticket(db, ticket.id)
sla = await get_sla_status(ticket)
ticket.sla_status = sla # type: ignore[attr-defined]
return ticket
@router.get("", response_model=TicketListResponse)
async def list_tickets(
db: Annotated[AsyncSession, Depends(get_db)],
page: int = Query(1, ge=1),
page_size: int = Query(50, ge=1, le=200),
status: str | None = Query(None),
priority: str | None = Query(None),
property: str | None = Query(None),
category_id: int | None = Query(None),
assigned_to: int | None = Query(None),
date_from: datetime | None = Query(None),
date_to: datetime | None = Query(None),
) -> TicketListResponse:
"""List tickets with optional filtering and pagination."""
tickets, total = await ticket_service.list_tickets(
db,
status_filter=status,
priority_filter=priority,
property_filter=property,
category_id=category_id,
assigned_to=assigned_to,
date_from=date_from,
date_to=date_to,
page=page,
page_size=page_size,
)
items = [TicketBrief.model_validate(t) for t in tickets]
return TicketListResponse(items=items, total=total, page=page, page_size=page_size)
@router.get("/{ticket_id}", response_model=TicketOut)
async def get_ticket(
ticket_id: int,
db: Annotated[AsyncSession, Depends(get_db)],
) -> Ticket:
"""Get a single ticket by ID with full details, timeline, photos, and SLA status."""
ticket = await ticket_service.get_ticket(db, ticket_id)
sla = await get_sla_status(ticket)
ticket.sla_status = sla # type: ignore[attr-defined]
return ticket
@router.patch("/{ticket_id}", response_model=TicketOut)
async def update_ticket(
ticket_id: int,
body: TicketUpdate,
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(get_current_user)],
) -> Ticket:
"""Update a ticket. Validates status transitions and logs timeline."""
ticket = await ticket_service.update_ticket(db, ticket_id, body.model_dump(exclude_none=True), user=current_user)
sla = await get_sla_status(ticket)
ticket.sla_status = sla # type: ignore[attr-defined]
return ticket
# ── Status Transitions (convenience endpoints) ───────────────────────
@router.post("/{ticket_id}/status", response_model=TicketOut)
async def change_ticket_status(
ticket_id: int,
body: dict,
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(get_current_user)],
) -> Ticket:
"""Change a ticket's status with optional note.
Request body:
```json
{"status": "Logged", "note": "Verified with customer"}
```
"""
status_val = body.get("status")
if not status_val:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="status is required")
update_data = {"status": status_val}
if "note" in body:
update_data["note"] = body["note"]
ticket = await ticket_service.update_ticket(db, ticket_id, update_data, user=current_user)
sla = await get_sla_status(ticket)
ticket.sla_status = sla # type: ignore[attr-defined]
return ticket
# ── SLA ──────────────────────────────────────────────────────────────
@router.get("/{ticket_id}/sla", response_model=SLAStatusOut)
async def check_sla(
ticket_id: int,
db: Annotated[AsyncSession, Depends(get_db)],
) -> dict:
"""Check SLA status for a ticket — response deadline, resolution deadline, breach flags."""
ticket = await ticket_service.get_ticket(db, ticket_id)
return await get_sla_status(ticket)
# ── Photo Uploads ────────────────────────────────────────────────────
ALLOWED_EXTENSIONS = {".jpg", ".jpeg", ".png", ".gif", ".webp"}
ALLOWED_MIME_TYPES = {"image/jpeg", "image/png", "image/gif", "image/webp"}
@router.post("/{ticket_id}/photos", response_model=list[TicketPhotoOut], status_code=status.HTTP_201_CREATED)
async def upload_photos(
ticket_id: int,
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(get_current_user)],
files: list[UploadFile],
is_before: bool = True,
) -> list[TicketPhoto]:
"""Upload photos for a ticket (before/after). Stores files under uploads/."""
# Verify ticket exists
await ticket_service.get_ticket(db, ticket_id)
# Validate all files before any write operations
for file in files:
if file.content_type not in ALLOWED_MIME_TYPES:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Unsupported content type '{file.content_type}'. Allowed: image/jpeg, image/png, image/gif, image/webp",
)
ext = Path(file.filename or "photo.jpg").suffix.lower() if file.filename else ".jpg"
if ext not in ALLOWED_EXTENSIONS:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Unsupported file extension '{ext}'. Allowed: .jpg, .jpeg, .png, .gif, .webp",
)
created_photos: list[TicketPhoto] = []
file_data: list[tuple[Path, bytes]] = []
for file in files:
# Generate unique filename
ext = Path(file.filename or "photo.jpg").suffix.lower() if file.filename else ".jpg"
unique_name = f"{uuid.uuid4().hex}{ext}"
file_path = UPLOADS_DIR / unique_name
content = await file.read()
file_data.append((file_path, content))
# Database record (no file write yet)
photo = TicketPhoto(
ticket_id=ticket_id,
photo_url=f"/uploads/{unique_name}",
is_before=is_before,
)
db.add(photo)
created_photos.append(photo)
await db.flush()
for p in created_photos:
await db.refresh(p)
# Write files to disk only after successful DB flush
for file_path, content in file_data:
file_path.write_bytes(content)
return created_photos
@router.get("/{ticket_id}/photos", response_model=list[TicketPhotoOut])
async def list_photos(
ticket_id: int,
db: Annotated[AsyncSession, Depends(get_db)],
) -> list[TicketPhoto]:
"""List all photos for a ticket."""
await ticket_service.get_ticket(db, ticket_id)
result = await db.execute(
select(TicketPhoto)
.where(TicketPhoto.ticket_id == ticket_id)
.order_by(TicketPhoto.id)
)
return list(result.scalars().all())
+45
View File
@@ -0,0 +1,45 @@
"""Mock WhatsApp endpoint for testing command parsing."""
from __future__ import annotations
from datetime import datetime, timezone
from typing import Annotated
from fastapi import APIRouter, Depends
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.models.whatsapp_log import WhatsAppLog
from app.schemas.whatsapp import MockWhatsAppLogEntry, MockWhatsAppRequest, MockWhatsAppResponse
router = APIRouter(prefix="/api/whatsapp", tags=["whatsapp"])
@router.post("/mock", response_model=MockWhatsAppResponse)
async def mock_whatsapp(
body: MockWhatsAppRequest,
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()
@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."""
result = await db.execute(
select(WhatsAppLog).order_by(WhatsAppLog.received_at.desc()).limit(limit)
)
return list(result.scalars().all())
View File
+39
View File
@@ -0,0 +1,39 @@
"""Pydantic schemas for authentication endpoints."""
from __future__ import annotations
from pydantic import BaseModel, EmailStr
class RegisterRequest(BaseModel):
email: str
password: str
full_name: str
phone: str | None = None
role: str = "CS Rep"
class LoginRequest(BaseModel):
email: str
password: str
class TokenResponse(BaseModel):
access_token: str
refresh_token: str
token_type: str = "bearer"
class RefreshRequest(BaseModel):
refresh_token: str
class UserOut(BaseModel):
id: int
email: str
full_name: str
phone: str | None
role: str
active: bool
model_config = {"from_attributes": True}
+8
View File
@@ -0,0 +1,8 @@
"""Health-check schema."""
from pydantic import BaseModel
class HealthResponse(BaseModel):
status: str = "ok"
app: str = "Denya OneCare"
+117
View File
@@ -0,0 +1,117 @@
"""Pydantic schemas for ticket CRUD, timeline, photos, and SLA."""
from __future__ import annotations
from datetime import datetime
from decimal import Decimal
from pydantic import BaseModel, Field
# ── Category ─────────────────────────────────────────────────────────
class CategoryOut(BaseModel):
id: int
type: str
name: str
parent_id: int | None = None
sla_urgency: str | None = None
model_config = {"from_attributes": True}
class CategoryTreeOut(CategoryOut):
children: list[CategoryTreeOut] = []
# ── Ticket ───────────────────────────────────────────────────────────
class TicketCreate(BaseModel):
unit_id: int | None = None
category_id: int | None = None
priority: str | None = None # urgent, high, medium, low
reporter: str | None = None
reported_via: str | None = None # whatsapp, phone, walk-in, qr, agent
description: str | None = None
assigned_to: int | None = None
class TicketUpdate(BaseModel):
status: str | None = None
unit_id: int | None = None
category_id: int | None = None
priority: str | None = None
reporter: str | None = None
reported_via: str | None = None
description: str | None = None
assigned_to: int | None = None
eta: datetime | None = None
cost: Decimal | None = None
parts_used: str | None = None
class TicketTimelineOut(BaseModel):
id: int
ticket_id: int
from_status: str | None = None
to_status: str | None = None
note: str | None = None
user_id: int | None = None
created_at: datetime
model_config = {"from_attributes": True}
class TicketPhotoOut(BaseModel):
id: int
ticket_id: int
photo_url: str
is_before: bool
model_config = {"from_attributes": True}
class TicketBrief(BaseModel):
"""Summary view for list endpoints."""
id: int
ticket_number: str
status: str
priority: str | None = None
unit_id: int | None = None
category_id: int | None = None
assigned_to: int | None = None
reporter: str | None = None
description: str | None = None
sla_deadline: datetime | None = None
reopen_count: int = 0
created_at: datetime
updated_at: datetime
model_config = {"from_attributes": True}
class TicketOut(TicketBrief):
"""Full ticket detail with relations."""
closed_at: datetime | None = None
eta: datetime | None = None
cost: Decimal | None = None
parts_used: str | None = None
customer_rating: int | None = None
timeline: list[TicketTimelineOut] = []
photos: list[TicketPhotoOut] = []
sla_status: dict | None = None
class TicketListResponse(BaseModel):
items: list[TicketBrief]
total: int
page: int
page_size: int
# ── SLA ──────────────────────────────────────────────────────────────
class SLAStatusOut(BaseModel):
priority: str | None = None
response_deadline: str | None = None
resolution_deadline: str | None = None
response_breached: bool = False
resolution_breached: bool = False
current_status: str | None = None
+28
View File
@@ -0,0 +1,28 @@
"""Pydantic schemas for mock WhatsApp endpoint."""
from __future__ import annotations
from datetime import datetime
from pydantic import BaseModel
class MockWhatsAppRequest(BaseModel):
command: str
from_number: str | None = None
ticket_id: int | None = None
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}
View File
+74
View File
@@ -0,0 +1,74 @@
"""Authentication service — register, login, refresh."""
from __future__ import annotations
from fastapi import HTTPException, status
from jose import JWTError, jwt
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import settings
from app.core.security import (
create_access_token,
create_refresh_token,
decode_token,
hash_password,
verify_password,
)
from app.models.user import User
from app.schemas.auth import RegisterRequest
async def register(db: AsyncSession, body: RegisterRequest) -> User:
"""Create a new user. Raises 409 if email already exists."""
result = await db.execute(select(User).where(User.email == body.email))
if result.scalar_one_or_none():
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Email already registered")
user = User(
email=body.email,
password_hash=hash_password(body.password),
full_name=body.full_name,
phone=body.phone,
role=body.role,
)
db.add(user)
await db.flush()
await db.refresh(user)
return user
async def login(db: AsyncSession, email: str, password: str) -> tuple[str, str, User]:
"""Authenticate and return (access_token, refresh_token, user)."""
result = await db.execute(select(User).where(User.email == email))
user = result.scalar_one_or_none()
if user is None or not verify_password(password, user.password_hash):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid email or password")
if not user.active:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Account is inactive")
access_token = create_access_token({"sub": str(user.id)})
refresh_token = create_refresh_token({"sub": str(user.id)})
return access_token, refresh_token, user
async def refresh_access_token(db: AsyncSession, token: str) -> tuple[str, str]:
"""Validate a refresh token and issue a new token pair."""
try:
payload = decode_token(token)
if payload.get("type") != "refresh":
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token type")
except HTTPException:
raise
except Exception:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid refresh token")
user_id: int = int(payload["sub"])
result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
if user is None or not user.active:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found or inactive")
new_access = create_access_token({"sub": str(user.id)})
new_refresh = create_refresh_token({"sub": str(user.id)})
return new_access, new_refresh
+205
View File
@@ -0,0 +1,205 @@
"""Seed script — users, units, and categories.
Called on first startup via lifespan hook.
"""
from __future__ import annotations
import json
import logging
from pathlib import Path
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.security import hash_password
from app.models.unit import Unit
from app.models.user import User
from app.models.category import Category
logger = logging.getLogger(__name__)
# ── Seed user data (dicts to avoid module-level model instantiation) ─
SEED_USERS_DATA = [
{"email": "jerome@denya.com", "full_name": "Jerome Tabiri", "phone": "+233000000001", "role": "Admin/Jerome"},
{"email": "wahab@denya.com", "full_name": "Wahab", "phone": "+233000000002", "role": "Admin/Wahab"},
{"email": "bella@denya.com", "full_name": "Bella", "phone": "+233000000003", "role": "CS Rep"},
{"email": "akua@denya.com", "full_name": "Akua", "phone": "+233000000004", "role": "CS Manager"},
{"email": "mercy@denya.com", "full_name": "Mercy Duah", "phone": "+233000000005", "role": "CS Manager"},
{"email": "ama@denya.com", "full_name": "Ama", "phone": "+233000000006", "role": "CS Rep"},
{"email": "nicholas@denya.com", "full_name": "Nicholas", "phone": "+233000000007", "role": "FM Dispatcher"},
{"email": "collins@denya.com", "full_name": "Collins", "phone": "+233000000008", "role": "FM Dispatcher"},
{"email": "prosper@denya.com", "full_name": "Prosper", "phone": "+233000000010", "role": "Tech"},
{"email": "sam@denya.com", "full_name": "Sam", "phone": "+233000000011", "role": "Tech"},
{"email": "steven@denya.com", "full_name": "Steven", "phone": "+233000000012", "role": "Tech"},
{"email": "junior@denya.com", "full_name": "Junior (Samuel)", "phone": "+233000000013", "role": "Tech"},
{"email": "francis@denya.com", "full_name": "Francis", "phone": "+233000000014", "role": "Tech"},
{"email": "desmond@denya.com", "full_name": "Desmond Afful", "phone": "+233000000015", "role": "Tech"},
{"email": "afful@denya.com", "full_name": "Afful", "phone": "+233000000016", "role": "Tech"},
{"email": "scott@denya.com", "full_name": "Scott Murray", "phone": "+233000000020", "role": "CEO"},
{"email": "director@denya.com", "full_name": "Director", "phone": "+233000000021", "role": "Director"},
]
async def seed_users(db: AsyncSession, default_password: str = "denya123") -> list[User]:
"""Insert seed users if they don't already exist."""
hashed = hash_password(default_password)
created: list[User] = []
for data in SEED_USERS_DATA:
result = await db.execute(select(User).where(User.email == data["email"]))
if result.scalar_one_or_none() is None:
user = User(
email=data["email"],
password_hash=hashed,
full_name=data["full_name"],
phone=data["phone"],
role=data["role"],
)
db.add(user)
created.append(user)
if created:
await db.flush()
for u in created:
await db.refresh(u)
logger.info("Seeded %d users", len(created))
else:
logger.info("All seed users already exist")
return created
async def seed_units(db: AsyncSession, json_path: str | Path | None = None) -> list[Unit]:
"""Insert units from *apartment_mapping.json*.
Falls back to a small built-in set if the file is not found.
"""
units_data: list[dict] = []
if json_path:
p = Path(json_path)
if p.exists():
with open(p) as f:
raw = json.load(f)
units_data = raw if isinstance(raw, list) else raw.get("units", [])
logger.info("Loaded %d units from %s", len(units_data), p)
if not units_data:
# Built-in fallback for Pavilion Accra
logger.warning("apartment_mapping.json not found; using built-in fallback")
for wing in ("East", "West"):
for floor in range(1, 11):
for num in range(1, 7):
code = f"{floor:02d}{num}{wing[0]}"
units_data.append({
"apartment_code": code,
"property": wing,
"building": f"Pavilion {wing}",
"floor": floor,
})
created: list[Unit] = []
for entry in units_data:
code = entry.get("apartment_code") or entry.get("unit") or ""
if not code:
continue
result = await db.execute(select(Unit).where(Unit.apartment_code == code))
if result.scalar_one_or_none() is None:
unit = Unit(
property=entry.get("property", "East"),
apartment_code=code,
building=entry.get("building"),
floor=entry.get("floor"),
)
db.add(unit)
created.append(unit)
if created:
await db.flush()
for u in created:
await db.refresh(u)
logger.info("Seeded %d units", len(created))
else:
logger.info("All units already exist")
return created
# ── Category taxonomy (PRD §7) ───────────────────────────────────────
# Top-level categories with sub-categories
SEED_CATEGORIES_DATA: list[dict] = [
# ── Maintenance ──────────────────────────────────────────────
{"type": "maintenance", "name": "Plumbing", "subs": ["WC not flushing", "Leaky tap", "Pipe burst", "Blocked drain", "Water heater"]},
{"type": "maintenance", "name": "Electrical", "subs": ["No power", "Tripped breaker", "Light fitting", "Socket repair", "Voltage issues"]},
{"type": "maintenance", "name": "HVAC", "subs": ["AC not cooling", "AC leaking", "Thermostat", "Fan not working"]},
{"type": "maintenance", "name": "Furniture", "subs": ["Broken bed", "Broken chair", "Broken table", "Wardrobe issue"]},
{"type": "maintenance", "name": "Appliances", "subs": ["Fridge", "Washing machine", "Microwave", "TV", "Kettle"]},
{"type": "maintenance", "name": "Cleaning", "subs": ["Deep clean requested", "Post-checkout turnover", "Common area cleaning"]},
{"type": "maintenance", "name": "Pest Control", "subs": ["Insects", "Rodents", "Fumigation"]},
{"type": "maintenance", "name": "Security", "subs": ["Lock broken", "Door not closing", "Window latch", "CCTV issue"]},
{"type": "maintenance", "name": "Internet", "subs": ["WiFi down", "Slow speed", "Router reset"]},
{"type": "maintenance", "name": "Structural", "subs": ["Wall crack", "Ceiling leak", "Floor tile", "Paint touch-up"]},
# ── Customer Service ─────────────────────────────────────────
{"type": "cs", "name": "Check-in", "subs": ["Early check-in", "Key handover", "Welcome instructions"]},
{"type": "cs", "name": "Check-out", "subs": ["Late checkout", "Key return", "Inspection"]},
{"type": "cs", "name": "Housekeeping", "subs": ["Mid-stay cleaning", "Linen change", "Restocking"]},
{"type": "cs", "name": "Lost Property", "subs": ["Guest left items behind"]},
{"type": "cs", "name": "Billing", "subs": ["Invoice question", "Payment issue", "Deposit query"]},
{"type": "cs", "name": "Staff Behaviour", "subs": ["Staff conduct feedback"]},
# ── Emergency ────────────────────────────────────────────────
{"type": "emergency", "name": "Fire", "subs": ["Smoke detected", "Fire alarm", "Sprinkler issue"], "sla_urgency": "urgent"},
{"type": "emergency", "name": "Flood", "subs": ["Major water leak", "Burst pipe", "Overflowing"], "sla_urgency": "urgent"},
{"type": "emergency", "name": "Gas Leak", "subs": ["Gas smell", "Suspected leak"], "sla_urgency": "urgent"},
{"type": "emergency", "name": "Electrical Hazard", "subs": ["Sparking", "Exposed wires", "Power outage multiple units"], "sla_urgency": "urgent"},
]
async def seed_categories(db: AsyncSession) -> list[Category]:
"""Seed the categories table with the full PRD §7 taxonomy."""
created: list[Category] = []
for group in SEED_CATEGORIES_DATA:
# Check if parent category exists
result = await db.execute(
select(Category).where(
Category.type == group["type"],
Category.name == group["name"],
Category.parent_id.is_(None),
)
)
parent = result.scalar_one_or_none()
if parent is None:
parent = Category(
type=group["type"],
name=group["name"],
parent_id=None,
sla_urgency=group.get("sla_urgency"),
)
db.add(parent)
await db.flush()
created.append(parent)
# Seed sub-categories
for sub_name in group["subs"]:
result = await db.execute(
select(Category).where(
Category.type == group["type"],
Category.name == sub_name,
Category.parent_id == parent.id,
)
)
existing = result.scalar_one_or_none()
if existing is None:
sub = Category(
type=group["type"],
name=sub_name,
parent_id=parent.id,
sla_urgency=None,
)
db.add(sub)
created.append(sub)
if created:
await db.flush()
for c in created:
await db.refresh(c)
logger.info("Seeded %d categories", len(created))
else:
logger.info("All seed categories already exist")
return created
+100
View File
@@ -0,0 +1,100 @@
"""SLA engine — priority-based deadlines, escalation triggers, and status checks."""
from __future__ import annotations
from datetime import datetime, timedelta, timezone
from app.models.ticket import Ticket
# ── Priority SLA Targets (from PRD §11) ──────────────────────────────
# Each entry: (response_window_minutes, resolution_window_hours)
SLA_TARGETS: dict[str, tuple[int, int]] = {
"urgent": (15, 4), # respond 10-15min, resolve 2-4h → use upper bound
"high": (30, 24), # respond 30min, resolve 8-24h
"medium": (240, 72), # respond 4h (240min), resolve 2-3d (72h)
"low": (1440, 168), # respond 1d (1440min), resolve 5-7d (168h)
}
def compute_sla_deadline(priority: str, created_at: datetime | None = None) -> datetime:
"""Return the resolution deadline for a given priority.
Uses the upper-bound resolution target.
"""
now = created_at or datetime.now(timezone.utc)
_response_min, resolution_hours = SLA_TARGETS.get(priority, (240, 72))
return now + timedelta(hours=resolution_hours)
def get_sla_response_window(priority: str) -> timedelta:
"""Return the response-window timedelta for a given priority."""
minutes, _hours = SLA_TARGETS.get(priority, (240, 72))
return timedelta(minutes=minutes)
def get_sla_resolution_window(priority: str) -> timedelta:
"""Return the resolution-window timedelta for a given priority."""
_minutes, hours = SLA_TARGETS.get(priority, (240, 72))
return timedelta(hours=hours)
# ── Escalation detection ─────────────────────────────────────────────
def should_escalate_on_response(ticket: Ticket) -> bool:
"""Return True if the ticket's response SLA has been breached.
A ticket is in breach if its status is still in the pre-acknowledgement
states (New, Logged, Triage, Assigned) beyond the response window.
"""
if not ticket.priority or ticket.created_at is None:
return False
pre_ack_states = {"New", "Logged", "Triage", "Assigned"}
if ticket.status not in pre_ack_states:
return False
response_window = get_sla_response_window(ticket.priority)
deadline = ticket.created_at.replace(tzinfo=timezone.utc) + response_window
return datetime.now(timezone.utc) > deadline
def is_sla_breached(ticket: Ticket) -> bool:
"""Return True if the ticket's resolution SLA deadline has passed."""
if ticket.sla_deadline is None or ticket.status in ("Closed", "Completed"):
return False
deadline = ticket.sla_deadline
if deadline.tzinfo is None:
deadline = deadline.replace(tzinfo=timezone.utc)
return datetime.now(timezone.utc) > deadline
# ── API helper ───────────────────────────────────────────────────────
async def get_sla_status(ticket: Ticket) -> dict:
"""Return a structured SLA status dict for a ticket."""
now = datetime.now(timezone.utc)
created = ticket.created_at
if created and created.tzinfo is None:
created = created.replace(tzinfo=timezone.utc)
response_deadline = None
resolution_deadline = ticket.sla_deadline
if ticket.priority and created:
response_deadline = created + get_sla_response_window(ticket.priority)
response_breached = False
if response_deadline and ticket.status in {"New", "Logged", "Triage", "Assigned"}:
response_breached = now > response_deadline
resolution_breached = False
if resolution_deadline:
rd = resolution_deadline
if rd.tzinfo is None:
rd = rd.replace(tzinfo=timezone.utc)
resolution_breached = now > rd if ticket.status not in ("Closed", "Completed") else False
return {
"priority": ticket.priority,
"response_deadline": response_deadline.isoformat() if response_deadline else None,
"resolution_deadline": resolution_deadline.isoformat() if resolution_deadline else None,
"response_breached": response_breached,
"resolution_breached": resolution_breached,
"current_status": ticket.status,
}
+292
View File
@@ -0,0 +1,292 @@
"""Ticket business logic — CRUD, status transitions, SLA enforcement."""
from __future__ import annotations
from datetime import datetime, timezone
from typing import Any
from fastapi import HTTPException, status
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.models.ticket import Escalation, Ticket, TicketTimeline
from app.models.unit import Unit
from app.models.user import User
from app.services.sla import compute_sla_deadline
# ── Status Transition Map ────────────────────────────────────────────
# Keys: current status → list of valid next statuses
VALID_TRANSITIONS: dict[str, list[str]] = {
"New": ["Logged"],
"Logged": ["Triage", "Closed"],
"Triage": ["Assigned", "Escalated"],
"Assigned": ["Accepted", "Triage"],
"Accepted": ["Travelling", "Triage"],
"Travelling": ["On Site", "Triage"],
"On Site": ["In Progress", "Triage"],
"In Progress": ["Waiting Parts", "Escalated", "Completed"],
"Waiting Parts": ["In Progress", "Escalated"],
"Escalated": ["Triage", "In Progress", "Completed", "Closed"],
"Completed": ["On-Field Verification", "In Progress"],
"On-Field Verification": ["Wahab Review", "Completed", "Closed"],
"Wahab Review": ["Closed", "On-Field Verification"],
"Closed": ["Reopened"],
"Reopened": ["Triage", "Logged"],
}
REOPEN_WINDOW_DAYS = 7
SLA_ACK_USER = "Ama"
# ── Helpers ──────────────────────────────────────────────────────────
async def _generate_ticket_number(db: AsyncSession) -> str:
"""Generate the next ticket number in PAV-YYYY-NNNNN format."""
year = datetime.now(timezone.utc).year
prefix = f"PAV-{year}-"
# Count existing tickets this year
result = await db.execute(
select(func.count(Ticket.id)).where(Ticket.ticket_number.like(f"{prefix}%"))
)
count = result.scalar() or 0
return f"{prefix}{count + 1:05d}"
async def _log_status_change(
db: AsyncSession,
ticket_id: int,
from_status: str | None,
to_status: str | None,
note: str | None = None,
user_id: int | None = None,
) -> TicketTimeline:
"""Create a timeline entry for a status change."""
entry = TicketTimeline(
ticket_id=ticket_id,
from_status=from_status,
to_status=to_status,
note=note,
user_id=user_id,
)
db.add(entry)
await db.flush()
return entry
async def _get_ticket_or_404(db: AsyncSession, ticket_id: int) -> Ticket:
"""Fetch a ticket by ID or raise 404."""
result = await db.execute(
select(Ticket)
.options(
selectinload(Ticket.timeline),
selectinload(Ticket.photos),
selectinload(Ticket.assigned_technician),
selectinload(Ticket.unit),
selectinload(Ticket.category),
)
.where(Ticket.id == ticket_id)
)
ticket = result.scalar_one_or_none()
if ticket is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Ticket not found")
return ticket
# ── CRUD ─────────────────────────────────────────────────────────────
async def create_ticket(
db: AsyncSession,
data: dict[str, Any],
user: User | None = None,
) -> Ticket:
"""Create a new ticket with auto-numbering and SLA deadline."""
ticket_number = await _generate_ticket_number(db)
priority = data.get("priority")
sla_deadline = compute_sla_deadline(priority) if priority else None
ticket = Ticket(
ticket_number=ticket_number,
status="New",
unit_id=data.get("unit_id"),
category_id=data.get("category_id"),
priority=priority,
reporter=data.get("reporter"),
reported_via=data.get("reported_via"),
description=data.get("description"),
assigned_to=data.get("assigned_to"),
sla_deadline=sla_deadline,
)
db.add(ticket)
await db.flush()
await db.refresh(ticket)
# Log initial creation
await _log_status_change(
db,
ticket.id,
from_status=None,
to_status="New",
note="Ticket created",
user_id=user.id if user else None,
)
# Auto-advance New → Logged (creation is also the logging step)
ticket.status = "Logged"
await _log_status_change(
db,
ticket.id,
from_status="New",
to_status="Logged",
note="Ticket logged and numbered",
user_id=user.id if user else None,
)
await db.flush()
await db.refresh(ticket)
return ticket
async def get_ticket(db: AsyncSession, ticket_id: int) -> Ticket:
"""Get a single ticket with full details."""
return await _get_ticket_or_404(db, ticket_id)
async def list_tickets(
db: AsyncSession,
*,
status_filter: str | None = None,
priority_filter: str | None = None,
property_filter: str | None = None,
category_id: int | None = None,
assigned_to: int | None = None,
date_from: datetime | None = None,
date_to: datetime | None = None,
page: int = 1,
page_size: int = 50,
) -> tuple[list[Ticket], int]:
"""List tickets with optional filters. Returns (tickets, total_count)."""
query = select(Ticket)
count_query = select(func.count(Ticket.id))
if status_filter:
query = query.where(Ticket.status == status_filter)
count_query = count_query.where(Ticket.status == status_filter)
if priority_filter:
query = query.where(Ticket.priority == priority_filter)
count_query = count_query.where(Ticket.priority == priority_filter)
if property_filter:
# Join unit to filter by property
query = query.join(Ticket.unit).where(Unit.property == property_filter)
count_query = count_query.join(Ticket.unit).where(Unit.property == property_filter)
if category_id:
query = query.where(Ticket.category_id == category_id)
count_query = count_query.where(Ticket.category_id == category_id)
if assigned_to:
query = query.where(Ticket.assigned_to == assigned_to)
count_query = count_query.where(Ticket.assigned_to == assigned_to)
if date_from:
query = query.where(Ticket.created_at >= date_from)
count_query = count_query.where(Ticket.created_at >= date_from)
if date_to:
query = query.where(Ticket.created_at <= date_to)
count_query = count_query.where(Ticket.created_at <= date_to)
# Count total
total_result = await db.execute(count_query)
total = total_result.scalar() or 0
# Paginate
offset = (page - 1) * page_size
query = query.order_by(Ticket.created_at.desc()).offset(offset).limit(page_size)
result = await db.execute(query)
tickets = list(result.scalars().all())
return tickets, total
async def update_ticket(
db: AsyncSession,
ticket_id: int,
data: dict[str, Any],
user: User | None = None,
) -> Ticket:
"""Update a ticket. Status changes are validated and logged."""
ticket = await _get_ticket_or_404(db, ticket_id)
# Handle status transitions separately
new_status = data.get("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:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Invalid status transition: {old_status}{new_status}. "
f"Valid targets: {valid_targets}",
)
# Handle special logic for reopening
if new_status == "Reopened":
if old_status != "Closed":
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Can only reopen a Closed ticket",
)
if ticket.closed_at:
closed_at = ticket.closed_at
if closed_at.tzinfo is None:
closed_at = closed_at.replace(tzinfo=timezone.utc)
days_since_close = (datetime.now(timezone.utc) - closed_at).days
if days_since_close > REOPEN_WINDOW_DAYS:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Cannot reopen: ticket closed {days_since_close} days ago "
f"(max {REOPEN_WINDOW_DAYS} days)",
)
# Auto-escalate to Ama
ama_result = await db.execute(
select(User).where(User.full_name == SLA_ACK_USER)
)
ama_user = ama_result.scalar_one_or_none()
ticket.reopen_count += 1
ticket.closed_at = None
if ama_user:
escalation = Escalation(
ticket_id=ticket.id,
escalated_to=ama_user.id,
reason=f"Auto-escalation: ticket reopened (reopen #{ticket.reopen_count})",
)
db.add(escalation)
# Handle closing
if new_status == "Closed":
ticket.closed_at = datetime.now(timezone.utc)
# Log the transition
await _log_status_change(
db,
ticket.id,
from_status=old_status,
to_status=new_status,
note=data.get("note"),
user_id=user.id if user else None,
)
ticket.status = new_status
# Update other fields
for field in ("unit_id", "category_id", "priority", "reporter", "reported_via",
"description", "assigned_to", "eta", "cost", "parts_used"):
if field in data:
setattr(ticket, field, data[field])
# Recompute SLA if priority changed
if data.get("priority"):
ticket.sla_deadline = compute_sla_deadline(data["priority"])
await db.flush()
await db.refresh(ticket)
return ticket
+17
View File
@@ -0,0 +1,17 @@
services:
app:
build: .
container_name: denya-onecare
ports:
- "8000:8000"
environment:
- DATABASE_URL=sqlite+aiosqlite:///./data/denya_onecare.db
- SECRET_KEY=change-me-in-production
- CORS_ORIGINS=*
- DEBUG=false
volumes:
- app-data:/app/data
restart: unless-stopped
volumes:
app-data:
+29
View File
@@ -0,0 +1,29 @@
[project]
name = "denya-onecare"
version = "0.1.0"
description = "Denya OneCare - Centralized issue tracking for Pavilion Accra"
requires-python = ">=3.11"
dependencies = [
"fastapi[standard]>=0.115.0",
"uvicorn[standard]>=0.30.0",
"sqlalchemy>=2.0.0",
"alembic>=1.13.0",
"pydantic-settings>=2.5.0",
"python-jose[cryptography]>=3.3.0",
"bcrypt>=4.0.0",
"python-multipart>=0.0.9",
"aiosqlite>=0.20.0",
]
[build-system]
requires = ["setuptools>=64.0"]
build-backend = "setuptools.build_meta"
[tool.setuptools.packages.find]
include = ["app*"]
[project.optional-dependencies]
dev = [
"pytest>=8.0",
"httpx>=0.27.0",
]