diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..d6093a3 --- /dev/null +++ b/.env.example @@ -0,0 +1,32 @@ +# HMS Licht & Ton – Environment Configuration +# Copy this file to .env and fill in real values. +# NEVER commit the real .env file! + +# --- Rentman API --- +RENTMAN_API_TOKEN= + +# --- Authentication --- +JWT_SECRET= + +# --- Database --- +DATABASE_URL=postgresql://hms:hms@postgres:5432/hms + +# --- Redis --- +REDIS_URL=redis://redis:6379/0 + +# --- SMTP (Email) --- +SMTP_HOST= +SMTP_PORT=587 +SMTP_USER= +SMTP_PASSWORD= +SMTP_FROM=info@hms-licht-ton.de + +# --- CORS --- +CORS_ORIGINS=https://hms.media-on.de,http://localhost:3000 + +# --- Admin --- +ADMIN_USERNAME=admin +ADMIN_PASSWORD= + +# --- Frontend --- +NUXT_PUBLIC_API_BASE=http://backend:8000 diff --git a/.forgejo/workflows/ci.yml b/.forgejo/workflows/ci.yml new file mode 100644 index 0000000..2531c9f --- /dev/null +++ b/.forgejo/workflows/ci.yml @@ -0,0 +1,80 @@ +name: CI/CD Pipeline + +on: + push: + branches: + - main + pull_request: + branches: + - main + +jobs: + frontend-test: + runs-on: ubuntu-latest + container: node:20 + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup npm cache + uses: actions/cache@v4 + with: + path: frontend/node_modules + key: ${{ runner.os }}-npm-${{ hashFiles('frontend/package-lock.json') }} + restore-keys: | + ${{ runner.os }}-npm- + + - name: Install dependencies + working-directory: frontend + run: npm ci + + - name: Run unit tests + working-directory: frontend + run: npm test + + - name: Build production + working-directory: frontend + run: npm run build + + backend-test: + runs-on: ubuntu-latest + container: python:3.12-slim + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install system dependencies + run: | + apt-get update && apt-get install -y --no-install-recommends gcc libpq-dev && rm -rf /var/lib/apt/lists/* + + - name: Setup pip cache + uses: actions/cache@v4 + with: + path: ~/.cache/pip + key: ${{ runner.os }}-pip-${{ hashFiles('backend/requirements.txt') }} + restore-keys: | + ${{ runner.os }}-pip- + + - name: Install Python dependencies + working-directory: backend + run: pip install --no-cache-dir -r requirements.txt + + - name: Run tests + working-directory: backend + run: pytest -v + + docker-build: + runs-on: ubuntu-latest + needs: [frontend-test, backend-test] + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Build frontend Docker image + run: docker build -t hms-frontend:ci ./frontend + + - name: Build backend Docker image + run: docker build -t hms-backend:ci ./backend + + - name: Validate docker-compose + run: docker compose config --quiet diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..70c185c --- /dev/null +++ b/.gitignore @@ -0,0 +1,11 @@ +__pycache__/ +*.pyc +*.pyo +.coverage +.env +*.db +htmlcov/ +.pytest_cache/ +node_modules/ +.nuxt/ +dist/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..1baca16 --- /dev/null +++ b/README.md @@ -0,0 +1,179 @@ +# HMS Licht & Ton – Homepage & Verwaltungssystem + +Die offizielle Website und Verwaltungsplattform für HMS Licht & Ton (Hammerschmidt u. Mössle GbR) aus Leipheim/Ellzee. Professionelle Veranstaltungstechnik – Vermietung, Verkauf, Personal und Transport von Tontechnik und Lichttechnik. + +--- + +## Tech Stack + +| Komponente | Technologie | +|---------------|-----------------------------------| +| Frontend | Nuxt 3 (Vue 3, TypeScript, Tailwind CSS) | +| Backend | FastAPI (Python 3.12, async) | +| Database | PostgreSQL 16 | +| Cache | Redis 7 | +| Deployment | Docker Compose via Coolify | +| CI/CD | Forgejo Actions | + +--- + +## Prerequisites + +- **Node.js** 20+ +- **Python** 3.12+ +- **Docker** & Docker Compose +- **Git** + +--- + +## Setup + +### 1. Repository klonen + +```bash +git clone https://forgejo.media-on.de/Leopoldadmin/hms-licht-ton.git +cd hms-licht-ton +``` + +### 2. Environment konfigurieren + +```bash +cp .env.example .env +# .env mit echten Werten ausfüllen (API-Token, Secrets, SMTP, etc.) +``` + +### 3. Anwendung starten (Docker) + +```bash +docker compose up -d +``` + +Die Anwendung ist danach erreichbar unter: +- **Frontend:** http://localhost:3000 +- **Backend API:** http://localhost:8000 +- **API Docs (Swagger):** http://localhost:8000/docs + +--- + +## Development + +### Frontend (Nuxt 3) + +```bash +cd frontend +npm ci +npm run dev # Dev-Server auf http://localhost:3000 +npm test # Unit Tests (Vitest) +npx playwright test # E2E Tests +npm run build # Production Build +npm run typecheck # TypeScript Type Check +npm run lint # ESLint +``` + +### Backend (FastAPI) + +```bash +cd backend +python -m venv venv +source venv/bin/activate +pip install -r requirements.txt +uvicorn app.main:app --reload --host 0.0.0.0 --port 8000 +pytest -v # Tests ausführen +``` + +Weitere Build- und Test-Befehle siehe [AGENTS.md](AGENTS.md). + +--- + +## Build (Docker) + +### Alle Services bauen + +```bash +docker compose build +``` + +### Einzelne Services bauen + +```bash +docker compose build frontend +docker compose build backend +``` + +--- + +## Deployment (Coolify) + +Die Anwendung wird über Coolify auf `coolify-01` (46.225.91.159) deployt. + +1. In Coolify ein neues Projekt "HMS Licht & Ton" anlegen. +2. Als Source das Forgejo-Repository `Leopoldadmin/hms-licht-ton` auswählen. +3. Als Deployment-Methode "Docker Compose" wählen und `docker-compose.yml` referenzieren. +4. Environment-Variablen in Coolify setzen (entsprechend `.env.example`). +5. Domain für Frontend konfigurieren (z.B. `hms.media-on.de` → Port 3000). +6. Backend-Port (8000) nur intern暴露en oder über Traefik mit eigener Subdomain. +7. Deploy auslösen. Coolify baut die Images und startet alle Services. + +### Health Checks + +Alle Services haben Health Checks konfiguriert: +- **Frontend:** HTTP-Check auf Port 3000 +- **Backend:** HTTP-Check auf `/health` Endpoint +- **PostgreSQL:** `pg_isready` +- **Redis:** `redis-cli ping` + +--- + +## CI/CD Pipeline (Forgejo Actions) + +Die Pipeline (`.forgejo/workflows/ci.yml`) läuft bei jedem Push und PR auf `main`: + +1. **frontend-test:** `npm ci` → `npm test` → `npm run build` +2. **backend-test:** `pip install` → `pytest -v` +3. **docker-build:** Baut Frontend- und Backend-Docker-Images, validiert `docker-compose.yml` + +--- + +## Projektstruktur + +``` +hms-licht-ton/ +├── frontend/ # Nuxt 3 Frontend +│ ├── Dockerfile +│ ├── .dockerignore +│ ├── package.json +│ ├── nuxt.config.ts +│ ├── pages/ +│ ├── components/ +│ ├── composables/ +│ ├── stores/ +│ └── tests/ +├── backend/ # FastAPI Backend +│ ├── Dockerfile +│ ├── .dockerignore +│ ├── requirements.txt +│ ├── app/ +│ │ ├── main.py +│ │ ├── config.py +│ │ ├── database.py +│ │ ├── auth.py +│ │ ├── cache.py +│ │ ├── routers/ +│ │ └── schemas/ +│ └── tests/ +├── docs/ # Projektdokumentation +├── .forgejo/ +│ └── workflows/ +│ └── ci.yml # CI/CD Pipeline +├── docker-compose.yml # 4 Services: frontend, backend, postgres, redis +├── .env.example # Environment-Vorlage +├── .gitignore +├── AGENTS.md # Build & Test Guide +└── README.md +``` + +--- + +## License + +© Hammerschmidt u. Mössle GbR. Alle Rechte vorbehalten. diff --git a/backend/.coverage b/backend/.coverage new file mode 100644 index 0000000..fe87633 Binary files /dev/null and b/backend/.coverage differ diff --git a/backend/.dockerignore b/backend/.dockerignore new file mode 100644 index 0000000..5350049 --- /dev/null +++ b/backend/.dockerignore @@ -0,0 +1,11 @@ +__pycache__ +*.pyc +*.pyo +.coverage +.env +.env.* +.pytest_cache +htmlcov +tests +*.md +docs diff --git a/backend/.env b/backend/.env new file mode 100644 index 0000000..abcc852 --- /dev/null +++ b/backend/.env @@ -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 diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..725f4be --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,17 @@ +FROM python:3.12-slim + +WORKDIR /app + +# Install system dependencies for asyncpg +RUN apt-get update && apt-get install -y --no-install-recommends \ + gcc libpq-dev && \ + rm -rf /var/lib/apt/lists/* + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY app/ ./app/ + +EXPOSE 8000 + +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/backend/app/__init__.py b/backend/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/__pycache__/__init__.cpython-313.pyc b/backend/app/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000..8428c34 Binary files /dev/null and b/backend/app/__pycache__/__init__.cpython-313.pyc differ diff --git a/backend/app/__pycache__/auth.cpython-313.pyc b/backend/app/__pycache__/auth.cpython-313.pyc new file mode 100644 index 0000000..4bce9c8 Binary files /dev/null and b/backend/app/__pycache__/auth.cpython-313.pyc differ diff --git a/backend/app/__pycache__/cache.cpython-313.pyc b/backend/app/__pycache__/cache.cpython-313.pyc new file mode 100644 index 0000000..753615e Binary files /dev/null and b/backend/app/__pycache__/cache.cpython-313.pyc differ diff --git a/backend/app/__pycache__/config.cpython-313.pyc b/backend/app/__pycache__/config.cpython-313.pyc new file mode 100644 index 0000000..8a3ea3e Binary files /dev/null and b/backend/app/__pycache__/config.cpython-313.pyc differ diff --git a/backend/app/__pycache__/database.cpython-313.pyc b/backend/app/__pycache__/database.cpython-313.pyc new file mode 100644 index 0000000..9997861 Binary files /dev/null and b/backend/app/__pycache__/database.cpython-313.pyc differ diff --git a/backend/app/__pycache__/main.cpython-313.pyc b/backend/app/__pycache__/main.cpython-313.pyc new file mode 100644 index 0000000..aaf1808 Binary files /dev/null and b/backend/app/__pycache__/main.cpython-313.pyc differ diff --git a/backend/app/auth.py b/backend/app/auth.py new file mode 100644 index 0000000..12fd25b --- /dev/null +++ b/backend/app/auth.py @@ -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 diff --git a/backend/app/cache.py b/backend/app/cache.py new file mode 100644 index 0000000..093050f --- /dev/null +++ b/backend/app/cache.py @@ -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() diff --git a/backend/app/config.py b/backend/app/config.py new file mode 100644 index 0000000..1146d3c --- /dev/null +++ b/backend/app/config.py @@ -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() diff --git a/backend/app/database.py b/backend/app/database.py new file mode 100644 index 0000000..278ebe3 --- /dev/null +++ b/backend/app/database.py @@ -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) diff --git a/backend/app/main.py b/backend/app/main.py new file mode 100644 index 0000000..b7be580 --- /dev/null +++ b/backend/app/main.py @@ -0,0 +1,94 @@ +"""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, async_session +from app.cache import cache +from app.routers import equipment, health, admin, rental_requests, contact +from app.services.sync_service import SyncService +from app.services.email_service import EmailService + +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) + + +async def run_email_retry() -> None: + """Scheduled job: retry failed emails every 15 minutes.""" + logger.info("Starting scheduled email retry") + async with async_session() as db: + sent = await EmailService.retry_failed_emails(db) + logger.info("Email retry complete: %d emails sent", sent) + + +@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.add_job( + run_email_retry, + trigger="cron", + minute="*/15", + id="email_retry", + replace_existing=True, + ) + scheduler.start() + logger.info("APScheduler started with equipment_sync (every 6h) and email_retry (every 15min)") + + 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) diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py new file mode 100644 index 0000000..e577eed --- /dev/null +++ b/backend/app/models/__init__.py @@ -0,0 +1,9 @@ +"""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 +from app.models.email_queue import EmailQueue + +__all__ = ["EquipmentCache", "RentalRequest", "RentalRequestItem", "Contact", "AdminUser", "SyncLog", "EmailQueue"] diff --git a/backend/app/models/__pycache__/__init__.cpython-313.pyc b/backend/app/models/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000..da5cae6 Binary files /dev/null and b/backend/app/models/__pycache__/__init__.cpython-313.pyc differ diff --git a/backend/app/models/__pycache__/admin_user.cpython-313.pyc b/backend/app/models/__pycache__/admin_user.cpython-313.pyc new file mode 100644 index 0000000..6b3f75a Binary files /dev/null and b/backend/app/models/__pycache__/admin_user.cpython-313.pyc differ diff --git a/backend/app/models/__pycache__/contact.cpython-313.pyc b/backend/app/models/__pycache__/contact.cpython-313.pyc new file mode 100644 index 0000000..c261f9e Binary files /dev/null and b/backend/app/models/__pycache__/contact.cpython-313.pyc differ diff --git a/backend/app/models/__pycache__/equipment.cpython-313.pyc b/backend/app/models/__pycache__/equipment.cpython-313.pyc new file mode 100644 index 0000000..60cad0a Binary files /dev/null and b/backend/app/models/__pycache__/equipment.cpython-313.pyc differ diff --git a/backend/app/models/__pycache__/rental_request.cpython-313.pyc b/backend/app/models/__pycache__/rental_request.cpython-313.pyc new file mode 100644 index 0000000..5ef1d02 Binary files /dev/null and b/backend/app/models/__pycache__/rental_request.cpython-313.pyc differ diff --git a/backend/app/models/__pycache__/sync_log.cpython-313.pyc b/backend/app/models/__pycache__/sync_log.cpython-313.pyc new file mode 100644 index 0000000..d2fc8a2 Binary files /dev/null and b/backend/app/models/__pycache__/sync_log.cpython-313.pyc differ diff --git a/backend/app/models/admin_user.py b/backend/app/models/admin_user.py new file mode 100644 index 0000000..cb4f1ff --- /dev/null +++ b/backend/app/models/admin_user.py @@ -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()) diff --git a/backend/app/models/contact.py b/backend/app/models/contact.py new file mode 100644 index 0000000..2618334 --- /dev/null +++ b/backend/app/models/contact.py @@ -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()) diff --git a/backend/app/models/email_queue.py b/backend/app/models/email_queue.py new file mode 100644 index 0000000..2fbaf76 --- /dev/null +++ b/backend/app/models/email_queue.py @@ -0,0 +1,18 @@ +"""Email queue model for failed email retry.""" +from sqlalchemy import Column, Integer, String, Text, TIMESTAMP, func +from app.database import Base + + +class EmailQueue(Base): + __tablename__ = "email_queue" + + id = Column(Integer, primary_key=True, autoincrement=True) + to_addr = Column(String(255), nullable=False) + subject = Column(String(255), nullable=False) + html_body = Column(Text, nullable=False) + text_body = Column(Text) + reply_to = Column(String(255)) + status = Column(String(32), default="pending", nullable=False) + attempts = Column(Integer, default=0, nullable=False) + created_at = Column(TIMESTAMP, server_default=func.now()) + updated_at = Column(TIMESTAMP, server_default=func.now(), onupdate=func.now()) diff --git a/backend/app/models/equipment.py b/backend/app/models/equipment.py new file mode 100644 index 0000000..6c1e320 --- /dev/null +++ b/backend/app/models/equipment.py @@ -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"), + ) diff --git a/backend/app/models/rental_request.py b/backend/app/models/rental_request.py new file mode 100644 index 0000000..7cd7554 --- /dev/null +++ b/backend/app/models/rental_request.py @@ -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"),) diff --git a/backend/app/models/sync_log.py b/backend/app/models/sync_log.py new file mode 100644 index 0000000..5f7eeba --- /dev/null +++ b/backend/app/models/sync_log.py @@ -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"),) diff --git a/backend/app/routers/__init__.py b/backend/app/routers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/routers/__pycache__/__init__.cpython-313.pyc b/backend/app/routers/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000..ade1a7f Binary files /dev/null and b/backend/app/routers/__pycache__/__init__.cpython-313.pyc differ diff --git a/backend/app/routers/__pycache__/admin.cpython-313.pyc b/backend/app/routers/__pycache__/admin.cpython-313.pyc new file mode 100644 index 0000000..03faf21 Binary files /dev/null and b/backend/app/routers/__pycache__/admin.cpython-313.pyc differ diff --git a/backend/app/routers/__pycache__/contact.cpython-313.pyc b/backend/app/routers/__pycache__/contact.cpython-313.pyc new file mode 100644 index 0000000..1328dda Binary files /dev/null and b/backend/app/routers/__pycache__/contact.cpython-313.pyc differ diff --git a/backend/app/routers/__pycache__/equipment.cpython-313.pyc b/backend/app/routers/__pycache__/equipment.cpython-313.pyc new file mode 100644 index 0000000..052c506 Binary files /dev/null and b/backend/app/routers/__pycache__/equipment.cpython-313.pyc differ diff --git a/backend/app/routers/__pycache__/health.cpython-313.pyc b/backend/app/routers/__pycache__/health.cpython-313.pyc new file mode 100644 index 0000000..c667845 Binary files /dev/null and b/backend/app/routers/__pycache__/health.cpython-313.pyc differ diff --git a/backend/app/routers/__pycache__/rental_requests.cpython-313.pyc b/backend/app/routers/__pycache__/rental_requests.cpython-313.pyc new file mode 100644 index 0000000..5b8c0a0 Binary files /dev/null and b/backend/app/routers/__pycache__/rental_requests.cpython-313.pyc differ diff --git a/backend/app/routers/admin.py b/backend/app/routers/admin.py new file mode 100644 index 0000000..4f74ad5 --- /dev/null +++ b/backend/app/routers/admin.py @@ -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"], + } diff --git a/backend/app/routers/contact.py b/backend/app/routers/contact.py new file mode 100644 index 0000000..281ce81 --- /dev/null +++ b/backend/app/routers/contact.py @@ -0,0 +1,50 @@ +"""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: + sent = await email_service.send_contact_email({ + "name": payload.name, + "email": str(payload.email), + "phone": payload.phone, + "message": payload.message, + }, db=db) + if sent: + contact.email_sent = True + await db.commit() + except Exception: + pass + + return ContactResponse(success=True) diff --git a/backend/app/routers/equipment.py b/backend/app/routers/equipment.py new file mode 100644 index 0000000..14ca538 --- /dev/null +++ b/backend/app/routers/equipment.py @@ -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 diff --git a/backend/app/routers/health.py b/backend/app/routers/health.py new file mode 100644 index 0000000..99e3858 --- /dev/null +++ b/backend/app/routers/health.py @@ -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} diff --git a/backend/app/routers/rental_requests.py b/backend/app/routers/rental_requests.py new file mode 100644 index 0000000..6faeee6 --- /dev/null +++ b/backend/app/routers/rental_requests.py @@ -0,0 +1,142 @@ +"""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, + db=db, + ) + except Exception: + # Email failure should not affect response + pass + + return RentalRequestResponse(reference_number=ref_number, status="pending") diff --git a/backend/app/schemas/__init__.py b/backend/app/schemas/__init__.py new file mode 100644 index 0000000..541b3cc --- /dev/null +++ b/backend/app/schemas/__init__.py @@ -0,0 +1 @@ +"""Schema exports.""" diff --git a/backend/app/schemas/__pycache__/__init__.cpython-313.pyc b/backend/app/schemas/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000..b403ca1 Binary files /dev/null and b/backend/app/schemas/__pycache__/__init__.cpython-313.pyc differ diff --git a/backend/app/schemas/__pycache__/auth.cpython-313.pyc b/backend/app/schemas/__pycache__/auth.cpython-313.pyc new file mode 100644 index 0000000..9d12cc4 Binary files /dev/null and b/backend/app/schemas/__pycache__/auth.cpython-313.pyc differ diff --git a/backend/app/schemas/__pycache__/contact.cpython-313.pyc b/backend/app/schemas/__pycache__/contact.cpython-313.pyc new file mode 100644 index 0000000..6a8327f Binary files /dev/null and b/backend/app/schemas/__pycache__/contact.cpython-313.pyc differ diff --git a/backend/app/schemas/__pycache__/equipment.cpython-313.pyc b/backend/app/schemas/__pycache__/equipment.cpython-313.pyc new file mode 100644 index 0000000..cd2f3fb Binary files /dev/null and b/backend/app/schemas/__pycache__/equipment.cpython-313.pyc differ diff --git a/backend/app/schemas/__pycache__/rental_request.cpython-313.pyc b/backend/app/schemas/__pycache__/rental_request.cpython-313.pyc new file mode 100644 index 0000000..b02946c Binary files /dev/null and b/backend/app/schemas/__pycache__/rental_request.cpython-313.pyc differ diff --git a/backend/app/schemas/__pycache__/sync.cpython-313.pyc b/backend/app/schemas/__pycache__/sync.cpython-313.pyc new file mode 100644 index 0000000..a95b766 Binary files /dev/null and b/backend/app/schemas/__pycache__/sync.cpython-313.pyc differ diff --git a/backend/app/schemas/auth.py b/backend/app/schemas/auth.py new file mode 100644 index 0000000..832fa94 --- /dev/null +++ b/backend/app/schemas/auth.py @@ -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 diff --git a/backend/app/schemas/contact.py b/backend/app/schemas/contact.py new file mode 100644 index 0000000..128aa07 --- /dev/null +++ b/backend/app/schemas/contact.py @@ -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 diff --git a/backend/app/schemas/equipment.py b/backend/app/schemas/equipment.py new file mode 100644 index 0000000..a702a7c --- /dev/null +++ b/backend/app/schemas/equipment.py @@ -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 diff --git a/backend/app/schemas/rental_request.py b/backend/app/schemas/rental_request.py new file mode 100644 index 0000000..0265326 --- /dev/null +++ b/backend/app/schemas/rental_request.py @@ -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 diff --git a/backend/app/schemas/sync.py b/backend/app/schemas/sync.py new file mode 100644 index 0000000..36bb407 --- /dev/null +++ b/backend/app/schemas/sync.py @@ -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 diff --git a/backend/app/services/__init__.py b/backend/app/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/services/__pycache__/__init__.cpython-313.pyc b/backend/app/services/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000..af3d4c7 Binary files /dev/null and b/backend/app/services/__pycache__/__init__.cpython-313.pyc differ diff --git a/backend/app/services/__pycache__/email_service.cpython-313.pyc b/backend/app/services/__pycache__/email_service.cpython-313.pyc new file mode 100644 index 0000000..34b3225 Binary files /dev/null and b/backend/app/services/__pycache__/email_service.cpython-313.pyc differ diff --git a/backend/app/services/__pycache__/rentman_service.cpython-313.pyc b/backend/app/services/__pycache__/rentman_service.cpython-313.pyc new file mode 100644 index 0000000..44d1c7b Binary files /dev/null and b/backend/app/services/__pycache__/rentman_service.cpython-313.pyc differ diff --git a/backend/app/services/__pycache__/sync_service.cpython-313.pyc b/backend/app/services/__pycache__/sync_service.cpython-313.pyc new file mode 100644 index 0000000..c0bd133 Binary files /dev/null and b/backend/app/services/__pycache__/sync_service.cpython-313.pyc differ diff --git a/backend/app/services/email_service.py b/backend/app/services/email_service.py new file mode 100644 index 0000000..a5aee9d --- /dev/null +++ b/backend/app/services/email_service.py @@ -0,0 +1,186 @@ +"""Email service using aiosmtplib for contact and rental confirmation emails. + +Includes a failed-email queue: when SMTP fails and a db session is provided, +the email is persisted to the email_queue table for later retry via APScheduler. +""" +import logging +from email.mime.text import MIMEText +from email.mime.multipart import MIMEMultipart +from typing import Any +from sqlalchemy import select, update +from sqlalchemy.ext.asyncio import AsyncSession +import aiosmtplib +from app.config import get_settings +from app.models.email_queue import EmailQueue + +logger = logging.getLogger(__name__) +settings = get_settings() + + +class EmailService: + """Send transactional emails via SMTP with failed-email queue support.""" + + async def send_email( + self, + to_addr: str, + subject: str, + html_body: str, + text_body: str = "", + reply_to: str | None = None, + db: AsyncSession | None = None, + ) -> bool: + """Send an email via aiosmtplib. Returns True on success. + + If SMTP fails and a db session is provided, the email is saved to + the email_queue table for later retry. + """ + 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) + if db is not None: + try: + queued = EmailQueue( + to_addr=to_addr, + subject=subject, + html_body=html_body, + text_body=text_body, + reply_to=reply_to, + status="pending", + attempts=0, + ) + db.add(queued) + await db.commit() + logger.info("Email queued for retry: %s", to_addr) + except Exception as queue_exc: + logger.error("Failed to queue email: %s", queue_exc) + return False + + async def send_contact_email( + self, contact_data: dict[str, Any], db: AsyncSession | None = None + ) -> bool: + """Send contact form data to info@hms-licht-ton.de.""" + html = f""" +

Neue Kontaktanfrage

+

Name: {contact_data.get('name', '')}

+

Email: {contact_data.get('email', '')}

+

Telefon: {contact_data.get('phone', '')}

+

Nachricht:

+

{contact_data.get('message', '')}

+ """ + 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"), + db=db, + ) + + async def send_rental_confirmation( + self, + to_addr: str, + reference_number: str, + event_name: str, + items: list[dict[str, Any]], + db: AsyncSession | None = None, + ) -> bool: + """Send rental request confirmation to customer.""" + items_html = "".join( + f"
  • {item.get('equipment_name', 'Unbekannt')} - Menge: {item.get('quantity', 1)}
  • " + for item in items + ) + html = f""" +

    Mietanfrage bestätigt – {reference_number}

    +

    Vielen Dank für Ihre Mietanfrage bei HMS Licht & Ton.

    +

    Veranstaltung: {event_name}

    +

    Referenznummer: {reference_number}

    +

    Artikel:

    + +

    Wir melden uns in Kürze bei Ihnen.

    +

    Ihr HMS Licht & Ton Team

    + """ + 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, + db=db, + ) + + @staticmethod + async def retry_failed_emails(db: AsyncSession, max_attempts: int = 5) -> int: + """Retry all pending emails in the queue. Returns count of successfully sent emails.""" + result = await db.execute( + select(EmailQueue).where( + EmailQueue.status == "pending", + EmailQueue.attempts < max_attempts, + ) + ) + pending = result.scalars().all() + sent_count = 0 + + for item in pending: + item.attempts += 1 + msg = MIMEMultipart("alternative") + msg["From"] = settings.smtp_from + msg["To"] = item.to_addr + msg["Subject"] = item.subject + if item.reply_to: + msg["Reply-To"] = item.reply_to + msg.attach(MIMEText(item.text_body or item.html_body, "plain")) + msg.attach(MIMEText(item.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, + ) + item.status = "sent" + sent_count += 1 + logger.info("Retry succeeded for email to %s", item.to_addr) + except Exception as exc: + logger.error("Retry failed for email to %s: %s", item.to_addr, exc) + if item.attempts >= max_attempts: + item.status = "failed" + + await db.commit() + return sent_count diff --git a/backend/app/services/rentman_service.py b/backend/app/services/rentman_service.py new file mode 100644 index 0000000..d5895d7 --- /dev/null +++ b/backend/app/services/rentman_service.py @@ -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, + } diff --git a/backend/app/services/sync_service.py b/backend/app/services/sync_service.py new file mode 100644 index 0000000..ca8b072 --- /dev/null +++ b/backend/app/services/sync_service.py @@ -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, + } diff --git a/backend/pytest.ini b/backend/pytest.ini new file mode 100644 index 0000000..cdce43f --- /dev/null +++ b/backend/pytest.ini @@ -0,0 +1,6 @@ +[pytest] +asyncio_mode = auto +testpaths = tests +python_files = test_*.py +python_classes = Test* +python_functions = test_* diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..7283440 --- /dev/null +++ b/backend/requirements.txt @@ -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 diff --git a/backend/test_report.md b/backend/test_report.md new file mode 100644 index 0000000..d864d57 --- /dev/null +++ b/backend/test_report.md @@ -0,0 +1,64 @@ +# Test Report – T05: Email Service + Contact & Rental Request Backend + +## Test Commands + +### Targeted T05 Tests +```bash +python -m pytest tests/test_contact_router.py tests/test_rental_router.py tests/test_email_service.py tests/test_rate_limiting.py -v +``` +**Result: 23 passed in 0.81s** + +### Full Suite with Coverage +```bash +python -m pytest tests/ -v --cov=app --cov-report=term-missing +``` +**Result: 57 passed, 4 warnings in 7.31s** +**Coverage: 78% overall** + +## Test Files Created + +### tests/test_contact_router.py (6 tests) +- test_contact_valid_returns_200 – POST /api/contact valid → 200, success=True +- test_contact_invalid_email_returns_422 – invalid email → 422 +- test_contact_privacy_consent_false_returns_422 – privacy_consent=false → 422 +- test_contact_triggers_email – mock EmailService.send_contact_email called with correct data +- test_contact_saved_to_db – Contact record saved with email_sent=True +- test_contact_email_failure_does_not_break_response – email failure still returns 200 + +### tests/test_rental_router.py (7 tests) +- test_rental_valid_returns_201 – valid POST → 201 with HMS-YYYY-NNNNN reference +- test_rental_date_end_before_start_returns_422 – date_end < date_start → 422 +- test_rental_empty_items_returns_422 – empty items array → 422 +- test_rental_saved_to_db – RentalRequest + RentalRequestItem saved, rentman_sync_status=success +- test_rental_triggers_rentman – RentmanService.create_project_request + add_equipment_to_request called +- test_rental_triggers_email – EmailService.send_rental_confirmation called with ref + items +- test_rental_rentman_failure_still_returns_201 – Rentman failure still returns 201 + +### tests/test_email_service.py (7 tests) +- test_send_contact_email_success – aiosmtplib.send called, MIMEMultipart contains name + message +- test_send_rental_confirmation_success – email contains reference_number + items +- test_send_email_smtp_failure_returns_false – SMTP exception → returns False, no crash +- test_send_contact_email_smtp_failure_returns_false – contact email SMTP failure → False +- test_send_rental_confirmation_smtp_failure_returns_false – rental email SMTP failure → False +- test_send_email_failure_saves_to_queue – failed email saved to email_queue table +- test_retry_failed_emails_resends_pending – retry sends queued email, marks as sent + +### tests/test_rate_limiting.py (3 tests) +- test_contact_rate_limit_allows_5_blocks_6th – 5 requests OK, 6th → 429 +- test_rental_rate_limit_allows_5_blocks_6th – 5 requests OK, 6th → 429 +- test_contact_rate_window_reset – rate window reset allows new requests after TTL + +## New Feature: Email Retry Queue +- `app/models/email_queue.py` – EmailQueue model (id, to_addr, subject, html_body, text_body, reply_to, status, attempts, created_at, updated_at) +- `app/services/email_service.py` – send_email now accepts optional `db` param; on SMTP failure saves to queue +- `EmailService.retry_failed_emails(db)` – static method to retry pending emails (max 5 attempts) +- `app/main.py` – APScheduler job `run_email_retry` every 15 minutes +- Routers updated to pass `db` to email service methods + +## Smoke Test +- All 57 tests pass (23 new T05 + 34 existing) +- No existing tests broken +- Coverage: 78% overall, email_service 90%, contact.py 67%, rental_requests.py 46% +- Rate limiting works via mock cache.incr_rate with sequential return values +- Email queue persistence verified with in-memory SQLite +- Retry mechanism verified: pending → sent on success diff --git a/backend/tests/__init__.py b/backend/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/tests/__pycache__/__init__.cpython-313.pyc b/backend/tests/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000..ed5094a Binary files /dev/null and b/backend/tests/__pycache__/__init__.cpython-313.pyc differ diff --git a/backend/tests/__pycache__/conftest.cpython-313-pytest-9.1.1.pyc b/backend/tests/__pycache__/conftest.cpython-313-pytest-9.1.1.pyc new file mode 100644 index 0000000..45d052a Binary files /dev/null and b/backend/tests/__pycache__/conftest.cpython-313-pytest-9.1.1.pyc differ diff --git a/backend/tests/__pycache__/test_admin_auth.cpython-313-pytest-9.1.1.pyc b/backend/tests/__pycache__/test_admin_auth.cpython-313-pytest-9.1.1.pyc new file mode 100644 index 0000000..58e6540 Binary files /dev/null and b/backend/tests/__pycache__/test_admin_auth.cpython-313-pytest-9.1.1.pyc differ diff --git a/backend/tests/__pycache__/test_admin_router.cpython-313-pytest-9.1.1.pyc b/backend/tests/__pycache__/test_admin_router.cpython-313-pytest-9.1.1.pyc new file mode 100644 index 0000000..70acca1 Binary files /dev/null and b/backend/tests/__pycache__/test_admin_router.cpython-313-pytest-9.1.1.pyc differ diff --git a/backend/tests/__pycache__/test_equipment_router.cpython-313-pytest-9.1.1.pyc b/backend/tests/__pycache__/test_equipment_router.cpython-313-pytest-9.1.1.pyc new file mode 100644 index 0000000..4b4df12 Binary files /dev/null and b/backend/tests/__pycache__/test_equipment_router.cpython-313-pytest-9.1.1.pyc differ diff --git a/backend/tests/__pycache__/test_health.cpython-313-pytest-9.1.1.pyc b/backend/tests/__pycache__/test_health.cpython-313-pytest-9.1.1.pyc new file mode 100644 index 0000000..b436d3b Binary files /dev/null and b/backend/tests/__pycache__/test_health.cpython-313-pytest-9.1.1.pyc differ diff --git a/backend/tests/__pycache__/test_rentman_import.cpython-313-pytest-9.1.1.pyc b/backend/tests/__pycache__/test_rentman_import.cpython-313-pytest-9.1.1.pyc new file mode 100644 index 0000000..a19a6ae Binary files /dev/null and b/backend/tests/__pycache__/test_rentman_import.cpython-313-pytest-9.1.1.pyc differ diff --git a/backend/tests/__pycache__/test_rentman_request.cpython-313-pytest-9.1.1.pyc b/backend/tests/__pycache__/test_rentman_request.cpython-313-pytest-9.1.1.pyc new file mode 100644 index 0000000..c4214cc Binary files /dev/null and b/backend/tests/__pycache__/test_rentman_request.cpython-313-pytest-9.1.1.pyc differ diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py new file mode 100644 index 0000000..3fbc1b1 --- /dev/null +++ b/backend/tests/conftest.py @@ -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 diff --git a/backend/tests/test_admin_auth.py b/backend/tests/test_admin_auth.py new file mode 100644 index 0000000..047c70d --- /dev/null +++ b/backend/tests/test_admin_auth.py @@ -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" diff --git a/backend/tests/test_admin_router.py b/backend/tests/test_admin_router.py new file mode 100644 index 0000000..6cde529 --- /dev/null +++ b/backend/tests/test_admin_router.py @@ -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" diff --git a/backend/tests/test_contact_router.py b/backend/tests/test_contact_router.py new file mode 100644 index 0000000..7649a4a --- /dev/null +++ b/backend/tests/test_contact_router.py @@ -0,0 +1,120 @@ +"""Tests for contact router (T05).""" +import pytest +from unittest.mock import AsyncMock, patch, MagicMock +from sqlalchemy import select + +from app.models.contact import Contact + + +@pytest.mark.asyncio +async def test_contact_valid_returns_200(client): + """POST /api/contact with valid data returns 200 and saves to contacts table.""" + with patch( + "app.services.email_service.EmailService.send_contact_email", + new_callable=AsyncMock, + return_value=True, + ): + resp = await client.post("/api/contact", json={ + "name": "Max Mustermann", + "email": "max@example.com", + "phone": "+49 123 456789", + "message": "Ich brauche eine PA-Anlage fuer ein Festival.", + "privacy_consent": True, + }) + assert resp.status_code == 200 + data = resp.json() + assert data["success"] is True + + +@pytest.mark.asyncio +async def test_contact_invalid_email_returns_422(client): + """POST /api/contact with invalid email returns 422.""" + resp = await client.post("/api/contact", json={ + "name": "Max Mustermann", + "email": "not-an-email", + "phone": "+49 123 456789", + "message": "Test message", + "privacy_consent": True, + }) + assert resp.status_code == 422 + + +@pytest.mark.asyncio +async def test_contact_privacy_consent_false_returns_422(client): + """POST /api/contact with privacy_consent=false returns 422.""" + resp = await client.post("/api/contact", json={ + "name": "Max Mustermann", + "email": "max@example.com", + "phone": "+49 123 456789", + "message": "Test message", + "privacy_consent": False, + }) + assert resp.status_code == 422 + + +@pytest.mark.asyncio +async def test_contact_triggers_email(client): + """POST /api/contact triggers send_contact_email on EmailService.""" + with patch( + "app.services.email_service.EmailService.send_contact_email", + new_callable=AsyncMock, + return_value=True, + ) as mock_send: + resp = await client.post("/api/contact", json={ + "name": "Erika Musterfrau", + "email": "erika@example.com", + "phone": "+49 987 654321", + "message": "Ich benoetige Beleuchtung fuer eine Gala.", + "privacy_consent": True, + }) + assert resp.status_code == 200 + mock_send.assert_awaited_once() + call_args = mock_send.call_args[0][0] + assert call_args["name"] == "Erika Musterfrau" + assert call_args["email"] == "erika@example.com" + assert call_args["message"] == "Ich benoetige Beleuchtung fuer eine Gala." + + +@pytest.mark.asyncio +async def test_contact_saved_to_db(client, test_db): + """POST /api/contact saves a record to the contacts table.""" + with patch( + "app.services.email_service.EmailService.send_contact_email", + new_callable=AsyncMock, + return_value=True, + ): + resp = await client.post("/api/contact", json={ + "name": "DB Testuser", + "email": "db@example.com", + "phone": "+49 111 222333", + "message": "Datenbank-Verifikation", + "privacy_consent": True, + }) + assert resp.status_code == 200 + + result = await test_db.execute(select(Contact).where(Contact.email == "db@example.com")) + contact = result.scalar_one_or_none() + assert contact is not None + assert contact.name == "DB Testuser" + assert contact.message == "Datenbank-Verifikation" + assert contact.privacy_consent is True + assert contact.email_sent is True + + +@pytest.mark.asyncio +async def test_contact_email_failure_does_not_break_response(client): + """POST /api/contact still returns 200 when email sending fails.""" + with patch( + "app.services.email_service.EmailService.send_contact_email", + new_callable=AsyncMock, + return_value=False, + ): + resp = await client.post("/api/contact", json={ + "name": "Fail Test", + "email": "fail@example.com", + "phone": "+49 000 000000", + "message": "Email soll fehlschlagen", + "privacy_consent": True, + }) + assert resp.status_code == 200 + assert resp.json()["success"] is True diff --git a/backend/tests/test_email_service.py b/backend/tests/test_email_service.py new file mode 100644 index 0000000..5436d42 --- /dev/null +++ b/backend/tests/test_email_service.py @@ -0,0 +1,169 @@ +"""Tests for email service (T05).""" +import pytest +from unittest.mock import AsyncMock, patch, MagicMock +from email.mime.multipart import MIMEMultipart + +from app.services.email_service import EmailService + + +@pytest.mark.asyncio +async def test_send_contact_email_success(): + """send_contact_email sends correct email via aiosmtplib.""" + service = EmailService() + contact_data = { + "name": "Test User", + "email": "test@example.com", + "phone": "+49 123 456", + "message": "Hallo Welt", + } + with patch("app.services.email_service.aiosmtplib.send", new_callable=AsyncMock) as mock_send: + result = await service.send_contact_email(contact_data) + assert result is True + mock_send.assert_awaited_once() + sent_msg = mock_send.call_args[0][0] + assert isinstance(sent_msg, MIMEMultipart) + assert "Hallo Welt" in str(sent_msg) + assert "Test User" in str(sent_msg) + + +@pytest.mark.asyncio +async def test_send_rental_confirmation_success(): + """send_rental_confirmation sends email with reference_number and items.""" + service = EmailService() + items = [ + {"equipment_name": "L-Acoustics K2", "quantity": 2}, + {"equipment_name": "d&b T10", "quantity": 4}, + ] + with patch("app.services.email_service.aiosmtplib.send", new_callable=AsyncMock) as mock_send: + result = await service.send_rental_confirmation( + to_addr="customer@example.com", + reference_number="HMS-2026-00042", + event_name="Rock Festival", + items=items, + ) + assert result is True + mock_send.assert_awaited_once() + sent_msg = mock_send.call_args[0][0] + msg_str = str(sent_msg) + assert "HMS-2026-00042" in msg_str + assert "Rock Festival" in msg_str + assert "L-Acoustics K2" in msg_str + assert "d&b T10" in msg_str + + +@pytest.mark.asyncio +async def test_send_email_smtp_failure_returns_false(): + """SMTP failure returns False, no crash, graceful handling.""" + service = EmailService() + with patch("app.services.email_service.aiosmtplib.send", new_callable=AsyncMock, side_effect=Exception("SMTP refused")): + result = await service.send_email( + to_addr="fail@example.com", + subject="Test Subject", + html_body="

    Test

    ", + text_body="Test", + ) + assert result is False + + +@pytest.mark.asyncio +async def test_send_contact_email_smtp_failure_returns_false(): + """send_contact_email returns False on SMTP failure without crashing.""" + service = EmailService() + with patch("app.services.email_service.aiosmtplib.send", new_callable=AsyncMock, side_effect=Exception("Connection refused")): + result = await service.send_contact_email({ + "name": "Fail User", + "email": "fail@example.com", + "phone": "+49 000", + "message": "Soll fehlschlagen", + }) + assert result is False + + +@pytest.mark.asyncio +async def test_send_rental_confirmation_smtp_failure_returns_false(): + """send_rental_confirmation returns False on SMTP failure without crashing.""" + service = EmailService() + with patch("app.services.email_service.aiosmtplib.send", new_callable=AsyncMock, side_effect=Exception("Timeout")): + result = await service.send_rental_confirmation( + to_addr="fail@example.com", + reference_number="HMS-2026-99999", + event_name="Fail Event", + items=[{"equipment_name": "Speaker", "quantity": 1}], + ) + assert result is False + + +@pytest.mark.asyncio +async def test_send_email_failure_saves_to_queue(): + """send_email saves failed email to email_queue when db is provided.""" + from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine + from app.database import Base + from app.models.email_queue import EmailQueue + from sqlalchemy import select + + engine = create_async_engine("sqlite+aiosqlite:///:memory:") + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + session_maker = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False) + + service = EmailService() + with patch("app.services.email_service.aiosmtplib.send", new_callable=AsyncMock, side_effect=Exception("SMTP down")): + async with session_maker() as db: + result = await service.send_email( + to_addr="queue@example.com", + subject="Queued Email", + html_body="

    Body

    ", + text_body="Body", + reply_to="reply@example.com", + db=db, + ) + assert result is False + + async with session_maker() as db: + queued = await db.execute(select(EmailQueue).where(EmailQueue.to_addr == "queue@example.com")) + item = queued.scalar_one_or_none() + assert item is not None + assert item.subject == "Queued Email" + assert item.status == "pending" + assert item.attempts == 0 + assert item.reply_to == "reply@example.com" + + await engine.dispose() + + +@pytest.mark.asyncio +async def test_retry_failed_emails_resends_pending(): + """retry_failed_emails retries pending queue items and marks sent on success.""" + from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine + from app.database import Base + from app.models.email_queue import EmailQueue + from sqlalchemy import select + + engine = create_async_engine("sqlite+aiosqlite:///:memory:") + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + session_maker = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False) + + async with session_maker() as db: + db.add(EmailQueue( + to_addr="retry@example.com", + subject="Retry Test", + html_body="

    Retry

    ", + text_body="Retry", + status="pending", + attempts=0, + )) + await db.commit() + + with patch("app.services.email_service.aiosmtplib.send", new_callable=AsyncMock): + async with session_maker() as db: + sent_count = await EmailService.retry_failed_emails(db) + assert sent_count == 1 + + async with session_maker() as db: + result = await db.execute(select(EmailQueue).where(EmailQueue.to_addr == "retry@example.com")) + item = result.scalar_one_or_none() + assert item.status == "sent" + assert item.attempts == 1 + + await engine.dispose() diff --git a/backend/tests/test_equipment_router.py b/backend/tests/test_equipment_router.py new file mode 100644 index 0000000..9d25989 --- /dev/null +++ b/backend/tests/test_equipment_router.py @@ -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 diff --git a/backend/tests/test_health.py b/backend/tests/test_health.py new file mode 100644 index 0000000..8e15a92 --- /dev/null +++ b/backend/tests/test_health.py @@ -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 diff --git a/backend/tests/test_rate_limiting.py b/backend/tests/test_rate_limiting.py new file mode 100644 index 0000000..7ad5eb3 --- /dev/null +++ b/backend/tests/test_rate_limiting.py @@ -0,0 +1,140 @@ +"""Tests for rate limiting on contact and rental endpoints (T05).""" +import pytest +from unittest.mock import AsyncMock, patch, MagicMock +from httpx import AsyncClient, ASGITransport +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker + +from app.database import Base, get_db +from app.models import EquipmentCache + + +VALID_CONTACT = { + "name": "Rate Limit", + "email": "rate@example.com", + "phone": "+49 123", + "message": "Rate limit test", + "privacy_consent": True, +} + +VALID_RENTAL = { + "event_name": "Rate Limit Fest", + "date_start": "2026-09-01", + "date_end": "2026-09-02", + "location": "Hamburg", + "contact_name": "RL User", + "contact_email": "rl@example.com", + "items": [{"equipment_id": 1, "quantity": 1}], +} + + +def _make_rate_limit_client(test_engine, rate_counts: list[int]): + """Create a client whose cache.incr_rate returns sequential values from rate_counts.""" + session_maker = async_sessionmaker(test_engine, class_=AsyncSession, expire_on_commit=False) + call_index = {"i": 0} + + async def override_get_db(): + async with session_maker() as session: + yield session + + async def mock_incr_rate(key: str, window: int = 60) -> int: + idx = call_index["i"] + if idx < len(rate_counts): + val = rate_counts[idx] + else: + val = rate_counts[-1] + 1 + call_index["i"] += 1 + return val + + 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 = mock_incr_rate + mock_cache.connect = AsyncMock(return_value=None) + mock_cache._redis = MagicMock() + mock_cache._redis.ping = AsyncMock(return_value=True) + + async def _yield_client(): + 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() + + return _yield_client + + +@pytest.mark.asyncio +async def test_contact_rate_limit_allows_5_blocks_6th(test_engine): + """5 requests OK, 6th request returns 429 for contact endpoint.""" + rate_counts = [1, 2, 3, 4, 5, 6] + client_gen = _make_rate_limit_client(test_engine, rate_counts) + async for client in client_gen(): + with patch( + "app.services.email_service.EmailService.send_contact_email", + new_callable=AsyncMock, + return_value=True, + ): + for i in range(5): + resp = await client.post("/api/contact", json=VALID_CONTACT) + assert resp.status_code == 200, f"Request {i+1} should succeed" + resp = await client.post("/api/contact", json=VALID_CONTACT) + assert resp.status_code == 429 + + +@pytest.mark.asyncio +async def test_rental_rate_limit_allows_5_blocks_6th(test_engine, seeded_equipment): + """5 requests OK, 6th request returns 429 for rental-requests endpoint.""" + rate_counts = [1, 2, 3, 4, 5, 6] + client_gen = _make_rate_limit_client(test_engine, rate_counts) + async for client in client_gen(): + with patch( + "app.services.rentman_service.RentmanService.create_project_request", + new_callable=AsyncMock, + return_value={"id": "rl-test"}, + ), patch( + "app.services.rentman_service.RentmanService.add_equipment_to_request", + new_callable=AsyncMock, + return_value={"id": "eq-rl"}, + ), patch( + "app.services.email_service.EmailService.send_rental_confirmation", + new_callable=AsyncMock, + return_value=True, + ): + for i in range(5): + resp = await client.post("/api/rental-requests", json=VALID_RENTAL) + assert resp.status_code == 201, f"Request {i+1} should succeed" + resp = await client.post("/api/rental-requests", json=VALID_RENTAL) + assert resp.status_code == 429 + + +@pytest.mark.asyncio +async def test_contact_rate_window_reset(test_engine): + """Rate window resets after TTL: first 5 OK, then 429, then after reset 5 more OK.""" + rate_counts = [1, 2, 3, 4, 5, 6, 1, 2] + client_gen = _make_rate_limit_client(test_engine, rate_counts) + async for client in client_gen(): + with patch( + "app.services.email_service.EmailService.send_contact_email", + new_callable=AsyncMock, + return_value=True, + ): + for i in range(5): + resp = await client.post("/api/contact", json=VALID_CONTACT) + assert resp.status_code == 200 + resp = await client.post("/api/contact", json=VALID_CONTACT) + assert resp.status_code == 429 + # After window reset, counter starts at 1 again + resp = await client.post("/api/contact", json=VALID_CONTACT) + assert resp.status_code == 200 + resp = await client.post("/api/contact", json=VALID_CONTACT) + assert resp.status_code == 200 + + diff --git a/backend/tests/test_rental_router.py b/backend/tests/test_rental_router.py new file mode 100644 index 0000000..b4684b5 --- /dev/null +++ b/backend/tests/test_rental_router.py @@ -0,0 +1,175 @@ +"""Tests for rental request router (T05).""" +import pytest +from unittest.mock import AsyncMock, patch +from sqlalchemy import select + +from app.models.rental_request import RentalRequest, RentalRequestItem + + +VALID_RENTAL_PAYLOAD = { + "event_name": "Sommerfestival 2026", + "date_start": "2026-08-15", + "date_end": "2026-08-17", + "location": "Berlin", + "person_count": 500, + "contact_name": "Max Veranstalter", + "contact_company": "Event GmbH", + "contact_email": "max@event.de", + "contact_phone": "+49 30 1234567", + "contact_street": "Hauptstr. 42", + "contact_postalcode": "10115", + "contact_city": "Berlin", + "message": "Bitte um Angebot", + "items": [{"equipment_id": 1, "quantity": 2}], +} + + +@pytest.mark.asyncio +async def test_rental_valid_returns_201(client, seeded_equipment): + """POST /api/rental-requests with valid data returns 201 with reference number.""" + with patch( + "app.services.rentman_service.RentmanService.create_project_request", + new_callable=AsyncMock, + return_value={"id": "rentman-123"}, + ), patch( + "app.services.rentman_service.RentmanService.add_equipment_to_request", + new_callable=AsyncMock, + return_value={"id": "eq-1"}, + ), patch( + "app.services.email_service.EmailService.send_rental_confirmation", + new_callable=AsyncMock, + return_value=True, + ): + resp = await client.post("/api/rental-requests", json=VALID_RENTAL_PAYLOAD) + assert resp.status_code == 201 + data = resp.json() + assert "reference_number" in data + ref = data["reference_number"] + assert ref.startswith("HMS-") + parts = ref.split("-") + assert len(parts) == 3 + assert parts[1].isdigit() + assert len(parts[2]) == 5 + assert parts[2].isdigit() + assert data["status"] == "pending" + + +@pytest.mark.asyncio +async def test_rental_date_end_before_start_returns_422(client, seeded_equipment): + """POST /api/rental-requests with date_end < date_start returns 422.""" + payload = {**VALID_RENTAL_PAYLOAD, "date_start": "2026-08-20", "date_end": "2026-08-15"} + resp = await client.post("/api/rental-requests", json=payload) + assert resp.status_code == 422 + + +@pytest.mark.asyncio +async def test_rental_empty_items_returns_422(client, seeded_equipment): + """POST /api/rental-requests with empty items array returns 422.""" + payload = {**VALID_RENTAL_PAYLOAD, "items": []} + resp = await client.post("/api/rental-requests", json=payload) + assert resp.status_code == 422 + + +@pytest.mark.asyncio +async def test_rental_saved_to_db(client, seeded_equipment, test_db): + """POST /api/rental-requests saves to rental_requests and rental_request_items tables.""" + with patch( + "app.services.rentman_service.RentmanService.create_project_request", + new_callable=AsyncMock, + return_value={"id": "rentman-456"}, + ), patch( + "app.services.rentman_service.RentmanService.add_equipment_to_request", + new_callable=AsyncMock, + return_value={"id": "eq-2"}, + ), patch( + "app.services.email_service.EmailService.send_rental_confirmation", + new_callable=AsyncMock, + return_value=True, + ): + resp = await client.post("/api/rental-requests", json=VALID_RENTAL_PAYLOAD) + assert resp.status_code == 201 + ref_number = resp.json()["reference_number"] + + result = await test_db.execute( + select(RentalRequest).where(RentalRequest.reference_number == ref_number) + ) + rental = result.scalar_one_or_none() + assert rental is not None + assert rental.event_name == "Sommerfestival 2026" + assert rental.contact_name == "Max Veranstalter" + assert rental.status == "pending" + assert rental.rentman_request_id == "rentman-456" + assert rental.rentman_sync_status == "success" + + items_result = await test_db.execute( + select(RentalRequestItem).where(RentalRequestItem.rental_request_id == rental.id) + ) + items = items_result.scalars().all() + assert len(items) == 1 + assert items[0].equipment_id == 1 + assert items[0].quantity == 2 + assert items[0].equipment_name == "L-Acoustics K2" + + +@pytest.mark.asyncio +async def test_rental_triggers_rentman(client, seeded_equipment): + """POST /api/rental-requests triggers Rentman create_project_request.""" + with patch( + "app.services.rentman_service.RentmanService.create_project_request", + new_callable=AsyncMock, + return_value={"id": "rentman-789"}, + ) as mock_create, patch( + "app.services.rentman_service.RentmanService.add_equipment_to_request", + new_callable=AsyncMock, + return_value={"id": "eq-3"}, + ) as mock_add, patch( + "app.services.email_service.EmailService.send_rental_confirmation", + new_callable=AsyncMock, + return_value=True, + ): + resp = await client.post("/api/rental-requests", json=VALID_RENTAL_PAYLOAD) + assert resp.status_code == 201 + mock_create.assert_awaited_once() + mock_add.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_rental_triggers_email(client, seeded_equipment): + """POST /api/rental-requests triggers confirmation email.""" + with patch( + "app.services.rentman_service.RentmanService.create_project_request", + new_callable=AsyncMock, + return_value={"id": "rentman-email-test"}, + ), patch( + "app.services.rentman_service.RentmanService.add_equipment_to_request", + new_callable=AsyncMock, + return_value={"id": "eq-4"}, + ), patch( + "app.services.email_service.EmailService.send_rental_confirmation", + new_callable=AsyncMock, + return_value=True, + ) as mock_email: + resp = await client.post("/api/rental-requests", json=VALID_RENTAL_PAYLOAD) + assert resp.status_code == 201 + mock_email.assert_awaited_once() + call_kwargs = mock_email.call_args.kwargs + assert call_kwargs["to_addr"] == "max@event.de" + assert call_kwargs["event_name"] == "Sommerfestival 2026" + assert call_kwargs["reference_number"].startswith("HMS-") + assert len(call_kwargs["items"]) == 1 + + +@pytest.mark.asyncio +async def test_rental_rentman_failure_still_returns_201(client, seeded_equipment): + """POST /api/rental-requests still returns 201 when Rentman API fails.""" + with patch( + "app.services.rentman_service.RentmanService.create_project_request", + new_callable=AsyncMock, + side_effect=Exception("Rentman API error"), + ), patch( + "app.services.email_service.EmailService.send_rental_confirmation", + new_callable=AsyncMock, + return_value=True, + ): + resp = await client.post("/api/rental-requests", json=VALID_RENTAL_PAYLOAD) + assert resp.status_code == 201 diff --git a/backend/tests/test_rentman_import.py b/backend/tests/test_rentman_import.py new file mode 100644 index 0000000..47f6214 --- /dev/null +++ b/backend/tests/test_rentman_import.py @@ -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 diff --git a/backend/tests/test_rentman_request.py b/backend/tests/test_rentman_request.py new file mode 100644 index 0000000..e57995e --- /dev/null +++ b/backend/tests/test_rentman_request.py @@ -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 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..ba7065a --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,91 @@ +services: + frontend: + build: ./frontend + ports: + - "3000:3000" + depends_on: + backend: + condition: service_healthy + environment: + - NUXT_PUBLIC_API_BASE=${NUXT_PUBLIC_API_BASE:-http://backend:8000} + restart: unless-stopped + healthcheck: + test: ["CMD", "node", "-e", "fetch('http://localhost:3000/').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 15s + networks: + - hms-network + + backend: + build: ./backend + ports: + - "8000:8000" + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + environment: + - DATABASE_URL=${DATABASE_URL:-postgresql://hms:hms@postgres:5432/hms} + - REDIS_URL=${REDIS_URL:-redis://redis:6379/0} + - RENTMAN_API_TOKEN=${RENTMAN_API_TOKEN} + - JWT_SECRET=${JWT_SECRET} + - SMTP_HOST=${SMTP_HOST} + - SMTP_PORT=${SMTP_PORT:-587} + - SMTP_USER=${SMTP_USER} + - SMTP_PASSWORD=${SMTP_PASSWORD} + - SMTP_FROM=${SMTP_FROM:-info@hms-licht-ton.de} + - CORS_ORIGINS=${CORS_ORIGINS:-https://hms.media-on.de,http://localhost:3000} + - ADMIN_USERNAME=${ADMIN_USERNAME:-admin} + - ADMIN_PASSWORD=${ADMIN_PASSWORD} + restart: unless-stopped + healthcheck: + test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 10s + networks: + - hms-network + + postgres: + image: postgres:16-alpine + volumes: + - postgres_data:/var/lib/postgresql/data + environment: + - POSTGRES_USER=hms + - POSTGRES_PASSWORD=hms + - POSTGRES_DB=hms + restart: unless-stopped + healthcheck: + test: ["CMD-SHELL", "pg_isready -U hms -d hms"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 5s + networks: + - hms-network + + redis: + image: redis:7-alpine + volumes: + - redis_data:/data + restart: unless-stopped + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 5s + networks: + - hms-network + +networks: + hms-network: + driver: bridge + +volumes: + postgres_data: + redis_data: diff --git a/frontend/.dockerignore b/frontend/.dockerignore new file mode 100644 index 0000000..0b6c734 --- /dev/null +++ b/frontend/.dockerignore @@ -0,0 +1,12 @@ +node_modules +.nuxt +.output +dist +.git +.gitignore +*.md +.env +.env.* +playwright-report +test-results +coverage diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..976dde8 --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,21 @@ +# --- Stage 1: Build --- +FROM node:20 AS builder + +WORKDIR /app + +COPY package.json package-lock.json ./ +RUN npm ci + +COPY . . +RUN npm run build + +# --- Stage 2: Production --- +FROM node:20-slim AS production + +WORKDIR /app + +COPY --from=builder /app/.output ./.output + +EXPOSE 3000 + +CMD ["node", ".output/server/index.mjs"] diff --git a/frontend/components/AppHeader.vue b/frontend/components/AppHeader.vue index 0f7f632..e3cfa0c 100644 --- a/frontend/components/AppHeader.vue +++ b/frontend/components/AppHeader.vue @@ -21,7 +21,7 @@ Kontakt - + + + +