feat: Sprint 1 foundation - FastAPI scaffold, DB schema, auth, seed data, mock WhatsApp, Docker

This commit is contained in:
root
2026-07-23 12:04:14 +00:00
parent 8c755f3db7
commit 32a4c50b23
34 changed files with 1390 additions and 0 deletions
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
+53
View File
@@ -0,0 +1,53 @@
"""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
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
+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