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('.', ',')} €
; + })()}
diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index b515ee7..7d4d139 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -33,7 +33,7 @@ export interface PaginatedEquipment { total_pages: number; } -export type SortOption = "name_asc" | "name_desc"; +export type SortOption = "name_asc" | "name_desc" | "price_asc" | "price_desc"; export interface CartItem { equipment_id: number;