feat: T03+T04 backend - FastAPI, DB models, Rentman integration, admin auth, APScheduler

This commit is contained in:
Implementation Engineer
2026-07-09 01:36:46 +02:00
parent 3bfa54b4b3
commit 88cff68f73
78 changed files with 2174 additions and 0 deletions
View File
+92
View File
@@ -0,0 +1,92 @@
"""Admin router: login, sync endpoints, sync log."""
from fastapi import APIRouter, Depends, HTTPException, Response, Query, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from typing import Any
from app.database import get_db
from app.models.admin_user import AdminUser
from app.models.sync_log import SyncLog
from app.schemas.auth import LoginRequest, TokenResponse, AdminInfo
from app.schemas.sync import SyncStatus, SyncLogEntry, SyncTriggerResponse
from app.auth import verify_password, create_access_token, get_current_user
from app.services.sync_service import SyncService
from app.cache import cache
router = APIRouter(prefix="/api/admin", tags=["admin"])
@router.post("/login", response_model=TokenResponse)
async def login(
creds: LoginRequest,
response: Response,
db: AsyncSession = Depends(get_db),
) -> Any:
"""Admin login: verify credentials, set JWT in HttpOnly cookie."""
# Rate limiting
rate_key = f"rate:login:{creds.username}"
count = await cache.incr_rate(rate_key, window=60)
if count > 5:
raise HTTPException(status_code=status.HTTP_429_TOO_MANY_REQUESTS, detail="Too many login attempts")
result = await db.execute(select(AdminUser).where(AdminUser.username == creds.username))
user = result.scalar_one_or_none()
if not user or not verify_password(creds.password, user.password_hash):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials")
token = create_access_token({"sub": user.username})
response.set_cookie(
key="hms_admin_token",
value=token,
httponly=True,
secure=True,
samesite="strict",
max_age=86400,
path="/",
)
return TokenResponse(access_token=token, token_type="bearer")
@router.get("/me", response_model=AdminInfo)
async def get_me(user: AdminUser = Depends(get_current_user)) -> Any:
"""Return current authenticated admin user."""
return AdminInfo(username=user.username)
@router.post("/sync", response_model=SyncTriggerResponse)
async def trigger_sync(
user: AdminUser = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
) -> Any:
"""Trigger a manual equipment sync (admin only)."""
sync_service = SyncService(db)
result = await sync_service.run_sync()
return SyncTriggerResponse(sync_id=result["sync_id"], status=result["status"])
@router.get("/sync-status", response_model=SyncStatus)
async def sync_status(
user: AdminUser = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
) -> Any:
"""Return the latest sync status."""
sync_service = SyncService(db)
return await sync_service.get_last_sync()
@router.get("/sync-log")
async def sync_log(
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
user: AdminUser = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
) -> Any:
"""Return paginated sync log entries (admin only)."""
sync_service = SyncService(db)
result = await sync_service.get_sync_log_paginated(page=page, page_size=page_size)
return {
"items": [SyncLogEntry.model_validate(log) for log in result["items"]],
"total": result["total"],
"page": result["page"],
"page_size": result["page_size"],
"total_pages": result["total_pages"],
}
+49
View File
@@ -0,0 +1,49 @@
"""Contact form router."""
from fastapi import APIRouter, Depends, HTTPException, Request, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.models.contact import Contact
from app.schemas.contact import ContactCreate, ContactResponse
from app.services.email_service import EmailService
from app.cache import cache
router = APIRouter(prefix="/api/contact", tags=["contact"])
@router.post("", response_model=ContactResponse)
async def create_contact(
payload: ContactCreate,
request: Request,
db: AsyncSession = Depends(get_db),
) -> ContactResponse:
"""Save contact form and send email."""
client_ip = request.client.host if request.client else "unknown"
rate_key = f"rate:contact:{client_ip}"
count = await cache.incr_rate(rate_key, window=60)
if count > 5:
raise HTTPException(status_code=status.HTTP_429_TOO_MANY_REQUESTS, detail="Too many requests")
contact = Contact(
name=payload.name,
email=str(payload.email),
phone=payload.phone,
message=payload.message,
privacy_consent=payload.privacy_consent,
)
db.add(contact)
await db.commit()
email_service = EmailService()
try:
await email_service.send_contact_email({
"name": payload.name,
"email": str(payload.email),
"phone": payload.phone,
"message": payload.message,
})
contact.email_sent = True
await db.commit()
except Exception:
pass
return ContactResponse(success=True)
+90
View File
@@ -0,0 +1,90 @@
"""Equipment API router: list, detail, categories."""
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import select, func, or_
from sqlalchemy.ext.asyncio import AsyncSession
from typing import Any
from app.database import get_db
from app.models.equipment import EquipmentCache
from app.schemas.equipment import EquipmentItem, EquipmentDetail, PaginatedResponse
from app.cache import cache
router = APIRouter(prefix="/api/equipment", tags=["equipment"])
@router.get("", response_model=PaginatedResponse)
async def list_equipment(
search: str | None = Query(None),
category: str | None = Query(None),
sort: str | None = Query("name_asc"),
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
db: AsyncSession = Depends(get_db),
) -> Any:
"""Return paginated equipment list with optional search/filter/sort."""
cache_key = f"equipment:list:{search}:{category}:{sort}:{page}:{page_size}"
cached = await cache.get(cache_key)
if cached:
return cached
query = select(EquipmentCache)
count_query = select(func.count(EquipmentCache.id))
if search:
query = query.where(EquipmentCache.name.ilike(f"%{search}%"))
count_query = count_query.where(EquipmentCache.name.ilike(f"%{search}%"))
if category:
query = query.where(EquipmentCache.category == category)
count_query = count_query.where(EquipmentCache.category == category)
if sort == "name_desc":
query = query.order_by(EquipmentCache.name.desc())
else:
query = query.order_by(EquipmentCache.name.asc())
total_result = await db.execute(count_query)
total = total_result.scalar() or 0
offset = (page - 1) * page_size
result = await db.execute(query.offset(offset).limit(page_size))
items = result.scalars().all()
response = {
"items": [EquipmentItem.model_validate(item) for item in items],
"total": total,
"page": page,
"page_size": page_size,
"total_pages": (total + page_size - 1) // page_size if page_size > 0 else 0,
}
await cache.set(cache_key, response, ttl=3600)
return response
@router.get("/categories", response_model=list[str])
async def list_categories(db: AsyncSession = Depends(get_db)) -> Any:
"""Return all distinct equipment categories."""
cached = await cache.get("equipment:categories")
if cached:
return cached
result = await db.execute(
select(EquipmentCache.category).distinct().where(EquipmentCache.category.isnot(None))
)
categories = [row[0] for row in result.fetchall() if row[0]]
await cache.set("equipment:categories", categories, ttl=3600)
return categories
@router.get("/{equipment_id}", response_model=EquipmentDetail)
async def get_equipment(equipment_id: int, db: AsyncSession = Depends(get_db)) -> Any:
"""Return a single equipment detail by ID."""
cache_key = f"equipment:detail:{equipment_id}"
cached = await cache.get(cache_key)
if cached:
return cached
result = await db.execute(select(EquipmentCache).where(EquipmentCache.id == equipment_id))
item = result.scalar_one_or_none()
if not item:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Equipment not found")
response = EquipmentDetail.model_validate(item)
await cache.set(cache_key, response.model_dump(), ttl=3600)
return response
+25
View File
@@ -0,0 +1,25 @@
"""Health check router."""
from fastapi import APIRouter, Depends
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db, engine
from app.cache import cache
router = APIRouter(prefix="/api/health", tags=["health"])
@router.get("")
async def health_check(db: AsyncSession = Depends(get_db)) -> dict:
"""Return health status: app, db, redis."""
db_ok = "connected"
redis_ok = "connected"
try:
await db.execute(text("SELECT 1"))
except Exception:
db_ok = "disconnected"
try:
await cache.connect()
await cache._redis.ping()
except Exception:
redis_ok = "disconnected"
return {"status": "ok", "db": db_ok, "redis": redis_ok}
+141
View File
@@ -0,0 +1,141 @@
"""Rental request router: accept Mietanfrage, save, forward to Rentman."""
import random
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, status, Request
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from typing import Any
from app.database import get_db
from app.models.equipment import EquipmentCache
from app.models.rental_request import RentalRequest, RentalRequestItem
from app.schemas.rental_request import RentalRequestCreate, RentalRequestResponse
from app.services.rentman_service import RentmanService
from app.services.email_service import EmailService
from app.cache import cache
router = APIRouter(prefix="/api/rental-requests", tags=["rental-requests"])
def generate_reference_number() -> str:
"""Generate a unique reference number: HMS-YYYY-NNNNN."""
year = datetime.now().year
num = random.randint(1, 99999)
return f"HMS-{year}-{num:05d}"
@router.post("", response_model=RentalRequestResponse, status_code=status.HTTP_201_CREATED)
async def create_rental_request(
payload: RentalRequestCreate,
request: Request,
db: AsyncSession = Depends(get_db),
) -> Any:
"""Create a rental request, save to DB, forward to Rentman, send email."""
# Rate limiting
client_ip = request.client.host if request.client else "unknown"
rate_key = f"rate:rental:{client_ip}"
count = await cache.incr_rate(rate_key, window=60)
if count > 5:
raise HTTPException(status_code=status.HTTP_429_TOO_MANY_REQUESTS, detail="Too many requests")
ref_number = generate_reference_number()
# Save to database
rental = RentalRequest(
reference_number=ref_number,
event_name=payload.event_name,
date_start=payload.date_start,
date_end=payload.date_end,
location=payload.location,
person_count=payload.person_count,
contact_name=payload.contact_name,
contact_company=payload.contact_company,
contact_email=str(payload.contact_email),
contact_phone=payload.contact_phone,
contact_street=payload.contact_street,
contact_postalcode=payload.contact_postalcode,
contact_city=payload.contact_city,
message=payload.message,
status="pending",
rentman_sync_status="pending",
)
db.add(rental)
await db.flush()
# Fetch equipment info for items
items_data = []
for item in payload.items:
result = await db.execute(select(EquipmentCache).where(EquipmentCache.id == item.equipment_id))
eq = result.scalar_one_or_none()
eq_name = eq.name if eq else "Unknown"
eq_rentman_id = eq.rentman_id if eq else ""
req_item = RentalRequestItem(
rental_request_id=rental.id,
equipment_id=item.equipment_id,
equipment_name=eq_name,
rentman_equipment_id=eq_rentman_id,
quantity=item.quantity,
rentman_sync_status="pending",
)
db.add(req_item)
items_data.append({
"equipment_name": eq_name,
"rentman_equipment_id": eq_rentman_id,
"quantity": item.quantity,
})
await db.commit()
# Forward to Rentman
rentman = RentmanService()
rentman_payload = RentmanService.build_project_request_payload({
"event_name": payload.event_name,
"date_start": str(payload.date_start),
"date_end": str(payload.date_end),
"contact_name": payload.contact_name,
"contact_company": payload.contact_company,
"contact_email": str(payload.contact_email),
"contact_phone": payload.contact_phone,
"contact_street": payload.contact_street,
"contact_postalcode": payload.contact_postalcode,
"contact_city": payload.contact_city,
"location": payload.location,
"message": payload.message,
})
try:
rentman_response = await rentman.create_project_request(rentman_payload)
rentman_request_id = str(rentman_response.get("id", ""))
rental.rentman_request_id = rentman_request_id
rental.rentman_sync_status = "project_created"
# Add equipment items to Rentman
for item_data in items_data:
eq_payload = RentmanService.build_equipment_payload(item_data)
try:
await rentman.add_equipment_to_request(rentman_request_id, eq_payload)
except Exception:
# Item-level failure: mark as failed but continue
pass
rental.rentman_sync_status = "success"
except Exception:
rental.rentman_sync_status = "failed"
rental.rentman_sync_error = "Rentman API request failed"
await db.commit()
# Send confirmation email
email_service = EmailService()
try:
await email_service.send_rental_confirmation(
to_addr=str(payload.contact_email),
reference_number=ref_number,
event_name=payload.event_name,
items=items_data,
)
except Exception:
# Email failure should not affect response
pass
return RentalRequestResponse(reference_number=ref_number, status="pending")