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
This commit is contained in:
Agent Zero
2026-09-24 21:26:18 +02:00
parent 654ec09ce1
commit df50ea8b4b
10 changed files with 233 additions and 39 deletions
+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:
"""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"<p>Description for {name}</p>",
"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)