6 Commits
34 changed files with 1405 additions and 0 deletions
+9
View File
@@ -0,0 +1,9 @@
__pycache__/
*.py[cod]
*.db
*.sqlite3
.env
.venv/
venv/
*.egg-info/
dist/
+70
View File
@@ -0,0 +1,70 @@
# 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)
└── routers/ # FastAPI route handlers
alembic/ # Database migrations
```
## Commands
- `alembic upgrade head` — apply migrations
- `alembic revision --autogenerate -m "msg"` — new migration
## Seed data
Users and units 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
## Key API endpoints
| 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 |
## 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
## Database
SQLite via aiosqlite with async SQLAlchemy 2.0. Alembic for migrations.
Tables: users, units, categories, tickets, ticket_timeline, ticket_photos, escalations, whatsapp_log
## Maintaining this file
Keep this file for knowledge useful to almost every future agent session in this project.
Do not repeat what the codebase already shows; point to the authoritative file or command instead.
Prefer rewriting or pruning existing entries over appending new ones.
When updating this file, preserve this bar for all agents and keep entries concise.
Symlink
+1
View File
@@ -0,0 +1 @@
AGENTS.md
+23
View File
@@ -0,0 +1,23 @@
FROM python:3.12-slim
WORKDIR /app
# Install system dependencies
RUN apt-get update && apt-get install -y --no-install-recommends \
gcc \
&& rm -rf /var/lib/apt/lists/*
# Copy project files
COPY pyproject.toml .
COPY alembic.ini .
COPY alembic/ alembic/
COPY app/ app/
# Install Python dependencies
RUN pip install --no-cache-dir .
# Expose port
EXPOSE 8000
# Run with uvicorn
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
+149
View File
@@ -0,0 +1,149 @@
# A generic, single database configuration.
[alembic]
# path to migration scripts.
# this is typically a path given in POSIX (e.g. forward slashes)
# format, relative to the token %(here)s which refers to the location of this
# ini file
script_location = %(here)s/alembic
# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
# Uncomment the line below if you want the files to be prepended with date and time
# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file
# for all available tokens
# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
# Or organize into date-based subdirectories (requires recursive_version_locations = true)
# file_template = %%(year)d/%%(month).2d/%%(day).2d_%%(hour).2d%%(minute).2d_%%(second).2d_%%(rev)s_%%(slug)s
# sys.path path, will be prepended to sys.path if present.
# defaults to the current working directory. for multiple paths, the path separator
# is defined by "path_separator" below.
prepend_sys_path = .
# timezone to use when rendering the date within the migration file
# as well as the filename.
# If specified, requires the tzdata library which can be installed by adding
# `alembic[tz]` to the pip requirements.
# string value is passed to ZoneInfo()
# leave blank for localtime
# timezone =
# max length of characters to apply to the "slug" field
# truncate_slug_length = 40
# set to 'true' to run the environment during
# the 'revision' command, regardless of autogenerate
# revision_environment = false
# set to 'true' to allow .pyc and .pyo files without
# a source .py file to be detected as revisions in the
# versions/ directory
# sourceless = false
# version location specification; This defaults
# to <script_location>/versions. When using multiple version
# directories, initial revisions must be specified with --version-path.
# The path separator used here should be the separator specified by "path_separator"
# below.
# version_locations = %(here)s/bar:%(here)s/bat:%(here)s/alembic/versions
# path_separator; This indicates what character is used to split lists of file
# paths, including version_locations and prepend_sys_path within configparser
# files such as alembic.ini.
# The default rendered in new alembic.ini files is "os", which uses os.pathsep
# to provide os-dependent path splitting.
#
# Note that in order to support legacy alembic.ini files, this default does NOT
# take place if path_separator is not present in alembic.ini. If this
# option is omitted entirely, fallback logic is as follows:
#
# 1. Parsing of the version_locations option falls back to using the legacy
# "version_path_separator" key, which if absent then falls back to the legacy
# behavior of splitting on spaces and/or commas.
# 2. Parsing of the prepend_sys_path option falls back to the legacy
# behavior of splitting on spaces, commas, or colons.
#
# Valid values for path_separator are:
#
# path_separator = :
# path_separator = ;
# path_separator = space
# path_separator = newline
#
# Use os.pathsep. Default configuration used for new projects.
path_separator = os
# set to 'true' to search source files recursively
# in each "version_locations" directory
# new in Alembic version 1.10
# recursive_version_locations = false
# the output encoding used when revision files
# are written from script.py.mako
# output_encoding = utf-8
# database URL. This is consumed by the user-maintained env.py script only.
# other means of configuring database URLs may be customized within the env.py
# file.
sqlalchemy.url = driver://user:pass@localhost/dbname
[post_write_hooks]
# post_write_hooks defines scripts or Python functions that are run
# on newly generated revision scripts. See the documentation for further
# detail and examples
# format using "black" - use the console_scripts runner, against the "black" entrypoint
# hooks = black
# black.type = console_scripts
# black.entrypoint = black
# black.options = -l 79 REVISION_SCRIPT_FILENAME
# lint with attempts to fix using "ruff" - use the module runner, against the "ruff" module
# hooks = ruff
# ruff.type = module
# ruff.module = ruff
# ruff.options = check --fix REVISION_SCRIPT_FILENAME
# Alternatively, use the exec runner to execute a binary found on your PATH
# hooks = ruff
# ruff.type = exec
# ruff.executable = ruff
# ruff.options = check --fix REVISION_SCRIPT_FILENAME
# Logging configuration. This is also consumed by the user-maintained
# env.py script only.
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARNING
handlers = console
qualname =
[logger_sqlalchemy]
level = WARNING
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S
+1
View File
@@ -0,0 +1 @@
Generic single-database configuration with an async dbapi.
+70
View File
@@ -0,0 +1,70 @@
import asyncio
from logging.config import fileConfig
from sqlalchemy import pool
from sqlalchemy.engine import Connection
from sqlalchemy.ext.asyncio import async_engine_from_config
from alembic import context
# Alembic Config object
config = context.config
# Set up logging
if config.config_file_name is not None:
fileConfig(config.config_file_name)
# ── Import all models so Alembic can detect them ─────────────────────
from app.core.database import Base # noqa: E402
from app.models.user import User # noqa: E402, F401
from app.models.unit import Unit # noqa: E402, F401
from app.models.category import Category # noqa: E402, F401
from app.models.ticket import Ticket, TicketTimeline, TicketPhoto, Escalation # noqa: E402, F401
from app.models.whatsapp_log import WhatsAppLog # noqa: E402, F401
target_metadata = Base.metadata
# ── Database URL from our settings ──────────────────────────────────
from app.core.config import settings as app_settings # noqa: E402
config.set_main_option("sqlalchemy.url", app_settings.DATABASE_URL)
def run_migrations_offline() -> None:
"""Run migrations in 'offline' mode."""
url = config.get_main_option("sqlalchemy.url")
context.configure(
url=url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
)
with context.begin_transaction():
context.run_migrations()
def do_run_migrations(connection: Connection) -> None:
context.configure(connection=connection, target_metadata=target_metadata)
with context.begin_transaction():
context.run_migrations()
async def run_async_migrations() -> None:
connectable = async_engine_from_config(
config.get_section(config.config_ini_section, {}),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
async with connectable.connect() as connection:
await connection.run_sync(do_run_migrations)
await connectable.dispose()
def run_migrations_online() -> None:
asyncio.run(run_async_migrations())
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()
+28
View File
@@ -0,0 +1,28 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision: str = ${repr(up_revision)}
down_revision: Union[str, Sequence[str], None] = ${repr(down_revision)}
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
def upgrade() -> None:
"""Upgrade schema."""
${upgrades if upgrades else "pass"}
def downgrade() -> None:
"""Downgrade schema."""
${downgrades if downgrades else "pass"}
@@ -0,0 +1,135 @@
"""initial schema
Revision ID: 795c6b95f637
Revises:
Create Date: 2026-07-23 12:01:33.135357
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '795c6b95f637'
down_revision: Union[str, Sequence[str], None] = None
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('categories',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('type', sa.String(length=20), nullable=False, comment='maintenance, cs, emergency'),
sa.Column('name', sa.String(length=100), nullable=False),
sa.Column('parent_id', sa.Integer(), nullable=True),
sa.Column('sla_urgency', sa.String(length=10), nullable=True, comment='urgent, high, medium, low'),
sa.ForeignKeyConstraint(['parent_id'], ['categories.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_table('units',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('property', sa.String(length=10), nullable=False, comment='East or West'),
sa.Column('apartment_code', sa.String(length=20), nullable=False),
sa.Column('building', sa.String(length=100), nullable=True),
sa.Column('floor', sa.Integer(), nullable=True),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_units_apartment_code'), 'units', ['apartment_code'], unique=True)
op.create_table('users',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('email', sa.String(length=255), nullable=False),
sa.Column('password_hash', sa.String(length=255), nullable=False),
sa.Column('full_name', sa.String(length=255), nullable=False),
sa.Column('phone', sa.String(length=50), nullable=True),
sa.Column('role', sa.String(length=50), nullable=False, comment='CS Rep, CS Manager, FM Dispatcher, Admin/Jerome, Admin/Wahab, Tech, CEO, Director'),
sa.Column('active', sa.Boolean(), nullable=False),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_users_email'), 'users', ['email'], unique=True)
op.create_table('whatsapp_log',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('command', sa.Text(), nullable=False),
sa.Column('from_number', sa.String(length=50), nullable=True),
sa.Column('ticket_id', sa.Integer(), nullable=True),
sa.Column('received_at', sa.DateTime(), nullable=False),
sa.PrimaryKeyConstraint('id')
)
op.create_table('tickets',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('ticket_number', sa.String(length=20), nullable=False, comment='Format: PAV-YYYY-NNNNN'),
sa.Column('status', sa.String(length=30), nullable=False, comment='New, Logged, Triage, Assigned, Accepted, Travelling, On Site, In Progress, Waiting Parts, Escalated, Completed, On-Field Verification, Wahab Review, Closed, Reopened'),
sa.Column('unit_id', sa.Integer(), nullable=True),
sa.Column('category_id', sa.Integer(), nullable=True),
sa.Column('priority', sa.String(length=10), nullable=True, comment='urgent, high, medium, low'),
sa.Column('reporter', sa.String(length=255), nullable=True),
sa.Column('reported_via', sa.String(length=20), nullable=True, comment='whatsapp, phone, walk-in, qr, agent'),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('assigned_to', sa.Integer(), nullable=True),
sa.Column('sla_deadline', sa.DateTime(), nullable=True),
sa.Column('eta', sa.DateTime(), nullable=True),
sa.Column('cost', sa.Numeric(precision=10, scale=2), nullable=True),
sa.Column('parts_used', sa.Text(), nullable=True),
sa.Column('customer_rating', sa.Integer(), nullable=True),
sa.Column('reopen_count', sa.Integer(), nullable=False),
sa.Column('closed_at', sa.DateTime(), nullable=True),
sa.Column('created_at', sa.DateTime(), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
sa.Column('updated_at', sa.DateTime(), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
sa.ForeignKeyConstraint(['assigned_to'], ['users.id'], ),
sa.ForeignKeyConstraint(['category_id'], ['categories.id'], ),
sa.ForeignKeyConstraint(['unit_id'], ['units.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_tickets_ticket_number'), 'tickets', ['ticket_number'], unique=True)
op.create_table('escalations',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('ticket_id', sa.Integer(), nullable=False),
sa.Column('escalated_to', sa.Integer(), nullable=False),
sa.Column('reason', sa.Text(), nullable=True),
sa.Column('resolved_at', sa.DateTime(), nullable=True),
sa.Column('created_at', sa.DateTime(), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
sa.ForeignKeyConstraint(['escalated_to'], ['users.id'], ),
sa.ForeignKeyConstraint(['ticket_id'], ['tickets.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_table('ticket_photos',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('ticket_id', sa.Integer(), nullable=False),
sa.Column('photo_url', sa.String(length=500), nullable=False),
sa.Column('is_before', sa.Boolean(), nullable=False),
sa.ForeignKeyConstraint(['ticket_id'], ['tickets.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_table('ticket_timeline',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('ticket_id', sa.Integer(), nullable=False),
sa.Column('from_status', sa.String(length=30), nullable=True),
sa.Column('to_status', sa.String(length=30), nullable=True),
sa.Column('note', sa.Text(), nullable=True),
sa.Column('user_id', sa.Integer(), nullable=True),
sa.Column('created_at', sa.DateTime(), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
sa.ForeignKeyConstraint(['ticket_id'], ['tickets.id'], ),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
sa.PrimaryKeyConstraint('id')
)
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table('ticket_timeline')
op.drop_table('ticket_photos')
op.drop_table('escalations')
op.drop_index(op.f('ix_tickets_ticket_number'), table_name='tickets')
op.drop_table('tickets')
op.drop_table('whatsapp_log')
op.drop_index(op.f('ix_users_email'), table_name='users')
op.drop_table('users')
op.drop_index(op.f('ix_units_apartment_code'), table_name='units')
op.drop_table('units')
op.drop_table('categories')
# ### end Alembic commands ###
View File
View File
+38
View File
@@ -0,0 +1,38 @@
"""Application configuration via Pydantic-settings environment variables."""
from __future__ import annotations
from pathlib import Path
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(
env_file=".env",
env_file_encoding="utf-8",
case_sensitive=False,
extra="ignore",
)
# ── App ──────────────────────────────────────────────────────────
APP_NAME: str = "Denya OneCare"
DEBUG: bool = False
# ── Database ─────────────────────────────────────────────────────
DATABASE_URL: str = "sqlite+aiosqlite:///./denya_onecare.db"
# ── Auth ─────────────────────────────────────────────────────────
SECRET_KEY: str = "change-me-in-production-use-a-real-secret"
ALGORITHM: str = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES: int = 30
REFRESH_TOKEN_EXPIRE_MINUTES: int = 60 * 24 * 7 # 7 days
# ── CORS ─────────────────────────────────────────────────────────
CORS_ORIGINS: str = "*"
# ── Paths ────────────────────────────────────────────────────────
BASE_DIR: Path = Path(__file__).resolve().parent.parent.parent
settings = Settings()
+33
View File
@@ -0,0 +1,33 @@
"""Database engine, session factory, and Base."""
from __future__ import annotations
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from sqlalchemy.orm import DeclarativeBase
from app.core.config import settings
engine = create_async_engine(settings.DATABASE_URL, echo=settings.DEBUG)
async_session_factory = async_sessionmaker(
engine,
class_=AsyncSession,
expire_on_commit=False,
)
class Base(DeclarativeBase):
pass
async def get_db() -> AsyncSession: # type: ignore[misc]
"""FastAPI dependency that yields an async DB session."""
async with async_session_factory() as session:
try:
yield session
await session.commit()
except Exception:
await session.rollback()
raise
finally:
await session.close()
+115
View File
@@ -0,0 +1,115 @@
"""Password hashing, JWT token creation / verification, and RBAC."""
from __future__ import annotations
from datetime import datetime, timedelta, timezone
from functools import wraps
from typing import Annotated, Any, Callable
import bcrypt
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from jose import JWTError, jwt
from jose.exceptions import JWTClaimsError
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import settings
from app.core.database import get_db
from app.models.user import User
bearer_scheme = HTTPBearer(auto_error=False)
# ── Password helpers ─────────────────────────────────────────────────
def hash_password(password: str) -> str:
"""Return bcrypt hash of *password*."""
salt = bcrypt.gensalt()
return bcrypt.hashpw(password.encode("utf-8"), salt).decode("utf-8")
def verify_password(plain: str, hashed: str) -> bool:
"""Return True if *plain* matches *hashed*."""
return bcrypt.checkpw(plain.encode("utf-8"), hashed.encode("utf-8"))
# ── JWT helpers ──────────────────────────────────────────────────────
def create_access_token(data: dict[str, Any], expires_delta: timedelta | None = None) -> str:
"""Create a signed JWT access token."""
to_encode = data.copy()
expire = datetime.now(timezone.utc) + (expires_delta or timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES))
to_encode.update({"exp": expire, "type": "access"})
return jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
def create_refresh_token(data: dict[str, Any]) -> str:
"""Create a signed JWT refresh token."""
to_encode = data.copy()
expire = datetime.now(timezone.utc) + timedelta(minutes=settings.REFRESH_TOKEN_EXPIRE_MINUTES)
to_encode.update({"exp": expire, "type": "refresh"})
return jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
def decode_token(token: str) -> dict[str, Any]:
"""Decode and validate a JWT token. Raises 401 on failure."""
try:
payload = jwt.decode(
token,
settings.SECRET_KEY,
algorithms=[settings.ALGORITHM],
options={"verify_sub": False},
)
return payload
except (JWTError, JWTClaimsError):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid or expired token",
)
# ── Dependencies ─────────────────────────────────────────────────────
async def get_current_user(
credentials: Annotated[HTTPAuthorizationCredentials | None, Depends(bearer_scheme)],
db: Annotated[AsyncSession, Depends(get_db)],
) -> User:
"""Return the authenticated User from the Bearer token."""
if credentials is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Not authenticated",
)
payload = decode_token(credentials.credentials)
raw_sub = payload.get("sub")
if raw_sub is None:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token payload")
try:
user_id = int(raw_sub)
except (ValueError, TypeError):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token payload")
result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
if user is None or not user.active:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found or inactive")
return user
def require_roles(*roles: str) -> Callable:
"""Decorator for route dependencies that enforces one of the given roles.
Usage::
@router.get("/admin-only")
async def admin_endpoint(current_user: Annotated[User, Depends(require_roles("Admin/Jerome"))]):
...
"""
async def role_checker(current_user: Annotated[User, Depends(get_current_user)]) -> User:
if current_user.role not in roles:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"Role '{current_user.role}' not in {roles}",
)
return current_user
return role_checker
+55
View File
@@ -0,0 +1,55 @@
"""Denya OneCare — FastAPI application entry point."""
from __future__ import annotations
import logging
from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import settings
from app.core.database import Base, async_session_factory, engine
from app.routers import auth, health, whatsapp
from app.services.seed import 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()
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=["*"],
)
# ── Routers ──────────────────────────────────────────────────────────
app.include_router(health.router)
app.include_router(auth.router)
app.include_router(whatsapp.router)
+7
View File
@@ -0,0 +1,7 @@
"""Import all models so SQLAlchemy can resolve string-based relationships."""
from app.models.category import Category # noqa: F401
from app.models.ticket import Escalation, Ticket, TicketPhoto, TicketTimeline # noqa: F401
from app.models.unit import Unit # noqa: F401
from app.models.user import User # noqa: F401
from app.models.whatsapp_log import WhatsAppLog # noqa: F401
+37
View File
@@ -0,0 +1,37 @@
"""Ticket category model with self-referencing parent for sub-categories."""
from __future__ import annotations
from sqlalchemy import ForeignKey, Integer, String
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.core.database import Base
class Category(Base):
__tablename__ = "categories"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
type: Mapped[str] = mapped_column(
String(20),
nullable=False,
comment="maintenance, cs, emergency",
)
name: Mapped[str] = mapped_column(String(100), nullable=False)
parent_id: Mapped[int | None] = mapped_column(
Integer,
ForeignKey("categories.id"),
nullable=True,
)
sla_urgency: Mapped[str | None] = mapped_column(
String(10),
nullable=True,
comment="urgent, high, medium, low",
)
# self-referencing relationship
children: Mapped[list[Category]] = relationship("Category", back_populates="parent", cascade="all, delete-orphan")
parent: Mapped[Category | None] = relationship("Category", back_populates="children", remote_side="Category.id")
def __repr__(self) -> str:
return f"<Category {self.type}:{self.name}>"
+123
View File
@@ -0,0 +1,123 @@
"""Ticket, TicketTimeline, TicketPhoto, and Escalation models."""
from __future__ import annotations
from datetime import datetime
from decimal import Decimal
from sqlalchemy import DateTime, ForeignKey, Integer, Numeric, String, Text, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.core.database import Base
class Ticket(Base):
__tablename__ = "tickets"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
ticket_number: Mapped[str] = mapped_column(
String(20),
unique=True,
nullable=False,
index=True,
comment="Format: PAV-YYYY-NNNNN",
)
status: Mapped[str] = mapped_column(
String(30),
nullable=False,
default="New",
comment=(
"New, Logged, Triage, Assigned, Accepted, Travelling, On Site, "
"In Progress, Waiting Parts, Escalated, Completed, "
"On-Field Verification, Wahab Review, Closed, Reopened"
),
)
unit_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("units.id"), nullable=True)
category_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("categories.id"), nullable=True)
priority: Mapped[str | None] = mapped_column(
String(10),
nullable=True,
comment="urgent, high, medium, low",
)
reporter: Mapped[str | None] = mapped_column(String(255), nullable=True)
reported_via: Mapped[str | None] = mapped_column(
String(20),
nullable=True,
comment="whatsapp, phone, walk-in, qr, agent",
)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
assigned_to: Mapped[int | None] = mapped_column(Integer, ForeignKey("users.id"), nullable=True)
sla_deadline: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
eta: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
cost: Mapped[Decimal | None] = mapped_column(Numeric(10, 2), nullable=True)
parts_used: Mapped[str | None] = mapped_column(Text, nullable=True)
customer_rating: Mapped[int | None] = mapped_column(Integer, nullable=True)
reopen_count: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
closed_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), nullable=False)
updated_at: Mapped[datetime] = mapped_column(
DateTime,
server_default=func.now(),
onupdate=func.now(),
nullable=False,
)
# relationships
unit = relationship("Unit")
category = relationship("Category")
assigned_technician = relationship("User", back_populates="assigned_tickets", foreign_keys=[assigned_to])
timeline = relationship("TicketTimeline", back_populates="ticket", order_by="TicketTimeline.created_at")
photos = relationship("TicketPhoto", back_populates="ticket")
escalations = relationship("Escalation", back_populates="ticket")
def __repr__(self) -> str:
return f"<Ticket {self.ticket_number} ({self.status})>"
class TicketTimeline(Base):
__tablename__ = "ticket_timeline"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
ticket_id: Mapped[int] = mapped_column(Integer, ForeignKey("tickets.id"), nullable=False)
from_status: Mapped[str | None] = mapped_column(String(30), nullable=True)
to_status: Mapped[str | None] = mapped_column(String(30), nullable=True)
note: Mapped[str | None] = mapped_column(Text, nullable=True)
user_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("users.id"), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), nullable=False)
ticket = relationship("Ticket", back_populates="timeline")
user = relationship("User", back_populates="timeline_entries")
def __repr__(self) -> str:
return f"<TicketTimeline {self.ticket_id}: {self.from_status}{self.to_status}>"
class TicketPhoto(Base):
__tablename__ = "ticket_photos"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
ticket_id: Mapped[int] = mapped_column(Integer, ForeignKey("tickets.id"), nullable=False)
photo_url: Mapped[str] = mapped_column(String(500), nullable=False)
is_before: Mapped[bool] = mapped_column(nullable=False, default=True)
ticket = relationship("Ticket", back_populates="photos")
def __repr__(self) -> str:
return f"<TicketPhoto {self.id} ticket={self.ticket_id}>"
class Escalation(Base):
__tablename__ = "escalations"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
ticket_id: Mapped[int] = mapped_column(Integer, ForeignKey("tickets.id"), nullable=False)
escalated_to: Mapped[int] = mapped_column(Integer, ForeignKey("users.id"), nullable=False)
reason: Mapped[str | None] = mapped_column(Text, nullable=True)
resolved_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), nullable=False)
ticket = relationship("Ticket", back_populates="escalations")
escalated_to_user = relationship("User", back_populates="escalations", foreign_keys=[escalated_to])
def __repr__(self) -> str:
return f"<Escalation ticket={self.ticket_id} → user={self.escalated_to}>"
+21
View File
@@ -0,0 +1,21 @@
"""Unit model — an apartment at Pavilion East/West."""
from __future__ import annotations
from sqlalchemy import String
from sqlalchemy.orm import Mapped, mapped_column
from app.core.database import Base
class Unit(Base):
__tablename__ = "units"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
property: Mapped[str] = mapped_column(String(10), nullable=False, comment="East or West")
apartment_code: Mapped[str] = mapped_column(String(20), unique=True, nullable=False, index=True)
building: Mapped[str | None] = mapped_column(String(100), nullable=True)
floor: Mapped[int | None] = mapped_column(nullable=True)
def __repr__(self) -> str:
return f"<Unit {self.apartment_code}>"
+32
View File
@@ -0,0 +1,32 @@
"""User model."""
from __future__ import annotations
from sqlalchemy import Boolean, String
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.core.database import Base
class User(Base):
__tablename__ = "users"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
email: Mapped[str] = mapped_column(String(255), unique=True, nullable=False, index=True)
password_hash: Mapped[str] = mapped_column(String(255), nullable=False)
full_name: Mapped[str] = mapped_column(String(255), nullable=False)
phone: Mapped[str | None] = mapped_column(String(50), nullable=True)
role: Mapped[str] = mapped_column(
String(50),
nullable=False,
comment="CS Rep, CS Manager, FM Dispatcher, Admin/Jerome, Admin/Wahab, Tech, CEO, Director",
)
active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
# relationships
assigned_tickets = relationship("Ticket", back_populates="assigned_technician", foreign_keys="Ticket.assigned_to")
timeline_entries = relationship("TicketTimeline", back_populates="user")
escalations = relationship("Escalation", back_populates="escalated_to_user", foreign_keys="Escalation.escalated_to")
def __repr__(self) -> str:
return f"<User {self.id}:{self.email} ({self.role})>"
+23
View File
@@ -0,0 +1,23 @@
"""WhatsApp log model for mock endpoint."""
from __future__ import annotations
from datetime import datetime
from sqlalchemy import DateTime, Integer, String, Text
from sqlalchemy.orm import Mapped, mapped_column
from app.core.database import Base
class WhatsAppLog(Base):
__tablename__ = "whatsapp_log"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
command: Mapped[str] = mapped_column(Text, nullable=False)
from_number: Mapped[str | None] = mapped_column(String(50), nullable=True)
ticket_id: Mapped[int | None] = mapped_column(Integer, nullable=True)
received_at: Mapped[datetime] = mapped_column(DateTime, nullable=False)
def __repr__(self) -> str:
return f"<WhatsAppLog {self.id}: {self.command[:50]}>"
View File
+61
View File
@@ -0,0 +1,61 @@
"""Authentication router — register, login, refresh, me."""
from __future__ import annotations
from typing import Annotated
from fastapi import APIRouter, Depends
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.core.security import get_current_user, require_roles
from app.models.user import User
from app.schemas.auth import (
LoginRequest,
RefreshRequest,
RegisterRequest,
TokenResponse,
UserOut,
)
from app.services import auth as auth_service
router = APIRouter(prefix="/api/auth", tags=["auth"])
@router.post("/register", response_model=UserOut, status_code=201)
async def register(
body: RegisterRequest,
db: Annotated[AsyncSession, Depends(get_db)],
) -> User:
return await auth_service.register(db, body)
@router.post("/login", response_model=TokenResponse)
async def login(
body: LoginRequest,
db: Annotated[AsyncSession, Depends(get_db)],
) -> TokenResponse:
access, refresh, _user = await auth_service.login(db, body.email, body.password)
return TokenResponse(access_token=access, refresh_token=refresh)
@router.post("/refresh", response_model=TokenResponse)
async def refresh(
body: RefreshRequest,
db: Annotated[AsyncSession, Depends(get_db)],
) -> TokenResponse:
access, refresh = await auth_service.refresh_access_token(db, body.refresh_token)
return TokenResponse(access_token=access, refresh_token=refresh)
@router.get("/me", response_model=UserOut)
async def me(current_user: Annotated[User, Depends(get_current_user)]) -> User:
return current_user
@router.get("/admin-only", response_model=UserOut)
async def admin_only(
current_user: Annotated[User, Depends(require_roles("Admin/Jerome", "Admin/Wahab"))],
) -> User:
"""Example RBAC-protected endpoint — only Admins can access."""
return current_user
+14
View File
@@ -0,0 +1,14 @@
"""Health-check endpoint."""
from __future__ import annotations
from fastapi import APIRouter
from app.schemas.health import HealthResponse
router = APIRouter(tags=["health"])
@router.get("/health", response_model=HealthResponse)
async def health_check() -> HealthResponse:
return HealthResponse()
+45
View File
@@ -0,0 +1,45 @@
"""Mock WhatsApp endpoint for testing command parsing."""
from __future__ import annotations
from datetime import datetime, timezone
from typing import Annotated
from fastapi import APIRouter, Depends
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.models.whatsapp_log import WhatsAppLog
from app.schemas.whatsapp import MockWhatsAppLogEntry, MockWhatsAppRequest, MockWhatsAppResponse
router = APIRouter(prefix="/api/whatsapp", tags=["whatsapp"])
@router.post("/mock", response_model=MockWhatsAppResponse)
async def mock_whatsapp(
body: MockWhatsAppRequest,
db: Annotated[AsyncSession, Depends(get_db)],
) -> MockWhatsAppResponse:
"""Accept a mock WhatsApp command and log it."""
log = WhatsAppLog(
command=body.command,
from_number=body.from_number,
ticket_id=body.ticket_id,
received_at=datetime.now(timezone.utc),
)
db.add(log)
await db.flush()
return MockWhatsAppResponse()
@router.get("/mock-log", response_model=list[MockWhatsAppLogEntry])
async def mock_whatsapp_log(
db: Annotated[AsyncSession, Depends(get_db)],
limit: int = 50,
) -> list[MockWhatsAppLogEntry]:
"""Return recent mock WhatsApp submissions."""
result = await db.execute(
select(WhatsAppLog).order_by(WhatsAppLog.received_at.desc()).limit(limit)
)
return list(result.scalars().all())
View File
+39
View File
@@ -0,0 +1,39 @@
"""Pydantic schemas for authentication endpoints."""
from __future__ import annotations
from pydantic import BaseModel, EmailStr
class RegisterRequest(BaseModel):
email: str
password: str
full_name: str
phone: str | None = None
role: str = "CS Rep"
class LoginRequest(BaseModel):
email: str
password: str
class TokenResponse(BaseModel):
access_token: str
refresh_token: str
token_type: str = "bearer"
class RefreshRequest(BaseModel):
refresh_token: str
class UserOut(BaseModel):
id: int
email: str
full_name: str
phone: str | None
role: str
active: bool
model_config = {"from_attributes": True}
+8
View File
@@ -0,0 +1,8 @@
"""Health-check schema."""
from pydantic import BaseModel
class HealthResponse(BaseModel):
status: str = "ok"
app: str = "Denya OneCare"
+28
View File
@@ -0,0 +1,28 @@
"""Pydantic schemas for mock WhatsApp endpoint."""
from __future__ import annotations
from datetime import datetime
from pydantic import BaseModel
class MockWhatsAppRequest(BaseModel):
command: str
from_number: str | None = None
ticket_id: int | None = None
class MockWhatsAppResponse(BaseModel):
status: str = "received"
message: str = "Command logged successfully"
class MockWhatsAppLogEntry(BaseModel):
id: int
command: str
from_number: str | None
ticket_id: int | None
received_at: datetime
model_config = {"from_attributes": True}
View File
+74
View File
@@ -0,0 +1,74 @@
"""Authentication service — register, login, refresh."""
from __future__ import annotations
from fastapi import HTTPException, status
from jose import JWTError, jwt
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import settings
from app.core.security import (
create_access_token,
create_refresh_token,
decode_token,
hash_password,
verify_password,
)
from app.models.user import User
from app.schemas.auth import RegisterRequest
async def register(db: AsyncSession, body: RegisterRequest) -> User:
"""Create a new user. Raises 409 if email already exists."""
result = await db.execute(select(User).where(User.email == body.email))
if result.scalar_one_or_none():
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Email already registered")
user = User(
email=body.email,
password_hash=hash_password(body.password),
full_name=body.full_name,
phone=body.phone,
role=body.role,
)
db.add(user)
await db.flush()
await db.refresh(user)
return user
async def login(db: AsyncSession, email: str, password: str) -> tuple[str, str, User]:
"""Authenticate and return (access_token, refresh_token, user)."""
result = await db.execute(select(User).where(User.email == email))
user = result.scalar_one_or_none()
if user is None or not verify_password(password, user.password_hash):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid email or password")
if not user.active:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Account is inactive")
access_token = create_access_token({"sub": str(user.id)})
refresh_token = create_refresh_token({"sub": str(user.id)})
return access_token, refresh_token, user
async def refresh_access_token(db: AsyncSession, token: str) -> tuple[str, str]:
"""Validate a refresh token and issue a new token pair."""
try:
payload = decode_token(token)
if payload.get("type") != "refresh":
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token type")
except HTTPException:
raise
except Exception:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid refresh token")
user_id: int = int(payload["sub"])
result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
if user is None or not user.active:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found or inactive")
new_access = create_access_token({"sub": str(user.id)})
new_refresh = create_refresh_token({"sub": str(user.id)})
return new_access, new_refresh
+120
View File
@@ -0,0 +1,120 @@
"""Seed script — users and units.
Called on first startup or via :func:`seed_all`.
"""
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
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
+17
View File
@@ -0,0 +1,17 @@
services:
app:
build: .
container_name: denya-onecare
ports:
- "8000:8000"
environment:
- DATABASE_URL=sqlite+aiosqlite:///./data/denya_onecare.db
- SECRET_KEY=change-me-in-production
- CORS_ORIGINS=*
- DEBUG=false
volumes:
- app-data:/app/data
restart: unless-stopped
volumes:
app-data:
+29
View File
@@ -0,0 +1,29 @@
[project]
name = "denya-onecare"
version = "0.1.0"
description = "Denya OneCare - Centralized issue tracking for Pavilion Accra"
requires-python = ">=3.11"
dependencies = [
"fastapi[standard]>=0.115.0",
"uvicorn[standard]>=0.30.0",
"sqlalchemy>=2.0.0",
"alembic>=1.13.0",
"pydantic-settings>=2.5.0",
"python-jose[cryptography]>=3.3.0",
"bcrypt>=4.0.0",
"python-multipart>=0.0.9",
"aiosqlite>=0.20.0",
]
[build-system]
requires = ["setuptools>=64.0"]
build-backend = "setuptools.build_meta"
[tool.setuptools.packages.find]
include = ["app*"]
[project.optional-dependencies]
dev = [
"pytest>=8.0",
"httpx>=0.27.0",
]