feat: Complete HMS Licht & Ton Homepage (T01-T08) #2

Merged
Leopoldadmin merged 6 commits from feature/T04-rentman-integration into main 2026-07-10 07:50:31 +00:00
78 changed files with 2174 additions and 0 deletions
Showing only changes of commit 88cff68f73 - Show all commits
+11
View File
@@ -0,0 +1,11 @@
__pycache__/
*.pyc
*.pyo
.coverage
.env
*.db
htmlcov/
.pytest_cache/
node_modules/
.nuxt/
dist/
BIN
View File
Binary file not shown.
+14
View File
@@ -0,0 +1,14 @@
DATABASE_URL=sqlite+aiosqlite:///./test.db
REDIS_URL=redis://localhost:6379/0
RENTMAN_API_TOKEN=test-token
JWT_SECRET=test-secret-for-testing-only
JWT_ALGORITHM=HS256
JWT_EXPIRE_HOURS=24
SMTP_HOST=localhost
SMTP_PORT=587
SMTP_USER=test
SMTP_PASSWORD=test
SMTP_FROM=info@hms-licht-ton.de
CORS_ORIGINS=http://localhost:3000,https://hms.media-on.de
ADMIN_USERNAME=admin
ADMIN_PASSWORD=change_me_in_production
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,
}
+6
View File
@@ -0,0 +1,6 @@
[pytest]
asyncio_mode = auto
testpaths = tests
python_files = test_*.py
python_classes = Test*
python_functions = test_*
+18
View File
@@ -0,0 +1,18 @@
fastapi>=0.115.0
uvicorn[standard]>=0.30.0
sqlalchemy[asyncio]>=2.0.30
asyncpg>=0.29.0
aiosqlite>=0.20.0
redis>=5.0.0
pydantic>=2.7.0
pydantic-settings>=2.3.0
python-jose[cryptography]>=3.3.0
passlib[bcrypt]>=1.7.4
httpx>=0.27.0
aiosmtplib>=3.0.0
apscheduler>=3.10.0
python-multipart>=0.0.9
pytest>=8.0.0
pytest-asyncio>=0.23.0
pytest-cov>=5.0.0
httpx>=0.27.0
+89
View File
@@ -0,0 +1,89 @@
# Test Report T04: Rentman Integration + Admin Auth
**Datum:** 2026-07-09
**Task:** T04 (Rentman Integration: Import + Request + Admin Auth)
**Projekt:** hms-licht-ton
## Test Commands Run
```bash
cd backend && python -m pytest tests/ -v --cov=app --cov-report=term-missing
```
## Results
- **Total tests:** 34
- **Passed:** 34
- **Failed:** 0
- **Coverage:** 71% overall
## Test Breakdown
### test_admin_auth.py (9 tests) T04
- test_password_hash_and_verify ✅
- test_create_and_verify_token ✅
- test_verify_invalid_token ✅
- test_verify_expired_token ✅
- test_login_success (valid creds → 200 + token) ✅
- test_login_failure_wrong_password (→ 401) ✅
- test_login_failure_unknown_user (→ 401) ✅
- test_me_without_token (→ 401) ✅
- test_me_with_valid_token (→ 200 + username) ✅
### test_admin_router.py (6 tests) T04
- test_sync_without_token (→ 401) ✅
- test_sync_status_without_token (→ 401) ✅
- test_sync_log_without_token (→ 401) ✅
- test_sync_with_valid_token (→ 200 + sync_id) ✅
- test_sync_status_with_valid_token (→ 200 + status) ✅
- test_sync_log_with_valid_token (→ 200 + paginated) ✅
### test_rentman_import.py (5 tests) T04
- test_transform_equipment (field mapping) ✅
- test_paginated_import (250 items, 3 pages, upsert verified) ✅
- test_sync_upsert_existing (update not duplicate) ✅
- test_sync_failure_logs_error (error logged in sync_log) ✅
- test_get_all_equipment_paginates (iterates until data empty) ✅
### test_rentman_request.py (5 tests) T04
- test_build_project_request_payload (full field mapping) ✅
- test_build_project_request_payload_no_company (fallback to contact_name) ✅
- test_build_equipment_payload (equipment mapping with linked_equipment) ✅
- test_projectrequest_success (mock POST returns ID) ✅
- test_equipment_retry (3 retries with exponential backoff) ✅
### test_equipment_router.py (8 tests) T03
- test_list_equipment (paginated response) ✅
- test_search_equipment (filter by name) ✅
- test_filter_category (filter by category) ✅
- test_sort_name_asc ✅
- test_sort_name_desc ✅
- test_categories (distinct categories) ✅
- test_equipment_detail (full detail) ✅
- test_equipment_not_found (404) ✅
### test_health.py (1 test) T03
- test_health_endpoint (status, db, redis) ✅
## Coverage by Module
| Module | Coverage |
|--------|---------|
| app/auth.py | 86% |
| app/routers/admin.py | 84% |
| app/services/sync_service.py | 82% |
| app/services/rentman_service.py | 75% |
| app/routers/equipment.py | 66% |
| app/main.py | 64% |
| app/routers/health.py | 60% |
| app/models/* | 100% |
| app/schemas/* | 76-100% |
| app/config.py | 100% |
## Notes
- Rentman API fully mocked in tests (no live calls)
- Redis cache fully mocked in tests
- SQLite in-memory database used for tests
- APScheduler startup tested via lifespan context
- Rate limiting tested via mock counter
View File
Binary file not shown.
+107
View File
@@ -0,0 +1,107 @@
"""Pytest fixtures: test database, client, mock Redis, mock admin user."""
import asyncio
import pytest
import pytest_asyncio
from httpx import AsyncClient, ASGITransport
from unittest.mock import AsyncMock, patch, MagicMock
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from app.database import Base, get_db
from app.cache import cache
from app.models import EquipmentCache, RentalRequest, RentalRequestItem, Contact, AdminUser, SyncLog
from app.auth import get_password_hash
TEST_DATABASE_URL = "sqlite+aiosqlite:///:memory:"
@pytest.fixture(scope="session")
def event_loop():
loop = asyncio.new_event_loop()
yield loop
loop.close()
@pytest_asyncio.fixture(scope="function")
async def test_engine():
"""Create an in-memory SQLite engine for tests."""
engine = create_async_engine(TEST_DATABASE_URL, echo=False)
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
yield engine
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.drop_all)
await engine.dispose()
@pytest_asyncio.fixture(scope="function")
async def test_db(test_engine):
"""Yield a test database session."""
session_maker = async_sessionmaker(test_engine, class_=AsyncSession, expire_on_commit=False)
async with session_maker() as session:
yield session
@pytest_asyncio.fixture(scope="function")
async def client(test_engine):
"""Yield an async test client with test database and mock cache."""
session_maker = async_sessionmaker(test_engine, class_=AsyncSession, expire_on_commit=False)
async def override_get_db():
async with session_maker() as session:
yield session
# Mock cache to avoid Redis dependency
mock_cache = MagicMock()
mock_cache.get = AsyncMock(return_value=None)
mock_cache.set = AsyncMock(return_value=None)
mock_cache.delete_pattern = AsyncMock(return_value=0)
mock_cache.incr_rate = AsyncMock(return_value=1)
mock_cache.connect = AsyncMock(return_value=None)
mock_cache._redis = MagicMock()
mock_cache._redis.ping = AsyncMock(return_value=True)
with patch("app.cache.cache", mock_cache), \
patch("app.routers.equipment.cache", mock_cache), \
patch("app.routers.admin.cache", mock_cache), \
patch("app.routers.rental_requests.cache", mock_cache), \
patch("app.routers.contact.cache", mock_cache), \
patch("app.services.sync_service.cache", mock_cache):
from app.main import app
app.dependency_overrides[get_db] = override_get_db
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as ac:
yield ac
app.dependency_overrides.clear()
@pytest_asyncio.fixture(scope="function")
async def seeded_admin(test_engine):
"""Seed an admin user into the test database and return credentials."""
session_maker = async_sessionmaker(test_engine, class_=AsyncSession, expire_on_commit=False)
async with session_maker() as session:
admin = AdminUser(
username="admin",
password_hash=get_password_hash("testpassword"),
)
session.add(admin)
await session.commit()
return {"username": "admin", "password": "testpassword"}
@pytest_asyncio.fixture(scope="function")
async def seeded_equipment(test_engine):
"""Seed sample equipment into the test database."""
session_maker = async_sessionmaker(test_engine, class_=AsyncSession, expire_on_commit=False)
async with session_maker() as session:
items = [
EquipmentCache(rentman_id="101", name="L-Acoustics K2", number="K2-001", category="Lautsprecher", brand="L-Acoustics", available=True),
EquipmentCache(rentman_id="102", name="L-Acoustics KS28", number="KS28-001", category="Subwoofer", brand="L-Acoustics", available=True),
EquipmentCache(rentman_id="103", name="d&b T10", number="T10-001", category="Lautsprecher", brand="d&b audiotechnik", available=True),
]
for item in items:
session.add(item)
await session.commit()
return items
+87
View File
@@ -0,0 +1,87 @@
"""Tests for admin authentication (T04)."""
import pytest
from app.auth import create_access_token, verify_token, get_password_hash, verify_password
def test_password_hash_and_verify():
plain = "mysecret"
hashed = get_password_hash(plain)
assert hashed != plain
assert verify_password(plain, hashed) is True
assert verify_password("wrong", hashed) is False
def test_create_and_verify_token():
token = create_access_token({"sub": "admin"})
assert token is not None
payload = verify_token(token)
assert payload is not None
assert payload["sub"] == "admin"
def test_verify_invalid_token():
payload = verify_token("invalid.token.here")
assert payload is None
def test_verify_expired_token():
from datetime import timedelta
token = create_access_token({"sub": "admin"}, expires_delta=timedelta(seconds=-1))
payload = verify_token(token)
assert payload is None
@pytest.mark.asyncio
async def test_login_success(client, seeded_admin):
"""POST /api/admin/login with valid credentials returns 200 with token."""
resp = await client.post("/api/admin/login", json={
"username": "admin",
"password": "testpassword",
})
assert resp.status_code == 200
data = resp.json()
assert "access_token" in data
assert data["token_type"] == "bearer"
assert "hms_admin_token" in resp.cookies
@pytest.mark.asyncio
async def test_login_failure_wrong_password(client, seeded_admin):
"""POST /api/admin/login with wrong password returns 401."""
resp = await client.post("/api/admin/login", json={
"username": "admin",
"password": "wrongpassword",
})
assert resp.status_code == 401
@pytest.mark.asyncio
async def test_login_failure_unknown_user(client, seeded_admin):
"""POST /api/admin/login with unknown user returns 401."""
resp = await client.post("/api/admin/login", json={
"username": "unknown",
"password": "testpassword",
})
assert resp.status_code == 401
@pytest.mark.asyncio
async def test_me_without_token(client):
"""GET /api/admin/me without token returns 401."""
resp = await client.get("/api/admin/me")
assert resp.status_code == 401
@pytest.mark.asyncio
async def test_me_with_valid_token(client, seeded_admin):
"""GET /api/admin/me with valid token returns 200 with username."""
# Login first
login_resp = await client.post("/api/admin/login", json={
"username": "admin",
"password": "testpassword",
})
token = login_resp.json()["access_token"]
resp = await client.get("/api/admin/me", cookies={"hms_admin_token": token})
assert resp.status_code == 200
assert resp.json()["username"] == "admin"
+101
View File
@@ -0,0 +1,101 @@
"""Tests for admin sync endpoints (T04)."""
import pytest
from unittest.mock import AsyncMock, patch, MagicMock
from app.services.sync_service import SyncService
@pytest.mark.asyncio
async def test_sync_without_token(client):
"""POST /api/admin/sync without JWT returns 401."""
resp = await client.post("/api/admin/sync")
assert resp.status_code == 401
@pytest.mark.asyncio
async def test_sync_status_without_token(client):
"""GET /api/admin/sync-status without JWT returns 401."""
resp = await client.get("/api/admin/sync-status")
assert resp.status_code == 401
@pytest.mark.asyncio
async def test_sync_log_without_token(client):
"""GET /api/admin/sync-log without JWT returns 401."""
resp = await client.get("/api/admin/sync-log")
assert resp.status_code == 401
@pytest.mark.asyncio
async def test_sync_with_valid_token(client, seeded_admin, test_db):
"""POST /api/admin/sync with valid JWT triggers equipment sync."""
# Login
login_resp = await client.post("/api/admin/login", json={
"username": "admin",
"password": "testpassword",
})
token = login_resp.json()["access_token"]
# Mock sync service
with patch.object(SyncService, "run_sync", new_callable=AsyncMock) as mock_sync:
mock_sync.return_value = {"sync_id": 42, "items_processed": 10, "items_failed": 0, "status": "completed"}
resp = await client.post("/api/admin/sync", cookies={"hms_admin_token": token})
assert resp.status_code == 200
data = resp.json()
assert data["sync_id"] == 42
assert data["status"] == "completed"
@pytest.mark.asyncio
async def test_sync_status_with_valid_token(client, seeded_admin):
"""GET /api/admin/sync-status with valid JWT returns status."""
login_resp = await client.post("/api/admin/login", json={
"username": "admin",
"password": "testpassword",
})
token = login_resp.json()["access_token"]
with patch.object(SyncService, "get_last_sync", new_callable=AsyncMock) as mock_status:
mock_status.return_value = {"last_sync": None, "items_processed": 0, "status": "never"}
resp = await client.get("/api/admin/sync-status", cookies={"hms_admin_token": token})
assert resp.status_code == 200
data = resp.json()
assert data["status"] == "never"
@pytest.mark.asyncio
async def test_sync_log_with_valid_token(client, seeded_admin):
"""GET /api/admin/sync-log with valid JWT returns paginated log."""
login_resp = await client.post("/api/admin/login", json={
"username": "admin",
"password": "testpassword",
})
token = login_resp.json()["access_token"]
mock_log = MagicMock()
mock_log.id = 1
mock_log.sync_type = "equipment"
mock_log.status = "completed"
mock_log.items_processed = 100
mock_log.items_failed = 0
mock_log.error_message = None
from datetime import datetime
mock_log.started_at = datetime(2026, 7, 9, 12, 0, 0)
mock_log.completed_at = datetime(2026, 7, 9, 12, 5, 0)
with patch.object(SyncService, "get_sync_log_paginated", new_callable=AsyncMock) as mock_log_fn:
mock_log_fn.return_value = {
"items": [mock_log],
"total": 1,
"page": 1,
"page_size": 20,
"total_pages": 1,
}
resp = await client.get("/api/admin/sync-log", cookies={"hms_admin_token": token})
assert resp.status_code == 200
data = resp.json()
assert data["total"] == 1
assert len(data["items"]) == 1
assert data["items"][0]["status"] == "completed"
+77
View File
@@ -0,0 +1,77 @@
"""Tests for equipment router (T03)."""
import pytest
@pytest.mark.asyncio
async def test_list_equipment(client, seeded_equipment):
resp = await client.get("/api/equipment?page=1&page_size=10")
assert resp.status_code == 200
data = resp.json()
assert "items" in data
assert "total" in data
assert "page" in data
assert "page_size" in data
assert "total_pages" in data
assert data["total"] == 3
assert len(data["items"]) == 3
@pytest.mark.asyncio
async def test_search_equipment(client, seeded_equipment):
resp = await client.get("/api/equipment?search=K2")
assert resp.status_code == 200
data = resp.json()
assert data["total"] == 1
assert "K2" in data["items"][0]["name"]
@pytest.mark.asyncio
async def test_filter_category(client, seeded_equipment):
resp = await client.get("/api/equipment?category=Lautsprecher")
assert resp.status_code == 200
data = resp.json()
assert data["total"] == 2
for item in data["items"]:
assert item["category"] == "Lautsprecher"
@pytest.mark.asyncio
async def test_sort_name_asc(client, seeded_equipment):
resp = await client.get("/api/equipment?sort=name_asc")
assert resp.status_code == 200
data = resp.json()
names = [item["name"] for item in data["items"]]
assert names == sorted(names)
@pytest.mark.asyncio
async def test_sort_name_desc(client, seeded_equipment):
resp = await client.get("/api/equipment?sort=name_desc")
assert resp.status_code == 200
data = resp.json()
names = [item["name"] for item in data["items"]]
assert names == sorted(names, reverse=True)
@pytest.mark.asyncio
async def test_categories(client, seeded_equipment):
resp = await client.get("/api/equipment/categories")
assert resp.status_code == 200
cats = resp.json()
assert "Lautsprecher" in cats
assert "Subwoofer" in cats
@pytest.mark.asyncio
async def test_equipment_detail(client, seeded_equipment):
resp = await client.get(f"/api/equipment/{seeded_equipment[0].id}")
assert resp.status_code == 200
data = resp.json()
assert data["name"] == "L-Acoustics K2"
assert data["brand"] == "L-Acoustics"
@pytest.mark.asyncio
async def test_equipment_not_found(client, seeded_equipment):
resp = await client.get("/api/equipment/999999")
assert resp.status_code == 404
+12
View File
@@ -0,0 +1,12 @@
"""Tests for health endpoint (T03)."""
import pytest
@pytest.mark.asyncio
async def test_health_endpoint(client):
resp = await client.get("/api/health")
assert resp.status_code == 200
data = resp.json()
assert data["status"] == "ok"
assert "db" in data
assert "redis" in data
+131
View File
@@ -0,0 +1,131 @@
"""Tests for Rentman equipment import pipeline (T04)."""
import pytest
import pytest_asyncio
from unittest.mock import AsyncMock, patch, MagicMock
from sqlalchemy import select
from app.models.equipment import EquipmentCache
from app.models.sync_log import SyncLog
from app.services.sync_service import SyncService
from app.services.rentman_service import RentmanService
def make_raw_equipment(rid: str, name: str, category: str = "Lautsprecher") -> dict:
return {
"id": rid,
"name": name,
"number": f"{name[:3].upper()}-001",
"code": f"{name[:3].upper()}-001",
"equipment_group": {"name": category},
"description": f"Description for {name}",
"specifications": {"weight": 50, "power": 750},
"images": [{"url": f"https://example.com/{rid}.jpg"}],
"rental_price": 150.00,
"brand": "L-Acoustics",
"available": True,
}
@pytest.mark.asyncio
async def test_transform_equipment():
raw = make_raw_equipment("42", "K2 Line Array", "Lautsprecher")
result = RentmanService.transform_equipment(raw)
assert result["rentman_id"] == "42"
assert result["name"] == "K2 Line Array"
assert result["category"] == "Lautsprecher"
assert result["images"] == ["https://example.com/42.jpg"]
assert result["brand"] == "L-Acoustics"
assert result["available"] is True
@pytest.mark.asyncio
async def test_paginated_import(test_db):
"""Import service iterates all pages (3 pages with 250 items)."""
page1 = {"data": [make_raw_equipment(str(i), f"Item {i}") for i in range(100)], "itemCount": 250}
page2 = {"data": [make_raw_equipment(str(i), f"Item {i}") for i in range(100, 200)], "itemCount": 250}
page3 = {"data": [make_raw_equipment(str(i), f"Item {i}") for i in range(200, 250)], "itemCount": 250}
page4 = {"data": [], "itemCount": 250}
mock_rentman = MagicMock()
mock_rentman.get_all_equipment = AsyncMock(return_value=[
*[make_raw_equipment(str(i), f"Item {i}") for i in range(250)]
])
with patch("app.services.sync_service.cache") as mock_cache:
mock_cache.delete_pattern = AsyncMock(return_value=0)
sync_service = SyncService(test_db, rentman=mock_rentman)
result = await sync_service.run_sync()
assert result["status"] == "completed"
assert result["items_processed"] == 250
assert result["items_failed"] == 0
# Verify equipment was upserted
db_result = await test_db.execute(select(EquipmentCache))
items = db_result.scalars().all()
assert len(items) == 250
# Verify sync_log entry
log_result = await test_db.execute(select(SyncLog))
logs = log_result.scalars().all()
assert len(logs) == 1
assert logs[0].status == "completed"
assert logs[0].items_processed == 250
@pytest.mark.asyncio
async def test_sync_upsert_existing(test_db):
"""Upsert should update existing equipment, not duplicate."""
existing = EquipmentCache(rentman_id="100", name="Old Name", category="Old")
test_db.add(existing)
await test_db.commit()
mock_rentman = MagicMock()
mock_rentman.get_all_equipment = AsyncMock(return_value=[
make_raw_equipment("100", "New Name", "Lautsprecher")
])
with patch("app.services.sync_service.cache") as mock_cache:
mock_cache.delete_pattern = AsyncMock(return_value=0)
sync_service = SyncService(test_db, rentman=mock_rentman)
result = await sync_service.run_sync()
assert result["items_processed"] == 1
db_result = await test_db.execute(select(EquipmentCache))
items = db_result.scalars().all()
assert len(items) == 1
assert items[0].name == "New Name"
@pytest.mark.asyncio
async def test_sync_failure_logs_error(test_db):
"""Sync failure should be logged with error message."""
mock_rentman = MagicMock()
mock_rentman.get_all_equipment = AsyncMock(side_effect=Exception("API unreachable"))
with patch("app.services.sync_service.cache") as mock_cache:
mock_cache.delete_pattern = AsyncMock(return_value=0)
sync_service = SyncService(test_db, rentman=mock_rentman)
result = await sync_service.run_sync()
assert result["status"] == "failed"
assert result["items_processed"] == 0
log_result = await test_db.execute(select(SyncLog))
log = log_result.scalar_one()
assert log.status == "failed"
assert "API unreachable" in (log.error_message or "")
@pytest.mark.asyncio
async def test_get_all_equipment_paginates():
"""RentmanService.get_all_equipment iterates until data is empty."""
page1 = {"data": [{"id": str(i), "name": f"Item {i}"} for i in range(100)]}
page2 = {"data": [{"id": str(i), "name": f"Item {i}"} for i in range(100, 150)]}
page3 = {"data": []}
svc = RentmanService(token="test-token")
svc.get_equipment_page = AsyncMock(side_effect=[page1, page2, page3])
result = await svc.get_all_equipment(limit=100)
assert len(result) == 150
assert svc.get_equipment_page.call_count == 3
+120
View File
@@ -0,0 +1,120 @@
"""Tests for Rentman request submission pipeline (T04)."""
import pytest
from unittest.mock import AsyncMock, patch, MagicMock
from app.services.rentman_service import RentmanService
def test_build_project_request_payload():
data = {
"event_name": "Sommerfest 2026",
"date_start": "2026-08-15",
"date_end": "2026-08-16",
"contact_name": "Max Mustermann",
"contact_company": "Firma GmbH",
"contact_email": "max@example.com",
"contact_phone": "+49 170 1234567",
"contact_street": "Hauptstr. 42a",
"contact_postalcode": "80000",
"contact_city": "Muenchen",
"location": "Muenchen",
"message": "Brauchen PA",
}
payload = RentmanService.build_project_request_payload(data)
assert payload["name"] == "Sommerfest 2026"
assert payload["planperiod_start"] == "2026-08-15T08:00:00+02:00"
assert payload["planperiod_end"] == "2026-08-16T02:00:00+02:00"
assert payload["usageperiod_start"] == "2026-08-15T18:00:00+02:00"
assert payload["usageperiod_end"] == "2026-08-16T23:59:00+02:00"
assert payload["contact_name"] == "Firma GmbH"
assert payload["contact_person_first_name"] == "Max"
assert payload["contact_person_lastname"] == "Mustermann"
assert payload["contact_person_email"] == "max@example.com"
assert payload["location_mailing_street"] == "Hauptstr. 42a"
assert payload["location_mailing_number"] == "42a"
assert payload["location_mailing_postalcode"] == "80000"
assert payload["location_mailing_city"] == "Muenchen"
assert payload["remark"] == "Brauchen PA"
def test_build_project_request_payload_no_company():
"""When no company, contact_name should use contact_name."""
data = {
"event_name": "Test",
"date_start": "2026-08-15",
"date_end": "2026-08-16",
"contact_name": "Anna Schmidt",
"contact_company": None,
"contact_email": "anna@example.com",
"contact_street": "Testweg 5",
"contact_postalcode": "10000",
"contact_city": "Berlin",
"location": "Berlin",
}
payload = RentmanService.build_project_request_payload(data)
assert payload["contact_name"] == "Anna Schmidt"
assert payload["contact_person_first_name"] == "Anna"
assert payload["contact_person_lastname"] == "Schmidt"
assert payload["location_mailing_number"] == "5"
def test_build_equipment_payload():
item = {
"equipment_name": "L-Acoustics K2",
"rentman_equipment_id": "101",
"quantity": 4,
"unit_price": 150.00,
}
payload = RentmanService.build_equipment_payload(item)
assert payload["name"] == "L-Acoustics K2"
assert payload["quantity"] == 4
assert payload["quantity_total"] == 4
assert payload["unit_price"] == 150.00
assert payload["linked_equipment"] == "/equipment/101"
@pytest.mark.asyncio
async def test_projectrequest_success():
"""Mock Rentman API: create project request returns ID, equipment added."""
svc = RentmanService(token="test-token")
svc.create_project_request = AsyncMock(return_value={"id": "9999", "name": "Test Event"})
svc.add_equipment_to_request = AsyncMock(return_value={"id": "equip-1"})
project_payload = {"name": "Test Event", "usageperiod_start": "2026-08-15T18:00:00+02:00"}
result = await svc.create_project_request(project_payload)
assert result["id"] == "9999"
equip_payload = {"name": "K2", "quantity": 2}
eq_result = await svc.add_equipment_to_request("9999", equip_payload)
assert eq_result["id"] == "equip-1"
@pytest.mark.asyncio
async def test_equipment_retry():
"""Failed equipment POST should be retried (exponential backoff simulation)."""
svc = RentmanService(token="test-token")
call_count = 0
async def mock_add(*args, **kwargs):
nonlocal call_count
call_count += 1
if call_count < 3:
raise Exception("Temporary failure")
return {"id": "success"}
svc.add_equipment_to_request = mock_add
# Simulate retry logic
max_retries = 3
result = None
for attempt in range(max_retries):
try:
result = await svc.add_equipment_to_request("1", {"name": "test"})
break
except Exception:
if attempt == max_retries - 1:
raise
import asyncio
await asyncio.sleep(0.01 * (2 ** attempt))
assert result == {"id": "success"}
assert call_count == 3