Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
344d555529 | ||
|
|
1120081760 | ||
|
|
57b18a27e3 | ||
|
|
a27b1856cc | ||
|
|
2de3c8514c | ||
|
|
31ae78ec07 | ||
|
|
65622de7a6 | ||
|
|
06bcdb0392 | ||
|
|
955b59945c | ||
|
|
a8745c79c9 | ||
|
|
4b5d3e4ac1 | ||
|
|
1677498a8e | ||
|
|
32a4c50b23 |
+11
@@ -0,0 +1,11 @@
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*.db
|
||||
*.sqlite3
|
||||
.env
|
||||
.venv/
|
||||
uploads/
|
||||
test_*.py
|
||||
venv/
|
||||
*.egg-info/
|
||||
dist/
|
||||
@@ -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.
|
||||
+23
@@ -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
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
Generic single-database configuration with an async dbapi.
|
||||
@@ -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()
|
||||
@@ -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 ###
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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
@@ -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)
|
||||
@@ -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
|
||||
@@ -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}>"
|
||||
@@ -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}>"
|
||||
@@ -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}>"
|
||||
@@ -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})>"
|
||||
@@ -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]}>"
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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}
|
||||
@@ -0,0 +1,8 @@
|
||||
"""Health-check schema."""
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class HealthResponse(BaseModel):
|
||||
status: str = "ok"
|
||||
app: str = "Denya OneCare"
|
||||
@@ -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
|
||||
@@ -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}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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:
|
||||
@@ -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",
|
||||
]
|
||||
Reference in New Issue
Block a user