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