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"
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.description}
-Preise auf Anfrage – unverbindliche Mietanfrage
+{item.rental_price != null ? 'Unverbindliche Mietanfrage – Endpreis inkl. Rabatt nach Prüfung' : 'Preise auf Anfrage – unverbindliche Mietanfrage'}