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
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+77
View File
@@ -0,0 +1,77 @@
"""JWT authentication helpers."""
from datetime import datetime, timedelta, timezone
from typing import Optional
from jose import JWTError, jwt
from passlib.context import CryptContext
from fastapi import Depends, HTTPException, status, Request
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from app.config import get_settings
from app.database import get_db
from app.models.admin_user import AdminUser
settings = get_settings()
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
def verify_password(plain_password: str, hashed_password: str) -> bool:
"""Verify a plain password against a hash."""
return pwd_context.verify(plain_password, hashed_password)
def get_password_hash(password: str) -> str:
"""Hash a password using bcrypt."""
return pwd_context.hash(password)
def create_access_token(data: dict, expires_delta: timedelta | None = None) -> str:
"""Create a JWT access token."""
to_encode = data.copy()
expire = datetime.now(timezone.utc) + (
expires_delta or timedelta(hours=settings.jwt_expire_hours)
)
to_encode.update({"exp": expire})
return jwt.encode(to_encode, settings.jwt_secret, algorithm=settings.jwt_algorithm)
def verify_token(token: str) -> dict | None:
"""Verify a JWT token and return its payload."""
try:
payload = jwt.decode(token, settings.jwt_secret, algorithms=[settings.jwt_algorithm])
return payload
except JWTError:
return None
async def get_current_user(
request: Request,
db: AsyncSession = Depends(get_db),
) -> AdminUser:
"""Dependency: extract JWT from cookie, verify, return AdminUser."""
token = request.cookies.get("hms_admin_token")
if not token:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Not authenticated",
)
payload = verify_token(token)
if not payload:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid or expired token",
)
username = payload.get("sub")
if not username:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid token payload",
)
result = await db.execute(select(AdminUser).where(AdminUser.username == username))
user = result.scalar_one_or_none()
if not user:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="User not found",
)
return user
+66
View File
@@ -0,0 +1,66 @@
"""Redis cache client and helpers."""
import json
from typing import Any
import redis.asyncio as redis
from app.config import get_settings
settings = get_settings()
async def get_redis() -> redis.Redis:
"""Return a Redis client instance."""
return redis.from_url(settings.redis_url, decode_responses=True)
class CacheClient:
"""Wrapper around Redis for equipment caching."""
def __init__(self) -> None:
self._redis: redis.Redis | None = None
async def connect(self) -> None:
self._redis = await get_redis()
async def disconnect(self) -> None:
if self._redis:
await self._redis.close()
self._redis = None
async def get(self, key: str) -> Any | None:
if not self._redis:
await self.connect()
assert self._redis is not None
data = await self._redis.get(key)
if data:
return json.loads(data)
return None
async def set(self, key: str, value: Any, ttl: int = 3600) -> None:
if not self._redis:
await self.connect()
assert self._redis is not None
await self._redis.setex(key, ttl, json.dumps(value, default=str))
async def delete_pattern(self, pattern: str) -> int:
if not self._redis:
await self.connect()
assert self._redis is not None
keys = []
async for key in self._redis.scan_iter(match=pattern):
keys.append(key)
if keys:
return await self._redis.delete(*keys)
return 0
async def incr_rate(self, key: str, window: int = 60) -> int:
if not self._redis:
await self.connect()
assert self._redis is not None
count = await self._redis.incr(key)
if count == 1:
await self._redis.expire(key, window)
return count
cache = CacheClient()
+43
View File
@@ -0,0 +1,43 @@
"""Application configuration via Pydantic Settings."""
from pydantic_settings import BaseSettings
from functools import lru_cache
class Settings(BaseSettings):
"""Settings loaded from environment variables."""
# Database
database_url: str = "sqlite+aiosqlite:///./test.db"
# Redis
redis_url: str = "redis://localhost:6379/0"
# Rentman
rentman_api_token: str = ""
# JWT
jwt_secret: str = "change-me-in-production"
jwt_algorithm: str = "HS256"
jwt_expire_hours: int = 24
# SMTP
smtp_host: str = ""
smtp_port: int = 587
smtp_user: str = ""
smtp_password: str = ""
smtp_from: str = "info@hms-licht-ton.de"
# CORS
cors_origins: str = "https://hms.media-on.de,http://localhost:3000"
# Admin seed
admin_username: str = "admin"
admin_password: str = "change_me_in_production"
model_config = {"env_file": ".env", "extra": "ignore"}
@lru_cache
def get_settings() -> Settings:
"""Return cached settings instance."""
return Settings()
+42
View File
@@ -0,0 +1,42 @@
"""SQLAlchemy async database engine and session."""
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from sqlalchemy.orm import DeclarativeBase
from app.config import get_settings
class Base(DeclarativeBase):
"""Declarative base for all models."""
pass
settings = get_settings()
engine = create_async_engine(
settings.database_url,
echo=False,
pool_pre_ping=True,
)
async_session = async_sessionmaker(
engine,
class_=AsyncSession,
expire_on_commit=False,
)
async def get_db() -> AsyncSession:
"""Dependency: yield an async database session."""
async with async_session() as session:
try:
yield session
except Exception:
await session.rollback()
raise
finally:
await session.close()
async def init_db() -> None:
"""Create all tables (for testing / first run)."""
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
+79
View File
@@ -0,0 +1,79 @@
"""FastAPI application assembly with CORS, routers, and APScheduler."""
import logging
from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from apscheduler.schedulers.asyncio import AsyncIOScheduler
from app.config import get_settings
from app.database import engine, init_db
from app.cache import cache
from app.routers import equipment, health, admin, rental_requests, contact
from app.services.sync_service import SyncService
from app.database import async_session
logger = logging.getLogger(__name__)
settings = get_settings()
scheduler = AsyncIOScheduler()
async def run_equipment_sync() -> None:
"""Scheduled job: run equipment sync every 6 hours."""
logger.info("Starting scheduled equipment sync")
async with async_session() as db:
sync_service = SyncService(db)
result = await sync_service.run_sync()
logger.info("Scheduled sync complete: %s", result)
@asynccontextmanager
async def lifespan(app: FastAPI):
"""Application lifespan: init DB, start scheduler, connect cache."""
# Create tables (for dev/testing; production uses Alembic)
await init_db()
# Connect Redis cache
await cache.connect()
# Start APScheduler
scheduler.add_job(
run_equipment_sync,
trigger="cron",
hour="*/6",
id="equipment_sync",
replace_existing=True,
)
scheduler.start()
logger.info("APScheduler started with equipment_sync job (every 6h)")
yield
# Shutdown
scheduler.shutdown(wait=False)
await cache.disconnect()
await engine.dispose()
app = FastAPI(
title="HMS Licht & Ton API",
version="1.0.0",
lifespan=lifespan,
)
# CORS
origins = [o.strip() for o in settings.cors_origins.split(",") if o.strip()]
app.add_middleware(
CORSMiddleware,
allow_origins=origins,
allow_methods=["GET", "POST"],
allow_headers=["*"],
allow_credentials=True,
)
# Routers
app.include_router(equipment.router)
app.include_router(health.router)
app.include_router(admin.router)
app.include_router(rental_requests.router)
app.include_router(contact.router)
+8
View File
@@ -0,0 +1,8 @@
"""Import all models for Alembic and Base metadata."""
from app.models.equipment import EquipmentCache
from app.models.rental_request import RentalRequest, RentalRequestItem
from app.models.contact import Contact
from app.models.admin_user import AdminUser
from app.models.sync_log import SyncLog
__all__ = ["EquipmentCache", "RentalRequest", "RentalRequestItem", "Contact", "AdminUser", "SyncLog"]
+12
View File
@@ -0,0 +1,12 @@
"""Admin user model."""
from sqlalchemy import Column, Integer, String, TIMESTAMP, func
from app.database import Base
class AdminUser(Base):
__tablename__ = "admin_users"
id = Column(Integer, primary_key=True, autoincrement=True)
username = Column(String(64), unique=True, nullable=False)
password_hash = Column(String(255), nullable=False)
created_at = Column(TIMESTAMP, server_default=func.now())
+16
View File
@@ -0,0 +1,16 @@
"""Contact form submission model."""
from sqlalchemy import Column, Integer, String, Text, Boolean, TIMESTAMP, func
from app.database import Base
class Contact(Base):
__tablename__ = "contacts"
id = Column(Integer, primary_key=True, autoincrement=True)
name = Column(String(255), nullable=False)
email = Column(String(255), nullable=False)
phone = Column(String(64))
message = Column(Text, nullable=False)
privacy_consent = Column(Boolean, nullable=False)
email_sent = Column(Boolean, default=False)
created_at = Column(TIMESTAMP, server_default=func.now())
+28
View File
@@ -0,0 +1,28 @@
"""Equipment cache model (imported from Rentman)."""
from sqlalchemy import Column, Integer, String, Text, Boolean, DECIMAL, JSON, Index, TIMESTAMP, func
from app.database import Base
class EquipmentCache(Base):
__tablename__ = "equipment_cache"
id = Column(Integer, primary_key=True, autoincrement=True)
rentman_id = Column(String(64), unique=True, nullable=False)
name = Column(String(255), nullable=False)
number = Column(String(64))
category = Column(String(128))
subcategory = Column(String(128))
description = Column(Text)
specifications = Column(JSON)
images = Column(JSON)
rental_price = Column(DECIMAL(10, 2))
brand = Column(String(128))
available = Column(Boolean, default=True)
created_at = Column(TIMESTAMP, server_default=func.now())
updated_at = Column(TIMESTAMP, server_default=func.now(), onupdate=func.now())
__table_args__ = (
Index("idx_equipment_category", "category"),
Index("idx_equipment_name", "name"),
Index("idx_equipment_number", "number"),
)
+50
View File
@@ -0,0 +1,50 @@
"""Rental request and rental request item models."""
from sqlalchemy import Column, Integer, String, Text, Date, TIMESTAMP, ForeignKey, func, Index
from sqlalchemy.orm import relationship
from app.database import Base
class RentalRequest(Base):
__tablename__ = "rental_requests"
id = Column(Integer, primary_key=True, autoincrement=True)
reference_number = Column(String(32), unique=True, nullable=False)
event_name = Column(String(255), nullable=False)
date_start = Column(Date, nullable=False)
date_end = Column(Date, nullable=False)
location = Column(String(255))
person_count = Column(Integer)
contact_name = Column(String(255), nullable=False)
contact_company = Column(String(255))
contact_email = Column(String(255), nullable=False)
contact_phone = Column(String(64))
contact_street = Column(String(255))
contact_postalcode = Column(String(16))
contact_city = Column(String(128))
message = Column(Text)
status = Column(String(32), default="pending")
rentman_request_id = Column(String(64))
rentman_sync_status = Column(String(32), default="pending")
rentman_sync_error = Column(Text)
created_at = Column(TIMESTAMP, server_default=func.now())
items = relationship("RentalRequestItem", back_populates="rental_request", cascade="all, delete-orphan")
__table_args__ = (Index("idx_rental_status", "status"),)
class RentalRequestItem(Base):
__tablename__ = "rental_request_items"
id = Column(Integer, primary_key=True, autoincrement=True)
rental_request_id = Column(Integer, ForeignKey("rental_requests.id", ondelete="CASCADE"), nullable=False)
equipment_id = Column(Integer, ForeignKey("equipment_cache.id"))
equipment_name = Column(String(255))
rentman_equipment_id = Column(String(64))
quantity = Column(Integer, nullable=False, default=1)
unit_price = Column(String(20))
rentman_sync_status = Column(String(32), default="pending")
rental_request = relationship("RentalRequest", back_populates="items")
__table_args__ = (Index("idx_rental_items_request", "rental_request_id"),)
+18
View File
@@ -0,0 +1,18 @@
"""Sync log model for equipment import tracking."""
from sqlalchemy import Column, Integer, String, Text, TIMESTAMP, func, Index
from app.database import Base
class SyncLog(Base):
__tablename__ = "sync_log"
id = Column(Integer, primary_key=True, autoincrement=True)
sync_type = Column(String(32), nullable=False, default="equipment")
status = Column(String(32), nullable=False)
items_processed = Column(Integer, default=0)
items_failed = Column(Integer, default=0)
error_message = Column(Text)
started_at = Column(TIMESTAMP, server_default=func.now())
completed_at = Column(TIMESTAMP)
__table_args__ = (Index("idx_sync_log_started", "started_at"),)
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")
+1
View File
@@ -0,0 +1 @@
"""Schema exports."""
Binary file not shown.
Binary file not shown.
+16
View File
@@ -0,0 +1,16 @@
"""Pydantic schemas for admin auth."""
from pydantic import BaseModel
class LoginRequest(BaseModel):
username: str
password: str
class TokenResponse(BaseModel):
access_token: str
token_type: str = "bearer"
class AdminInfo(BaseModel):
username: str
+21
View File
@@ -0,0 +1,21 @@
"""Pydantic schema for contact form."""
from pydantic import BaseModel, EmailStr, field_validator
class ContactCreate(BaseModel):
name: str
email: EmailStr
phone: str | None = None
message: str
privacy_consent: bool
@field_validator("privacy_consent")
@classmethod
def consent_must_be_true(cls, v: bool) -> bool:
if not v:
raise ValueError("privacy_consent must be True")
return v
class ContactResponse(BaseModel):
success: bool
+42
View File
@@ -0,0 +1,42 @@
"""Pydantic schemas for equipment endpoints."""
from pydantic import BaseModel
from decimal import Decimal
from typing import Any
class EquipmentItem(BaseModel):
id: int
rentman_id: str
name: str
number: str | None = None
category: str | None = None
description: str | None = None
image_url: str | None = None
rental_price: Decimal | None = None
available: bool = True
model_config = {"from_attributes": True}
class EquipmentDetail(BaseModel):
id: int
rentman_id: str
name: str
number: str | None = None
category: str | None = None
description: str | None = None
specifications: dict[str, Any] | None = None
images: list[str] | None = None
rental_price: Decimal | None = None
brand: str | None = None
available: bool = True
model_config = {"from_attributes": True}
class PaginatedResponse(BaseModel):
items: list[EquipmentItem]
total: int
page: int
page_size: int
total_pages: int
+52
View File
@@ -0,0 +1,52 @@
"""Pydantic schemas for rental requests."""
from pydantic import BaseModel, EmailStr, field_validator
from datetime import date
class RentalRequestItemCreate(BaseModel):
equipment_id: int
quantity: int = 1
@field_validator("quantity")
@classmethod
def quantity_positive(cls, v: int) -> int:
if v < 1:
raise ValueError("quantity must be >= 1")
return v
class RentalRequestCreate(BaseModel):
event_name: str
date_start: date
date_end: date
location: str | None = None
person_count: int | None = None
contact_name: str
contact_company: str | None = None
contact_email: EmailStr
contact_phone: str | None = None
contact_street: str | None = None
contact_postalcode: str | None = None
contact_city: str | None = None
message: str | None = None
items: list[RentalRequestItemCreate]
@field_validator("date_end")
@classmethod
def date_end_after_start(cls, v: date, values) -> date:
start = values.data.get("date_start")
if start and v < start:
raise ValueError("date_end must be >= date_start")
return v
@field_validator("items")
@classmethod
def items_not_empty(cls, v: list) -> list:
if len(v) == 0:
raise ValueError("items must not be empty")
return v
class RentalRequestResponse(BaseModel):
reference_number: str
status: str
+28
View File
@@ -0,0 +1,28 @@
"""Pydantic schemas for sync status and log."""
from pydantic import BaseModel
from datetime import datetime
from typing import Any
class SyncStatus(BaseModel):
last_sync: datetime | None = None
items_processed: int = 0
status: str = "never"
class SyncLogEntry(BaseModel):
id: int
sync_type: str
status: str
items_processed: int
items_failed: int
error_message: str | None = None
started_at: datetime
completed_at: datetime | None = None
model_config = {"from_attributes": True}
class SyncTriggerResponse(BaseModel):
sync_id: int
status: str
View File
+110
View File
@@ -0,0 +1,110 @@
"""Email service using aiosmtplib for contact and rental confirmation emails."""
import logging
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
from typing import Any
import aiosmtplib
from app.config import get_settings
logger = logging.getLogger(__name__)
settings = get_settings()
class EmailService:
"""Send transactional emails via SMTP."""
async def send_email(
self,
to_addr: str,
subject: str,
html_body: str,
text_body: str = "",
reply_to: str | None = None,
) -> bool:
"""Send an email via aiosmtplib. Returns True on success."""
msg = MIMEMultipart("alternative")
msg["From"] = settings.smtp_from
msg["To"] = to_addr
msg["Subject"] = subject
if reply_to:
msg["Reply-To"] = reply_to
msg.attach(MIMEText(text_body or html_body, "plain"))
msg.attach(MIMEText(html_body, "html"))
try:
await aiosmtplib.send(
msg,
hostname=settings.smtp_host,
port=settings.smtp_port,
username=settings.smtp_user,
password=settings.smtp_password,
start_tls=True,
)
return True
except Exception as exc:
logger.error("Email send failed to %s: %s", to_addr, exc)
return False
async def send_contact_email(self, contact_data: dict[str, Any]) -> bool:
"""Send contact form data to info@hms-licht-ton.de."""
html = f"""
<h2>Neue Kontaktanfrage</h2>
<p><strong>Name:</strong> {contact_data.get('name', '')}</p>
<p><strong>Email:</strong> {contact_data.get('email', '')}</p>
<p><strong>Telefon:</strong> {contact_data.get('phone', '')}</p>
<p><strong>Nachricht:</strong></p>
<p>{contact_data.get('message', '')}</p>
"""
text = (
f"Neue Kontaktanfrage\n"
f"Name: {contact_data.get('name', '')}\n"
f"Email: {contact_data.get('email', '')}\n"
f"Telefon: {contact_data.get('phone', '')}\n"
f"Nachricht: {contact_data.get('message', '')}\n"
)
return await self.send_email(
to_addr=settings.smtp_from,
subject="Neue Kontaktanfrage ueber hms-licht-ton.de",
html_body=html,
text_body=text,
reply_to=contact_data.get("email"),
)
async def send_rental_confirmation(
self,
to_addr: str,
reference_number: str,
event_name: str,
items: list[dict[str, Any]],
) -> bool:
"""Send rental request confirmation to customer."""
items_html = "".join(
f"<li>{item.get('equipment_name', 'Unbekannt')} - Menge: {item.get('quantity', 1)}</li>"
for item in items
)
html = f"""
<h2>Mietanfrage bestätigt {reference_number}</h2>
<p>Vielen Dank für Ihre Mietanfrage bei HMS Licht &amp; Ton.</p>
<p><strong>Veranstaltung:</strong> {event_name}</p>
<p><strong>Referenznummer:</strong> {reference_number}</p>
<h3>Artikel:</h3>
<ul>{items_html}</ul>
<p>Wir melden uns in Kürze bei Ihnen.</p>
<p>Ihr HMS Licht &amp; Ton Team</p>
"""
text = (
f"Mietanfrage bestaetigt - {reference_number}\n"
f"Veranstaltung: {event_name}\n"
f"Referenznummer: {reference_number}\n"
f"Artikel:\n"
+ "\n".join(
f"- {item.get('equipment_name', 'Unbekannt')}: {item.get('quantity', 1)}"
for item in items
)
)
return await self.send_email(
to_addr=to_addr,
subject=f"Mietanfrage bestaetigt {reference_number}",
html_body=html,
text_body=text,
)
+146
View File
@@ -0,0 +1,146 @@
"""Rentman API client wrapper for equipment import and request submission."""
import httpx
import logging
import re
from typing import Any
from app.config import get_settings
logger = logging.getLogger(__name__)
settings = get_settings()
RENTMAN_BASE_URL = "https://api.rentman.net"
class RentmanService:
"""Wrapper around the Rentman REST API using httpx."""
def __init__(self, token: str | None = None) -> None:
self._token = token or settings.rentman_api_token
self._base_url = RENTMAN_BASE_URL
def _headers(self) -> dict[str, str]:
return {
"Authorization": f"Bearer {self._token}",
"Content-Type": "application/json",
}
async def get_equipment_page(self, limit: int = 100, offset: int = 0) -> dict[str, Any]:
"""Fetch a single page of equipment from Rentman.
Returns the raw JSON response dict with 'data' and optional 'itemCount'.
"""
url = f"{self._base_url}/equipment"
params = {"limit": limit, "offset": offset}
async with httpx.AsyncClient(timeout=30.0) as client:
resp = await client.get(url, headers=self._headers(), params=params)
resp.raise_for_status()
return resp.json()
async def get_all_equipment(self, limit: int = 100) -> list[dict[str, Any]]:
"""Paginate through all equipment pages until data is empty."""
all_items: list[dict[str, Any]] = []
offset = 0
while True:
page = await self.get_equipment_page(limit=limit, offset=offset)
data = page.get("data", [])
if not data:
break
all_items.extend(data)
offset += limit
return all_items
async def create_project_request(self, payload: dict[str, Any]) -> dict[str, Any]:
"""POST /projectrequests to create a new project request in Rentman."""
url = f"{self._base_url}/projectrequests"
async with httpx.AsyncClient(timeout=30.0) as client:
resp = await client.post(url, headers=self._headers(), json=payload)
resp.raise_for_status()
return resp.json()
async def add_equipment_to_request(
self, request_id: str, equipment_payload: dict[str, Any]
) -> dict[str, Any]:
"""POST /projectrequests/{id}/projectrequestequipment for a single item."""
url = f"{self._base_url}/projectrequests/{request_id}/projectrequestequipment"
async with httpx.AsyncClient(timeout=30.0) as client:
resp = await client.post(url, headers=self._headers(), json=equipment_payload)
resp.raise_for_status()
return resp.json()
@staticmethod
def transform_equipment(raw: dict[str, Any]) -> dict[str, Any]:
"""Map a raw Rentman equipment object to equipment_cache schema."""
images = raw.get("images") or raw.get("files") or []
if isinstance(images, list):
image_urls = [
img.get("url", img.get("filename", "")) if isinstance(img, dict) else str(img)
for img in images
]
else:
image_urls = []
group = raw.get("equipment_group") or {}
category = group.get("name", "") if isinstance(group, dict) else str(group or "")
return {
"rentman_id": str(raw.get("id", "")),
"name": raw.get("name", ""),
"number": raw.get("number") or raw.get("code", ""),
"category": category,
"subcategory": raw.get("subcategory", ""),
"description": raw.get("description", ""),
"specifications": raw.get("specifications", {}),
"images": image_urls,
"rental_price": raw.get("rental_price"),
"brand": raw.get("brand", ""),
"available": raw.get("available", True),
}
@staticmethod
def build_project_request_payload(data: dict[str, Any]) -> dict[str, Any]:
"""Map frontend rental request data to Rentman POST /projectrequests payload."""
date_start = data.get("date_start")
date_end = data.get("date_end")
contact_name = data.get("contact_name", "")
parts = contact_name.strip().split(" ", 1)
first_name = parts[0] if parts else ""
last_name = parts[1] if len(parts) > 1 else ""
street = data.get("contact_street", "")
house_number = ""
if street:
match = re.search(r"(\d+[a-zA-Z]*)", street)
if match:
house_number = match.group(1)
return {
"name": data.get("event_name", ""),
"planperiod_start": f"{date_start}T08:00:00+02:00" if date_start else None,
"planperiod_end": f"{date_end}T02:00:00+02:00" if date_end else None,
"usageperiod_start": f"{date_start}T18:00:00+02:00" if date_start else None,
"usageperiod_end": f"{date_end}T23:59:00+02:00" if date_end else None,
"contact_name": data.get("contact_company") or data.get("contact_name", ""),
"contact_person_first_name": first_name,
"contact_person_lastname": last_name,
"contact_person_email": data.get("contact_email", ""),
"contact_person_phone": data.get("contact_phone", ""),
"location_name": data.get("location", ""),
"location_mailing_street": street,
"location_mailing_number": house_number,
"location_mailing_postalcode": data.get("contact_postalcode", ""),
"location_mailing_city": data.get("contact_city") or data.get("location", ""),
"remark": data.get("message", ""),
}
@staticmethod
def build_equipment_payload(item: dict[str, Any]) -> dict[str, Any]:
"""Map a single rental request item to Rentman projectrequestequipment payload."""
return {
"name": item.get("equipment_name", ""),
"quantity": item.get("quantity", 1),
"quantity_total": item.get("quantity", 1),
"unit_price": item.get("unit_price", 0),
"linked_equipment": f"/equipment/{item.get('rentman_equipment_id', '')}" if item.get("rentman_equipment_id") else None,
}
+149
View File
@@ -0,0 +1,149 @@
"""Equipment sync service: import from Rentman, upsert into DB, invalidate cache."""
import logging
from datetime import datetime, timezone
from typing import Any
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.equipment import EquipmentCache
from app.models.sync_log import SyncLog
from app.services.rentman_service import RentmanService
from app.cache import cache
logger = logging.getLogger(__name__)
class SyncService:
"""Orchestrates equipment import from Rentman into the local database."""
def __init__(self, db: AsyncSession, rentman: RentmanService | None = None) -> None:
self.db = db
self.rentman = rentman or RentmanService()
async def run_sync(self) -> dict[str, Any]:
"""Execute a full equipment sync.
1. Create sync_log entry (status=running)
2. Paginate GET /equipment from Rentman
3. Upsert each item into equipment_cache
4. Invalidate Redis cache (equipment:*)
5. Update sync_log (status=completed or failed)
Returns dict with sync_id, items_processed, status.
"""
log_entry = SyncLog(
sync_type="equipment",
status="running",
started_at=datetime.now(timezone.utc),
)
self.db.add(log_entry)
await self.db.commit()
await self.db.refresh(log_entry)
sync_id = log_entry.id
items_processed = 0
items_failed = 0
error_message: str | None = None
try:
all_equipment = await self.rentman.get_all_equipment(limit=100)
for raw_item in all_equipment:
try:
transformed = RentmanService.transform_equipment(raw_item)
await self._upsert_equipment(transformed)
items_processed += 1
except Exception as exc:
logger.warning("Failed to upsert equipment item: %s", exc)
items_failed += 1
await self.db.commit()
# Invalidate Redis cache
await cache.delete_pattern("equipment:*")
status_val = "completed"
except Exception as exc:
logger.error("Equipment sync failed: %s", exc)
error_message = str(exc)
status_val = "failed"
# Update log entry
log_entry.status = status_val
log_entry.items_processed = items_processed
log_entry.items_failed = items_failed
log_entry.error_message = error_message
log_entry.completed_at = datetime.now(timezone.utc)
await self.db.commit()
return {
"sync_id": sync_id,
"items_processed": items_processed,
"items_failed": items_failed,
"status": status_val,
}
async def _upsert_equipment(self, data: dict[str, Any]) -> None:
"""Insert or update a single equipment row by rentman_id."""
result = await self.db.execute(
select(EquipmentCache).where(EquipmentCache.rentman_id == data["rentman_id"])
)
existing = result.scalar_one_or_none()
if existing:
existing.name = data["name"]
existing.number = data.get("number", "")
existing.category = data.get("category", "")
existing.subcategory = data.get("subcategory", "")
existing.description = data.get("description", "")
existing.specifications = data.get("specifications")
existing.images = data.get("images")
existing.rental_price = data.get("rental_price")
existing.brand = data.get("brand", "")
existing.available = data.get("available", True)
else:
new_item = EquipmentCache(
rentman_id=data["rentman_id"],
name=data["name"],
number=data.get("number", ""),
category=data.get("category", ""),
subcategory=data.get("subcategory", ""),
description=data.get("description", ""),
specifications=data.get("specifications"),
images=data.get("images"),
rental_price=data.get("rental_price"),
brand=data.get("brand", ""),
available=data.get("available", True),
)
self.db.add(new_item)
async def get_last_sync(self) -> dict[str, Any]:
"""Return the most recent sync_log entry summary."""
result = await self.db.execute(
select(SyncLog).order_by(SyncLog.started_at.desc()).limit(1)
)
log = result.scalar_one_or_none()
if not log:
return {"last_sync": None, "items_processed": 0, "status": "never"}
return {
"last_sync": log.started_at,
"items_processed": log.items_processed,
"status": log.status,
}
async def get_sync_log_paginated(self, page: int = 1, page_size: int = 20) -> dict[str, Any]:
"""Return paginated sync log entries."""
offset = (page - 1) * page_size
result = await self.db.execute(
select(SyncLog)
.order_by(SyncLog.started_at.desc())
.offset(offset)
.limit(page_size)
)
logs = result.scalars().all()
count_result = await self.db.execute(select(SyncLog))
total = len(count_result.scalars().all())
return {
"items": logs,
"total": total,
"page": page,
"page_size": page_size,
"total_pages": (total + page_size - 1) // page_size if page_size > 0 else 0,
}