feat: Sprint 1 foundation - FastAPI scaffold, DB schema, auth, seed data, mock WhatsApp, Docker
This commit is contained in:
@@ -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
|
||||
Reference in New Issue
Block a user