Sprint 1: Foundation — FastAPI scaffold, auth, DB schema, mock WhatsApp, Docker #1
@@ -0,0 +1,9 @@
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*.db
|
||||
*.sqlite3
|
||||
.env
|
||||
.venv/
|
||||
venv/
|
||||
*.egg-info/
|
||||
dist/
|
||||
@@ -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.
|
||||
+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
|
||||
+55
@@ -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)
|
||||
@@ -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,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,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,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
|
||||
@@ -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