From df50ea8b4b320347389131cc765bd38c9757c370 Mon Sep 17 00:00:00 2001
From: Agent Zero
Date: Thu, 24 Sep 2026 21:26:18 +0200
Subject: [PATCH] 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
---
backend/app/routers/equipment.py | 12 +-
backend/app/services/rentman_service.py | 160 +++++++++++++++++++---
backend/app/services/sync_service.py | 5 +-
backend/tests/test_rentman_import.py | 60 ++++++--
docker-compose.yml | 2 +
frontend/src/components/EquipmentCard.tsx | 6 +-
frontend/src/pages/EquipmentDetail.tsx | 10 +-
frontend/src/pages/Mietkatalog.tsx | 4 +-
frontend/src/pages/Warenkorb.tsx | 11 ++
frontend/src/types/index.ts | 2 +-
10 files changed, 233 insertions(+), 39 deletions(-)
diff --git a/backend/app/routers/equipment.py b/backend/app/routers/equipment.py
index 590e709..0e9d8bd 100644
--- a/backend/app/routers/equipment.py
+++ b/backend/app/routers/equipment.py
@@ -30,8 +30,8 @@ async def list_equipment(
if cached:
return cached
- query = select(EquipmentCache)
- count_query = select(func.count(EquipmentCache.id))
+ query = select(EquipmentCache).where(EquipmentCache.available == True) # noqa: E712
+ count_query = select(func.count(EquipmentCache.id)).where(EquipmentCache.available == True) # noqa: E712
if search:
query = query.where(EquipmentCache.name.ilike(f"%{search}%"))
@@ -42,6 +42,10 @@ async def list_equipment(
if sort == "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:
query = query.order_by(EquipmentCache.name.asc())
@@ -70,7 +74,9 @@ async def list_categories(db: AsyncSession = Depends(get_db)) -> Any:
if cached:
return cached
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]]
await cache.set("equipment:categories", categories, ttl=3600)
diff --git a/backend/app/services/rentman_service.py b/backend/app/services/rentman_service.py
index 5aafd74..08a6929 100644
--- a/backend/app/services/rentman_service.py
+++ b/backend/app/services/rentman_service.py
@@ -50,6 +50,53 @@ class RentmanService:
offset += limit
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:
"""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)
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"
", "\n", text, flags=re.IGNORECASE)
+ text = re.sub(r"
", "\n", text, flags=re.IGNORECASE)
+ text = re.sub(r"<[^>]+>", "", text)
+ text = text.replace(" ", " ").replace("&", "&")
+ text = text.replace("<", "<").replace(">", ">").replace(""", '"')
+ 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.
- Uses 'image' (singular) field which contains a relative path like '/files/3173'.
- Fetches the actual S3 URL via GET /files/{file_id}.
+ Uses the real Rentman field names:
+ - 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_urls: list[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)
if match:
file_id = match.group(1)
s3_url = await self.get_file_url(file_id)
if 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 = group.get("name", "") if isinstance(group, dict) else str(group or "")
+ category, subcategory = self._resolve_category(raw.get("folder"), folder_map)
+
+ 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 {
"rentman_id": str(raw.get("id", "")),
- "name": raw.get("name", ""),
- "number": raw.get("number") or raw.get("code", ""),
+ "name": raw.get("name", "") or raw.get("displayname", ""),
+ "number": raw.get("code", "") or raw.get("number", ""),
"category": category,
- "subcategory": raw.get("subcategory", ""),
- "description": raw.get("description", ""),
- "specifications": raw.get("specifications", {}),
+ "subcategory": subcategory,
+ "description": description,
+ "specifications": self._build_specifications(raw) or None,
"images": image_urls,
- "rental_price": raw.get("rental_price"),
- "brand": raw.get("brand", ""),
- "available": raw.get("available", True),
+ "rental_price": price,
+ "brand": self._extract_brand(raw.get("name", "")),
+ "available": available,
"update_hash": raw.get("updateHash", ""),
}
diff --git a/backend/app/services/sync_service.py b/backend/app/services/sync_service.py
index ce77013..da4f9d3 100644
--- a/backend/app/services/sync_service.py
+++ b/backend/app/services/sync_service.py
@@ -53,8 +53,9 @@ class SyncService:
# Ensure images directory exists
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)
+ folder_map = await self.rentman.get_folder_map()
# Build set of rentman_ids from API for availability check
api_rentman_ids = set()
@@ -75,7 +76,7 @@ class SyncService:
continue
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)
# Download image if S3 URL available
diff --git a/backend/tests/test_rentman_import.py b/backend/tests/test_rentman_import.py
index 47f6214..0322131 100644
--- a/backend/tests/test_rentman_import.py
+++ b/backend/tests/test_rentman_import.py
@@ -10,31 +10,63 @@ from app.services.rentman_service import RentmanService
def make_raw_equipment(rid: str, name: str, category: str = "Lautsprecher") -> dict:
+ """Raw equipment item using REAL Rentman API field names."""
return {
"id": rid,
"name": name,
- "number": f"{name[:3].upper()}-001",
+ "displayname": name,
"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"}],
+ "folder": f"/folders/{int(rid) % 5 + 1}",
+ "shop_description_long": f"Description for {name}
",
+ "external_remark": "",
+ "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,
- "brand": "L-Acoustics",
+ "brand": "",
"available": True,
+ "update_hash": raw.get("updateHash", ""),
}
@pytest.mark.asyncio
async def test_transform_equipment():
- raw = make_raw_equipment("42", "K2 Line Array", "Lautsprecher")
- result = RentmanService.transform_equipment(raw)
+ raw = make_raw_equipment("42", "Shure SM58 Mikrofon dynamisch")
+ 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["name"] == "K2 Line Array"
- assert result["category"] == "Lautsprecher"
- assert result["images"] == ["https://example.com/42.jpg"]
- assert result["brand"] == "L-Acoustics"
+ assert result["name"] == "Shure SM58 Mikrofon dynamisch"
+ assert result["category"] == "Tontechnik"
+ assert result["subcategory"] == "Mikrofone"
+ assert result["rental_price"] == 150.00
+ assert result["brand"] == "Shure"
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
@@ -49,6 +81,8 @@ async def test_paginated_import(test_db):
mock_rentman.get_all_equipment = AsyncMock(return_value=[
*[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:
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=[
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:
mock_cache.delete_pattern = AsyncMock(return_value=0)
diff --git a/docker-compose.yml b/docker-compose.yml
index 88863d8..b5fb533 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -41,6 +41,7 @@ services:
- MCP_AUTH_TOKEN=${MCP_AUTH_TOKEN:-}
volumes:
- /var/run/docker.sock:/var/run/docker.sock
+ - equipment_images:/data/images
restart: unless-stopped
healthcheck:
test:
@@ -98,3 +99,4 @@ networks:
volumes:
postgres_data: null
redis_data: null
+ equipment_images: null
diff --git a/frontend/src/components/EquipmentCard.tsx b/frontend/src/components/EquipmentCard.tsx
index 94f57ea..d156861 100644
--- a/frontend/src/components/EquipmentCard.tsx
+++ b/frontend/src/components/EquipmentCard.tsx
@@ -20,8 +20,10 @@ export default function EquipmentCard({ item, onClick, onAddToCart }: Props) {
{item.name}
{item.description}
-
-
{item.code}
+
+
+ {item.rental_price != null ? `${Number(item.rental_price).toFixed(2).replace('.', ',')} € / Tag` : item.code}
+
diff --git a/frontend/src/pages/EquipmentDetail.tsx b/frontend/src/pages/EquipmentDetail.tsx
index 61bfef5..0efe4c2 100644
--- a/frontend/src/pages/EquipmentDetail.tsx
+++ b/frontend/src/pages/EquipmentDetail.tsx
@@ -41,7 +41,7 @@ export default function EquipmentDetail() {
addItemByFields({
equipment_id: item.id,
name: item.name,
- rental_price: null,
+ rental_price: item.rental_price ?? null,
image_url: item.image_url || null,
});
navigateTo('/warenkorb');
@@ -99,6 +99,12 @@ export default function EquipmentDetail() {
)}
Mietanfrage
+ {item.rental_price != null && (
+
+ Mietpreis pro Tag
+ {Number(item.rental_price).toFixed(2).replace('.', ',')} €
+
+ )}
@@ -121,7 +127,7 @@ export default function EquipmentDetail() {
Zur Mietanfrage hinzufügen
-
Preise auf Anfrage – unverbindliche Mietanfrage
+
{item.rental_price != null ? 'Unverbindliche Mietanfrage – Endpreis inkl. Rabatt nach Prüfung' : 'Preise auf Anfrage – unverbindliche Mietanfrage'}
diff --git a/frontend/src/pages/Mietkatalog.tsx b/frontend/src/pages/Mietkatalog.tsx
index 5659809..79b3ec7 100644
--- a/frontend/src/pages/Mietkatalog.tsx
+++ b/frontend/src/pages/Mietkatalog.tsx
@@ -85,7 +85,7 @@ export default function Mietkatalog() {
addItemByFields({
equipment_id: item.id,
name: item.name,
- rental_price: null,
+ rental_price: item.rental_price ?? null,
image_url: item.image_url || null,
});
}
@@ -120,6 +120,8 @@ export default function Mietkatalog() {
{categories.length > 1 && (
diff --git a/frontend/src/pages/Warenkorb.tsx b/frontend/src/pages/Warenkorb.tsx
index c22230d..72f4eda 100644
--- a/frontend/src/pages/Warenkorb.tsx
+++ b/frontend/src/pages/Warenkorb.tsx
@@ -74,6 +74,11 @@ export default function Warenkorb() {
+ {item.rental_price != null && (
+
+ {Number(item.rental_price).toFixed(2).replace('.', ',')} € / Tag × {item.quantity}
+
+ )}
{item.quantity}
@@ -92,6 +97,12 @@ export default function Warenkorb() {
Geräte gesamt:{totalCount}
Positionen:{cartItems.length}
+ {(() => {
+ 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
Zwischensumme (pro Tag, ohne MwSt.):{sum.toFixed(2).replace('.', ',')} €
;
+ })()}