14 Commits

Author SHA1 Message Date
Agent Zero e66bcc7058 fix: seed admin user on startup and run sync as background task
- Seed/update admin user from ADMIN_USERNAME/ADMIN_PASSWORD settings on
  startup (admin_users table was empty, login was impossible)
- POST /api/admin/sync now returns immediately and runs the equipment
  sync via BackgroundTasks (full sync took minutes and hit nginx 504)
- run_sync accepts optional sync_log_id to reuse a pre-created log entry
- Adapt admin router test to background sync behavior
2026-09-24 21:41:35 +02:00
Agent Zero 709190719f chore: add package-lock.json for reproducible npm ci builds 2026-09-24 21:26:31 +02:00
Agent Zero df50ea8b4b fix: repair Rentman equipment sync with real API field mapping
- Map real Rentman fields: price -> rental_price, folder -> category/subcategory
  (resolved via folder hierarchy to top-level categories), code -> number,
  shop_description_long (fallback short/external_remark) -> description (HTML stripped)
- Sync only in_shop items; exclude archived/temporary from public catalog
- Extract known brands from equipment names (d&b, Shure, Pioneer, ...)
- Build specifications from physical fields (power, weight, dimensions)
- Persist equipment images in docker volume (survive redeploys)
- Router: filter available items, add price_asc/price_desc sorting
- Frontend: show prices in catalog card, detail page and cart with
  per-day subtotal; price sorting options
- Tests: use real Rentman field names in fixtures, mock folder_map
2026-09-24 21:26:18 +02:00
Agent Zero 654ec09ce1 fix: pin mcp<2 (FastMCP removed in mcp 2.x, backend uses v1 API) 2026-09-24 18:35:49 +02:00
Agent Zero 345230a9fc fix: set Traefik service port label to new app uuid (hms-office env) 2026-09-24 18:27:46 +02:00
Agent Zero 1b77f5815e staging: add X-Robots-Tag noindex header for hms.hms-office.de 2026-09-24 18:26:38 +02:00
Agent Zero 489419a7d4 fix: add Traefik service port label for frontend (port 3000)
Coolify generates router labels but not the service port label for
docker-compose services. Without this, Traefik gets 502 because it
doesn't know which port to forward traffic to.
2026-07-15 13:20:11 +02:00
Agent Zero f815e64499 fix: add coolify external network to frontend for Traefik routing
Coolify does not automatically add the coolify network to docker-compose services.
The frontend needs to be on the coolify network for Traefik to route traffic to it.
2026-07-15 13:14:03 +02:00
Agent Zero e5606c64c5 fix: remove custom Traefik labels and port mapping for Coolify deployment
- Remove conflicting Traefik labels that caused redirect loop with Coolify auto-generated labels
- Remove port mapping (Coolify handles routing via Traefik)
- Remove external coolify network (Coolify adds it automatically)
- Remove ./ volume mount from backend (incompatible with Coolify build context)
2026-07-15 13:09:52 +02:00
Agent Zero a08bdaa3b1 Revert "chore: add equipment images and docker-compose backup"
This reverts commit 36a1ad0b84.
2026-07-14 22:24:28 +02:00
Agent Zero 36a1ad0b84 chore: add equipment images and docker-compose backup 2026-07-14 22:23:40 +02:00
Agent Zero 80cb971308 fix: disable MCP DNS rebinding protection for Docker/proxy deployment (fixes 421 Invalid Host header) 2026-07-13 10:07:23 +02:00
Agent Zero 6483453437 feat: reintegrate MCP server with proper session manager lifecycle, nginx /mcp proxy, docker.sock mount 2026-07-13 03:42:13 +02:00
Agent Zero 1221350ed6 fix: remove coolify network from backend to prevent DNS conflict with crm-postgres; remove MCP Traefik labels and docker.sock mount 2026-07-13 03:00:12 +02:00
17 changed files with 2882 additions and 103 deletions
+30 -1
View File
@@ -9,8 +9,12 @@ from app.config import get_settings
from app.database import engine, init_db, async_session from app.database import engine, init_db, async_session
from app.cache import cache from app.cache import cache
from app.routers import equipment, health, admin, rental_requests, contact from app.routers import equipment, health, admin, rental_requests, contact
from app.models.admin_user import AdminUser
from app.auth import get_password_hash
from sqlalchemy import select
from app.services.sync_service import SyncService from app.services.sync_service import SyncService
from app.services.email_service import EmailService from app.services.email_service import EmailService
from app.mcp_server import get_mcp_app, mcp_session_manager
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
settings = get_settings() settings = get_settings()
@@ -35,12 +39,34 @@ async def run_email_retry() -> None:
logger.info("Email retry complete: %d emails sent", sent) logger.info("Email retry complete: %d emails sent", sent)
async def seed_admin_user() -> None:
"""Ensure the admin user from settings exists (create or update password)."""
async with async_session() as db:
result = await db.execute(
select(AdminUser).where(AdminUser.username == settings.admin_username)
)
user = result.scalar_one_or_none()
if not user:
db.add(AdminUser(
username=settings.admin_username,
password_hash=get_password_hash(settings.admin_password),
))
logger.info("Seeded admin user %s", settings.admin_username)
else:
user.password_hash = get_password_hash(settings.admin_password)
await db.commit()
@asynccontextmanager @asynccontextmanager
async def lifespan(app: FastAPI): async def lifespan(app: FastAPI):
"""Application lifespan: init DB, start scheduler, connect cache.""" """Application lifespan: init DB, seed admin, start scheduler, connect cache."""
await init_db() await init_db()
await seed_admin_user()
await cache.connect() await cache.connect()
_mcp_cm = mcp_session_manager.run()
await _mcp_cm.__aenter__()
scheduler.add_job(run_equipment_sync, trigger="cron", hour="*/6", id="equipment_sync", replace_existing=True) 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.add_job(run_email_retry, trigger="cron", minute="*/15", id="email_retry", replace_existing=True)
scheduler.start() scheduler.start()
@@ -49,6 +75,7 @@ async def lifespan(app: FastAPI):
yield yield
scheduler.shutdown(wait=False) scheduler.shutdown(wait=False)
await _mcp_cm.__aexit__(None, None, None)
await cache.disconnect() await cache.disconnect()
await engine.dispose() await engine.dispose()
@@ -63,3 +90,5 @@ app.include_router(health.router)
app.include_router(admin.router) app.include_router(admin.router)
app.include_router(rental_requests.router) app.include_router(rental_requests.router)
app.include_router(contact.router) app.include_router(contact.router)
app.mount("/mcp", get_mcp_app())
+2 -13
View File
@@ -122,20 +122,9 @@ _STATIC_COMPONENTS = [
# streamable_http_path="/" so that when mounted at /mcp the endpoint is /mcp/ # streamable_http_path="/" so that when mounted at /mcp the endpoint is /mcp/
# stateless_http=True: each request is independent (no session management) # stateless_http=True: each request is independent (no session management)
# Disable DNS rebinding protection for Docker deployment # Disable DNS rebinding protection for Docker deployment
try: from mcp.server.transport_security import TransportSecuritySettings
from mcp.server.fastmcp.settings import TransportSecuritySettings
_ts = TransportSecuritySettings(enable_dns_rebinding_protection=False)
except Exception:
_ts = None
_mcp_kwargs: dict[str, Any] = { mcp = FastMCP("hms-cms", streamable_http_path="/", stateless_http=True, transport_security=TransportSecuritySettings(enable_dns_rebinding_protection=False))
"streamable_http_path": "/",
"stateless_http": True,
}
if _ts is not None:
_mcp_kwargs["transport_security"] = _ts
mcp = FastMCP("hms-cms", **_mcp_kwargs)
# ---------------------------------------------------------------------------# # ---------------------------------------------------------------------------#
# Bearer token authentication wrapper # Bearer token authentication wrapper
+31 -5
View File
@@ -1,5 +1,5 @@
"""Admin router: login, sync endpoints, sync log.""" """Admin router: login, sync endpoints, sync log."""
from fastapi import APIRouter, Depends, HTTPException, Response, Query, status from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Response, Query, status
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from typing import Any from typing import Any
@@ -10,7 +10,9 @@ from app.schemas.auth import LoginRequest, TokenResponse, AdminInfo
from app.schemas.sync import SyncStatus, SyncLogEntry, SyncTriggerResponse from app.schemas.sync import SyncStatus, SyncLogEntry, SyncTriggerResponse
from app.auth import verify_password, create_access_token, get_current_user from app.auth import verify_password, create_access_token, get_current_user
from app.services.sync_service import SyncService from app.services.sync_service import SyncService
from app.database import async_session
from app.cache import cache from app.cache import cache
from datetime import datetime
router = APIRouter(prefix="/api/admin", tags=["admin"]) router = APIRouter(prefix="/api/admin", tags=["admin"])
@@ -54,13 +56,37 @@ async def get_me(user: AdminUser = Depends(get_current_user)) -> Any:
@router.post("/sync", response_model=SyncTriggerResponse) @router.post("/sync", response_model=SyncTriggerResponse)
async def trigger_sync( async def trigger_sync(
background_tasks: BackgroundTasks,
user: AdminUser = Depends(get_current_user), user: AdminUser = Depends(get_current_user),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
) -> Any: ) -> Any:
"""Trigger a manual equipment sync (admin only).""" """Trigger a manual equipment sync in the background (admin only).
sync_service = SyncService(db)
result = await sync_service.run_sync() Runs the sync via BackgroundTasks so the HTTP request returns immediately
return SyncTriggerResponse(sync_id=result["sync_id"], status=result["status"]) (a full sync of 700+ items incl. image downloads takes minutes and
would otherwise hit proxy timeouts).
Poll GET /api/admin/sync-status for progress.
"""
log_entry = SyncLog(sync_type="equipment", status="running", started_at=datetime.utcnow())
db.add(log_entry)
await db.commit()
await db.refresh(log_entry)
async def _run_sync(sync_id: int) -> None:
async with async_session() as sync_db:
sync_service = SyncService(sync_db)
try:
await sync_service.run_sync(sync_log_id=sync_id)
except Exception as exc: # noqa: BLE001
log = await sync_db.get(SyncLog, sync_id)
if log:
log.status = "failed"
log.error_message = str(exc)
log.completed_at = datetime.utcnow()
await sync_db.commit()
background_tasks.add_task(_run_sync, log_entry.id)
return SyncTriggerResponse(sync_id=log_entry.id, status="running")
@router.get("/sync-status", response_model=SyncStatus) @router.get("/sync-status", response_model=SyncStatus)
+9 -3
View File
@@ -30,8 +30,8 @@ async def list_equipment(
if cached: if cached:
return cached return cached
query = select(EquipmentCache) query = select(EquipmentCache).where(EquipmentCache.available == True) # noqa: E712
count_query = select(func.count(EquipmentCache.id)) count_query = select(func.count(EquipmentCache.id)).where(EquipmentCache.available == True) # noqa: E712
if search: if search:
query = query.where(EquipmentCache.name.ilike(f"%{search}%")) query = query.where(EquipmentCache.name.ilike(f"%{search}%"))
@@ -42,6 +42,10 @@ async def list_equipment(
if sort == "name_desc": if sort == "name_desc":
query = query.order_by(EquipmentCache.name.desc()) query = query.order_by(EquipmentCache.name.desc())
elif sort == "price_asc":
query = query.order_by(EquipmentCache.rental_price.asc().nulls_last(), EquipmentCache.name.asc())
elif sort == "price_desc":
query = query.order_by(EquipmentCache.rental_price.desc().nulls_last(), EquipmentCache.name.asc())
else: else:
query = query.order_by(EquipmentCache.name.asc()) query = query.order_by(EquipmentCache.name.asc())
@@ -70,7 +74,9 @@ async def list_categories(db: AsyncSession = Depends(get_db)) -> Any:
if cached: if cached:
return cached return cached
result = await db.execute( result = await db.execute(
select(EquipmentCache.category).distinct().where(EquipmentCache.category.isnot(None)) select(EquipmentCache.category).distinct().where(
EquipmentCache.category.isnot(None), EquipmentCache.available == True # noqa: E712
)
) )
categories = [row[0] for row in result.fetchall() if row[0]] categories = [row[0] for row in result.fetchall() if row[0]]
await cache.set("equipment:categories", categories, ttl=3600) await cache.set("equipment:categories", categories, ttl=3600)
+144 -16
View File
@@ -50,6 +50,53 @@ class RentmanService:
offset += limit offset += limit
return all_items return all_items
async def get_all_folders(self) -> list[dict[str, Any]]:
"""Paginate through all folders until data is empty."""
all_folders: list[dict[str, Any]] = []
offset = 0
while True:
url = f"{self._base_url}/folders"
params = {"limit": 100, "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()
page = resp.json()
data = page.get("data", [])
if not data:
break
all_folders.extend(data)
offset += 100
return all_folders
async def get_folder_map(self) -> dict[str, dict[str, str]]:
"""Build a map: folder path -> {category: top-level name, subcategory: folder name}.
Walks the parent chain so every folder resolves to its top-level ancestor.
"""
folders = await self.get_all_folders()
by_path = {f"/folders/{f.get('id')}": f for f in folders}
def top_ancestor(folder: dict) -> dict:
current = folder
seen = set()
while current.get("parent") and current["parent"] not in seen:
seen.add(current["parent"])
parent = by_path.get(current["parent"])
if not parent:
break
current = parent
return current
folder_map: dict[str, dict[str, str]] = {}
for path, folder in by_path.items():
ancestor = top_ancestor(folder)
folder_map[path] = {
"category": ancestor.get("name", ""),
"subcategory": folder.get("name", ""),
}
return folder_map
async def get_file_url(self, file_id: str | int) -> str | None: async def get_file_url(self, file_id: str | int) -> str | None:
"""Fetch the S3 URL for a file from Rentman. """Fetch the S3 URL for a file from Rentman.
@@ -71,41 +118,122 @@ class RentmanService:
logger.warning("Failed to fetch file URL for file_id=%s: %s", file_id, exc) logger.warning("Failed to fetch file URL for file_id=%s: %s", file_id, exc)
return None return None
async def transform_equipment(self, raw: dict[str, Any]) -> dict[str, Any]: # Well-known rental brands for extraction from equipment names
KNOWN_BRANDS = [
"d&b audiotechnik", "d&b", "L-Acoustics", "Shure", "Sennheiser", "Pioneer",
"RCF", "Soundcraft", "Cameo", "Eurolite", "Globaltruss", "Klotz",
"Martin", "JBL", "QSC", "Yamaha", "Behringer", "ROBE", "Chauvet",
"PL-Audio", "Showtec", "American DJ", "ADJ", "GLP", "Wireless Solution",
"Sennheiser", "AKG", "Rode", "Audix", "Palmer", "Neutrik", "Layher",
]
@staticmethod
def _strip_html(text: str) -> str:
"""Remove HTML tags and normalize whitespace in description fields."""
if not text:
return ""
text = re.sub(r"<br\s*/?>", "\n", text, flags=re.IGNORECASE)
text = re.sub(r"</p>", "\n", text, flags=re.IGNORECASE)
text = re.sub(r"<[^>]+>", "", text)
text = text.replace("&nbsp;", " ").replace("&amp;", "&")
text = text.replace("&lt;", "<").replace("&gt;", ">").replace("&quot;", '"')
text = re.sub(r"[ \t]+", " ", text)
text = re.sub(r"\n\s*\n+", "\n\n", text)
return text.strip()
def _extract_brand(self, name: str) -> str:
"""Extract a known brand from the equipment name, if present."""
lowered = (name or "").lower()
for brand in self.KNOWN_BRANDS:
if lowered.startswith(brand.lower() + " ") or lowered.startswith(brand.lower() + "-"):
return brand
return ""
def _resolve_category(self, folder_path: str | None, folder_map: dict[str, dict[str, str]] | None) -> tuple[str, str]:
"""Resolve (category, subcategory) from the Rentman folder path.
category = top-level folder name (e.g. "Tontechnik")
subcategory = direct folder name (e.g. "Mikrofone")
"""
if not folder_path or not folder_map:
return "", ""
info = folder_map.get(folder_path)
if not info:
return "", ""
return info.get("category", ""), info.get("subcategory", "")
def _build_specifications(self, raw: dict[str, Any]) -> dict[str, Any]:
"""Collect physical specs from flat Rentman fields when set."""
specs: dict[str, Any] = {}
field_map = [
("Leistung (W)", "power"),
("Gewicht (kg)", "empty_weight"),
("Hoehe (mm)", "height"),
("Breite (mm)", "width"),
("Laenge (mm)", "length"),
("Volumen (m3)", "volume"),
]
for label, key in field_map:
value = raw.get(key)
if isinstance(value, (int, float)) and value > 0:
specs[label] = value
return specs
async def transform_equipment(
self,
raw: dict[str, Any],
folder_map: dict[str, dict[str, str]] | None = None,
) -> dict[str, Any]:
"""Map a raw Rentman equipment object to equipment_cache schema. """Map a raw Rentman equipment object to equipment_cache schema.
Uses 'image' (singular) field which contains a relative path like '/files/3173'. Uses the real Rentman field names:
Fetches the actual S3 URL via GET /files/{file_id}. - price -> rental_price (per day, shop price)
- folder ("/folders/N") -> category/subcategory via folder_map
- shop_description_long (fallbacks: shop_description_short,
external_remark) -> description (HTML stripped)
- code -> number (article number)
- in_shop/in_archive -> availability for the public catalog
""" """
image_path = raw.get("image") image_path = raw.get("image")
image_urls: list[str] = [] image_urls: list[str] = []
if image_path and isinstance(image_path, str): if image_path and isinstance(image_path, str):
# Extract file_id from path like /files/3173
match = re.search(r"/files/(\d+)", image_path) match = re.search(r"/files/(\d+)", image_path)
if match: if match:
file_id = match.group(1) file_id = match.group(1)
s3_url = await self.get_file_url(file_id) s3_url = await self.get_file_url(file_id)
if s3_url: if s3_url:
image_urls = [s3_url] image_urls = [s3_url]
else:
logger.debug("Could not extract file_id from image path: %s", image_path)
group = raw.get("equipment_group") or {} category, subcategory = self._resolve_category(raw.get("folder"), folder_map)
category = group.get("name", "") if isinstance(group, dict) else str(group or "")
description = self._strip_html(
raw.get("shop_description_long")
or raw.get("shop_description_short")
or raw.get("external_remark")
or ""
)
price = raw.get("price")
try:
price = float(price) if price is not None else None
except (TypeError, ValueError):
price = None
available = bool(raw.get("in_shop")) and not bool(raw.get("in_archive")) and not bool(raw.get("temporary"))
return { return {
"rentman_id": str(raw.get("id", "")), "rentman_id": str(raw.get("id", "")),
"name": raw.get("name", ""), "name": raw.get("name", "") or raw.get("displayname", ""),
"number": raw.get("number") or raw.get("code", ""), "number": raw.get("code", "") or raw.get("number", ""),
"category": category, "category": category,
"subcategory": raw.get("subcategory", ""), "subcategory": subcategory,
"description": raw.get("description", ""), "description": description,
"specifications": raw.get("specifications", {}), "specifications": self._build_specifications(raw) or None,
"images": image_urls, "images": image_urls,
"rental_price": raw.get("rental_price"), "rental_price": price,
"brand": raw.get("brand", ""), "brand": self._extract_brand(raw.get("name", "")),
"available": raw.get("available", True), "available": available,
"update_hash": raw.get("updateHash", ""), "update_hash": raw.get("updateHash", ""),
} }
+13 -4
View File
@@ -23,10 +23,10 @@ class SyncService:
self.db = db self.db = db
self.rentman = rentman or RentmanService() self.rentman = rentman or RentmanService()
async def run_sync(self) -> dict[str, Any]: async def run_sync(self, sync_log_id: int | None = None) -> dict[str, Any]:
"""Execute an incremental equipment sync. """Execute an incremental equipment sync.
1. Create sync_log entry (status=running) 1. Create sync_log entry (status=running) or reuse sync_log_id
2. Paginate GET /equipment from Rentman 2. Paginate GET /equipment from Rentman
3. Compare updateHash with DB, only process changed items 3. Compare updateHash with DB, only process changed items
4. Download images only for changed items 4. Download images only for changed items
@@ -35,12 +35,20 @@ class SyncService:
7. Update sync_log (status=completed or failed) 7. Update sync_log (status=completed or failed)
Returns dict with sync_id, items_processed, status. Returns dict with sync_id, items_processed, status.
""" """
if sync_log_id is not None:
log_entry = await self.db.get(SyncLog, sync_log_id)
if not log_entry:
log_entry = None
if sync_log_id is None or log_entry is None:
log_entry = SyncLog( log_entry = SyncLog(
sync_type="equipment", sync_type="equipment",
status="running", status="running",
started_at=datetime.utcnow(), started_at=datetime.utcnow(),
) )
self.db.add(log_entry) self.db.add(log_entry)
else:
log_entry.status = "running"
log_entry.started_at = datetime.utcnow()
await self.db.commit() await self.db.commit()
await self.db.refresh(log_entry) await self.db.refresh(log_entry)
sync_id = log_entry.id sync_id = log_entry.id
@@ -53,8 +61,9 @@ class SyncService:
# Ensure images directory exists # Ensure images directory exists
os.makedirs(IMAGES_DIR, exist_ok=True) os.makedirs(IMAGES_DIR, exist_ok=True)
# Fetch all equipment from Rentman # Fetch all equipment and folders from Rentman
all_equipment = await self.rentman.get_all_equipment(limit=100) all_equipment = await self.rentman.get_all_equipment(limit=100)
folder_map = await self.rentman.get_folder_map()
# Build set of rentman_ids from API for availability check # Build set of rentman_ids from API for availability check
api_rentman_ids = set() api_rentman_ids = set()
@@ -75,7 +84,7 @@ class SyncService:
continue continue
try: try:
transformed = await self.rentman.transform_equipment(raw_item) transformed = await self.rentman.transform_equipment(raw_item, folder_map=folder_map)
await self._upsert_equipment(transformed) await self._upsert_equipment(transformed)
# Download image if S3 URL available # Download image if S3 URL available
+1 -1
View File
@@ -16,4 +16,4 @@ pytest>=8.0.0
pytest-asyncio>=0.23.0 pytest-asyncio>=0.23.0
pytest-cov>=5.0.0 pytest-cov>=5.0.0
httpx>=0.27.0 httpx>=0.27.0
mcp>=1.0.0 mcp>=1.0.0,<2
+7 -4
View File
@@ -35,15 +35,18 @@ async def test_sync_with_valid_token(client, seeded_admin, test_db):
}) })
token = login_resp.json()["access_token"] token = login_resp.json()["access_token"]
# Mock sync service # Mock sync service (background task)
with patch.object(SyncService, "run_sync", new_callable=AsyncMock) as mock_sync: 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"} mock_sync.return_value = {"sync_id": 1, "items_processed": 10, "items_failed": 0, "status": "completed"}
resp = await client.post("/api/admin/sync", cookies={"hms_admin_token": token}) resp = await client.post("/api/admin/sync", cookies={"hms_admin_token": token})
assert resp.status_code == 200 assert resp.status_code == 200
data = resp.json() data = resp.json()
assert data["sync_id"] == 42 # Background sync: request returns immediately with a running log entry
assert data["status"] == "completed" assert data["sync_id"] == 1
assert data["status"] == "running"
# Background task executed by TestClient: run_sync was called once
mock_sync.assert_awaited_once()
@pytest.mark.asyncio @pytest.mark.asyncio
+48 -12
View File
@@ -10,31 +10,63 @@ from app.services.rentman_service import RentmanService
def make_raw_equipment(rid: str, name: str, category: str = "Lautsprecher") -> dict: def make_raw_equipment(rid: str, name: str, category: str = "Lautsprecher") -> dict:
"""Raw equipment item using REAL Rentman API field names."""
return { return {
"id": rid, "id": rid,
"name": name, "name": name,
"number": f"{name[:3].upper()}-001", "displayname": name,
"code": f"{name[:3].upper()}-001", "code": f"{name[:3].upper()}-001",
"equipment_group": {"name": category}, "folder": f"/folders/{int(rid) % 5 + 1}",
"description": f"Description for {name}", "shop_description_long": f"<p>Description for {name}</p>",
"specifications": {"weight": 50, "power": 750}, "external_remark": "",
"images": [{"url": f"https://example.com/{rid}.jpg"}], "power": 750,
"weight": 50,
"empty_weight": 50,
"image": f"/files/{rid}",
"price": 150.00,
"in_shop": True,
"in_archive": False,
"temporary": False,
"updateHash": f"hash-{rid}",
}
def make_transformed(raw: dict, folder_map=None) -> dict:
"""Transformed equipment dict as produced by the real Rentman mapping (no network)."""
return {
"rentman_id": str(raw["id"]),
"name": raw["name"],
"number": raw.get("code", ""),
"category": "Tontechnik",
"subcategory": "Mikrofone",
"description": f"Description for {raw['name']}",
"specifications": None,
"images": [],
"rental_price": 150.00, "rental_price": 150.00,
"brand": "L-Acoustics", "brand": "",
"available": True, "available": True,
"update_hash": raw.get("updateHash", ""),
} }
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_transform_equipment(): async def test_transform_equipment():
raw = make_raw_equipment("42", "K2 Line Array", "Lautsprecher") raw = make_raw_equipment("42", "Shure SM58 Mikrofon dynamisch")
result = RentmanService.transform_equipment(raw) service = RentmanService.__new__(RentmanService) # no network on init path
folder_map = {
f"/folders/{42 % 5 + 1}": {"category": "Tontechnik", "subcategory": "Mikrofone"},
}
with patch.object(RentmanService, "get_file_url", new=AsyncMock(return_value="https://example.com/42.jpg")):
result = await service.transform_equipment(raw, folder_map=folder_map)
assert result["rentman_id"] == "42" assert result["rentman_id"] == "42"
assert result["name"] == "K2 Line Array" assert result["name"] == "Shure SM58 Mikrofon dynamisch"
assert result["category"] == "Lautsprecher" assert result["category"] == "Tontechnik"
assert result["images"] == ["https://example.com/42.jpg"] assert result["subcategory"] == "Mikrofone"
assert result["brand"] == "L-Acoustics" assert result["rental_price"] == 150.00
assert result["brand"] == "Shure"
assert result["available"] is True assert result["available"] is True
assert result["description"] == "Description for Shure SM58 Mikrofon dynamisch"
assert result["images"] == ["https://example.com/42.jpg"]
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -49,6 +81,8 @@ async def test_paginated_import(test_db):
mock_rentman.get_all_equipment = AsyncMock(return_value=[ mock_rentman.get_all_equipment = AsyncMock(return_value=[
*[make_raw_equipment(str(i), f"Item {i}") for i in range(250)] *[make_raw_equipment(str(i), f"Item {i}") for i in range(250)]
]) ])
mock_rentman.get_folder_map = AsyncMock(return_value={})
mock_rentman.transform_equipment = AsyncMock(side_effect=make_transformed)
with patch("app.services.sync_service.cache") as mock_cache: with patch("app.services.sync_service.cache") as mock_cache:
mock_cache.delete_pattern = AsyncMock(return_value=0) mock_cache.delete_pattern = AsyncMock(return_value=0)
@@ -83,6 +117,8 @@ async def test_sync_upsert_existing(test_db):
mock_rentman.get_all_equipment = AsyncMock(return_value=[ mock_rentman.get_all_equipment = AsyncMock(return_value=[
make_raw_equipment("100", "New Name", "Lautsprecher") make_raw_equipment("100", "New Name", "Lautsprecher")
]) ])
mock_rentman.get_folder_map = AsyncMock(return_value={})
mock_rentman.transform_equipment = AsyncMock(side_effect=make_transformed)
with patch("app.services.sync_service.cache") as mock_cache: with patch("app.services.sync_service.cache") as mock_cache:
mock_cache.delete_pattern = AsyncMock(return_value=0) mock_cache.delete_pattern = AsyncMock(return_value=0)
+5 -30
View File
@@ -1,24 +1,11 @@
services: services:
frontend: frontend:
build: ./frontend build: ./frontend
ports: labels:
- 3000:3000 - traefik.http.services.https-0-d9tyazu90ywaixl6l77wh7zd-frontend.loadbalancer.server.port=3000
depends_on: depends_on:
backend: backend:
condition: service_healthy condition: service_healthy
labels:
- traefik.enable=true
- traefik.http.middlewares.hms-gzip.compress=true
- traefik.http.middlewares.hms-redirect.redirectscheme.scheme=https
- traefik.http.routers.hms-http.entryPoints=http
- traefik.http.routers.hms-http.middlewares=hms-redirect
- traefik.http.routers.hms-http.rule=Host(`hms.media-on.de`) && PathPrefix(`/`)
- traefik.http.routers.hms-https.entryPoints=https
- traefik.http.routers.hms-https.middlewares=hms-gzip
- traefik.http.routers.hms-https.rule=Host(`hms.media-on.de`) && PathPrefix(`/`)
- traefik.http.routers.hms-https.tls=true
- traefik.http.routers.hms-https.tls.certresolver=letsencrypt
- traefik.http.services.hms.loadbalancer.server.port=3000
restart: unless-stopped restart: unless-stopped
healthcheck: healthcheck:
test: test:
@@ -53,34 +40,21 @@ services:
- ADMIN_PASSWORD=${ADMIN_PASSWORD} - ADMIN_PASSWORD=${ADMIN_PASSWORD}
- MCP_AUTH_TOKEN=${MCP_AUTH_TOKEN:-} - MCP_AUTH_TOKEN=${MCP_AUTH_TOKEN:-}
volumes: volumes:
- ./:/data/repo
- ./images:/data/images
- /var/run/docker.sock:/var/run/docker.sock - /var/run/docker.sock:/var/run/docker.sock
- equipment_images:/data/images
restart: unless-stopped restart: unless-stopped
healthcheck: healthcheck:
test: test:
- CMD - CMD
- python - python
- -c - -c
- import urllib.request; urllib.request.urlopen('http://localhost:8000/api/health') - import urllib.request; urllib.request.urlopen("http://localhost:8000/api/health")
interval: 30s interval: 30s
timeout: 10s timeout: 10s
retries: 3 retries: 3
start_period: 10s start_period: 10s
networks: networks:
- hms-network - hms-network
- coolify
labels:
- traefik.enable=true
- traefik.http.routers.hms-mcp-http.entryPoints=http
- traefik.http.routers.hms-mcp-http.rule=Host(`hms.media-on.de`) && PathPrefix(`/mcp`)
- traefik.http.routers.hms-mcp-http.middlewares=hms-redirect
- traefik.http.routers.hms-mcp-https.entryPoints=https
- traefik.http.routers.hms-mcp-https.rule=Host(`hms.media-on.de`) && PathPrefix(`/mcp`)
- traefik.http.routers.hms-mcp-https.tls=true
- traefik.http.routers.hms-mcp-https.tls.certresolver=letsencrypt
- traefik.http.routers.hms-mcp-https.middlewares=hms-gzip
- traefik.http.services.hms-mcp.loadbalancer.server.port=8000
postgres: postgres:
image: postgres:16-alpine image: postgres:16-alpine
volumes: volumes:
@@ -125,3 +99,4 @@ networks:
volumes: volumes:
postgres_data: null postgres_data: null
redis_data: null redis_data: null
equipment_images: null
+17 -1
View File
@@ -2,7 +2,10 @@ server {
listen 3000; listen 3000;
server_name _; server_name _;
root /usr/share/nginx/html; root /usr/share/nginx/html;
index index.html; index index index.html;
# Staging: keep out of search engines (remove when going live on official domain)
add_header X-Robots-Tag "noindex, nofollow" always;
location /api/ { location /api/ {
proxy_pass http://backend:8000/api/; proxy_pass http://backend:8000/api/;
@@ -12,6 +15,18 @@ server {
proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-Proto $scheme;
} }
location /mcp {
proxy_pass http://backend:8000/mcp;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
location / { location / {
try_files $uri $uri/ /index.html; try_files $uri $uri/ /index.html;
} }
@@ -19,5 +34,6 @@ server {
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ { location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
expires 1y; expires 1y;
add_header Cache-Control "public, immutable"; add_header Cache-Control "public, immutable";
add_header X-Robots-Tag "noindex, nofollow" always;
} }
} }
+2541
View File
File diff suppressed because it is too large Load Diff
+4 -2
View File
@@ -20,8 +20,10 @@ export default function EquipmentCard({ item, onClick, onAddToCart }: Props) {
<div className="p-4"> <div className="p-4">
<h3 className="font-semibold text-sm mb-1 truncate" style={{ color: 'var(--text)' }}>{item.name}</h3> <h3 className="font-semibold text-sm mb-1 truncate" style={{ color: 'var(--text)' }}>{item.name}</h3>
<p className="text-xs mb-3 line-clamp-2" style={{ color: 'var(--secondary)' }}>{item.description}</p> <p className="text-xs mb-3 line-clamp-2" style={{ color: 'var(--secondary)' }}>{item.description}</p>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between gap-2">
<span className="text-xs" style={{ color: 'var(--secondary)' }}>{item.code}</span> <span className="text-xs font-medium" style={{ color: 'var(--text)' }}>
{item.rental_price != null ? `${Number(item.rental_price).toFixed(2).replace('.', ',')} € / Tag` : item.code}
</span>
<button onClick={e => { e.stopPropagation(); onAddToCart(item); }} className="hms-btn hms-btn-ghost text-xs px-3 py-1.5" style={{ color: 'var(--color-accent)' }} aria-label={item.name + ' zum Warenkorb hinzufügen'}>+ Hinzufügen</button> <button onClick={e => { e.stopPropagation(); onAddToCart(item); }} className="hms-btn hms-btn-ghost text-xs px-3 py-1.5" style={{ color: 'var(--color-accent)' }} aria-label={item.name + ' zum Warenkorb hinzufügen'}>+ Hinzufügen</button>
</div> </div>
</div> </div>
+8 -2
View File
@@ -41,7 +41,7 @@ export default function EquipmentDetail() {
addItemByFields({ addItemByFields({
equipment_id: item.id, equipment_id: item.id,
name: item.name, name: item.name,
rental_price: null, rental_price: item.rental_price ?? null,
image_url: item.image_url || null, image_url: item.image_url || null,
}); });
navigateTo('/warenkorb'); navigateTo('/warenkorb');
@@ -99,6 +99,12 @@ export default function EquipmentDetail() {
)} )}
<div className="hms-card p-6"> <div className="hms-card p-6">
<h2 className="text-sm font-semibold mb-4" style={{ color: 'var(--text)' }}>Mietanfrage</h2> <h2 className="text-sm font-semibold mb-4" style={{ color: 'var(--text)' }}>Mietanfrage</h2>
{item.rental_price != null && (
<div className="flex items-baseline justify-between mb-4 pb-4 border-b" style={{ borderColor: 'var(--border)' }}>
<span className="text-sm" style={{ color: 'var(--secondary)' }}>Mietpreis pro Tag</span>
<span className="text-2xl font-bold" style={{ color: 'var(--color-accent)' }}>{Number(item.rental_price).toFixed(2).replace('.', ',')} €</span>
</div>
)}
<div className="grid grid-cols-2 gap-3 mb-4"> <div className="grid grid-cols-2 gap-3 mb-4">
<div> <div>
<label htmlFor="rental-start" className="block text-xs font-medium mb-1" style={{ color: 'var(--secondary)' }}>Mietbeginn</label> <label htmlFor="rental-start" className="block text-xs font-medium mb-1" style={{ color: 'var(--secondary)' }}>Mietbeginn</label>
@@ -121,7 +127,7 @@ export default function EquipmentDetail() {
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M3 3h2l.4 2M7 13h10l4-8H5.4M7 13L5.4 5M7 13l-2.293 2.293c-.63.63-.184 1.707.707 1.707H17m0 0a2 2 0 100 4 2 2 0 000-4zm-8 2a2 2 0 11-4 0 2 2 0 014 0z"/></svg> <svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M3 3h2l.4 2M7 13h10l4-8H5.4M7 13L5.4 5M7 13l-2.293 2.293c-.63.63-.184 1.707.707 1.707H17m0 0a2 2 0 100 4 2 2 0 000-4zm-8 2a2 2 0 11-4 0 2 2 0 014 0z"/></svg>
Zur Mietanfrage hinzufügen Zur Mietanfrage hinzufügen
</button> </button>
<p className="text-xs mt-3 text-center" style={{ color: 'var(--secondary)' }}>Preise auf Anfrage – unverbindliche Mietanfrage</p> <p className="text-xs mt-3 text-center" style={{ color: 'var(--secondary)' }}>{item.rental_price != null ? 'Unverbindliche Mietanfrage – Endpreis inkl. Rabatt nach Prüfung' : 'Preise auf Anfrage – unverbindliche Mietanfrage'}</p>
</div> </div>
</div> </div>
</div> </div>
+3 -1
View File
@@ -85,7 +85,7 @@ export default function Mietkatalog() {
addItemByFields({ addItemByFields({
equipment_id: item.id, equipment_id: item.id,
name: item.name, name: item.name,
rental_price: null, rental_price: item.rental_price ?? null,
image_url: item.image_url || null, image_url: item.image_url || null,
}); });
} }
@@ -120,6 +120,8 @@ export default function Mietkatalog() {
<select value={sortBy} onChange={e => { setSortBy(e.target.value as SortOption); setCurrentPage(1); reload(); }} className="hms-input lg:w-48" aria-label="Sortieren nach"> <select value={sortBy} onChange={e => { setSortBy(e.target.value as SortOption); setCurrentPage(1); reload(); }} className="hms-input lg:w-48" aria-label="Sortieren nach">
<option value="name_asc">Sortieren: Name (A-Z)</option> <option value="name_asc">Sortieren: Name (A-Z)</option>
<option value="name_desc">Sortieren: Name (Z-A)</option> <option value="name_desc">Sortieren: Name (Z-A)</option>
<option value="price_asc">Sortieren: Preis (aufsteigend)</option>
<option value="price_desc">Sortieren: Preis (absteigend)</option>
</select> </select>
</div> </div>
{categories.length > 1 && ( {categories.length > 1 && (
+11
View File
@@ -74,6 +74,11 @@ export default function Warenkorb() {
</button> </button>
</div> </div>
<div className="flex flex-wrap items-center gap-3 mt-3"> <div className="flex flex-wrap items-center gap-3 mt-3">
{item.rental_price != null && (
<span className="text-xs font-medium" style={{ color: 'var(--color-accent)' }}>
{Number(item.rental_price).toFixed(2).replace('.', ',')} € / Tag × {item.quantity}
</span>
)}
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<button onClick={() => decrementQuantity(item.equipment_id)} className="w-7 h-7 rounded border flex items-center justify-center" style={{ borderColor: 'var(--border-strong)', color: 'var(--text-muted)' }} aria-label="Anzahl verringern">−</button> <button onClick={() => decrementQuantity(item.equipment_id)} className="w-7 h-7 rounded border flex items-center justify-center" style={{ borderColor: 'var(--border-strong)', color: 'var(--text-muted)' }} aria-label="Anzahl verringern">−</button>
<span className="w-8 text-center text-sm font-medium" style={{ color: 'var(--text)' }}>{item.quantity}</span> <span className="w-8 text-center text-sm font-medium" style={{ color: 'var(--text)' }}>{item.quantity}</span>
@@ -92,6 +97,12 @@ export default function Warenkorb() {
<div className="text-sm mb-4 pb-4 border-b" style={{ borderColor: 'var(--border)', color: 'var(--secondary)' }}> <div className="text-sm mb-4 pb-4 border-b" style={{ borderColor: 'var(--border)', color: 'var(--secondary)' }}>
<div className="flex justify-between mb-1"><span>Geräte gesamt:</span><span className="font-medium" style={{ color: 'var(--text)' }}>{totalCount}</span></div> <div className="flex justify-between mb-1"><span>Geräte gesamt:</span><span className="font-medium" style={{ color: 'var(--text)' }}>{totalCount}</span></div>
<div className="flex justify-between"><span>Positionen:</span><span className="font-medium" style={{ color: 'var(--text)' }}>{cartItems.length}</span></div> <div className="flex justify-between"><span>Positionen:</span><span className="font-medium" style={{ color: 'var(--text)' }}>{cartItems.length}</span></div>
{(() => {
const known = cartItems.filter(i => i.rental_price != null);
if (known.length === 0) return null;
const sum = known.reduce((acc, i) => acc + Number(i.rental_price) * i.quantity, 0);
return <div className="flex justify-between mt-1"><span>Zwischensumme (pro Tag, ohne MwSt.):</span><span className="font-medium" style={{ color: 'var(--text)' }}>{sum.toFixed(2).replace('.', ',')} €</span></div>;
})()}
</div> </div>
<form onSubmit={submitRequest} noValidate> <form onSubmit={submitRequest} noValidate>
<div className="space-y-3"> <div className="space-y-3">
+1 -1
View File
@@ -33,7 +33,7 @@ export interface PaginatedEquipment {
total_pages: number; total_pages: number;
} }
export type SortOption = "name_asc" | "name_desc"; export type SortOption = "name_asc" | "name_desc" | "price_asc" | "price_desc";
export interface CartItem { export interface CartItem {
equipment_id: number; equipment_id: number;