feat: T03+T04 backend - FastAPI, DB models, Rentman integration, admin auth, APScheduler
This commit is contained in:
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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"],
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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}
|
||||
@@ -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")
|
||||
Reference in New Issue
Block a user