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