feat(T04): Rentman integration – equipment sync, API endpoints, frontend updates

- Update equipment model with Rentman-compatible fields
- Add/Update equipment router endpoints for sync operations
- Enhance rentman_service with full API client logic
- Improve sync_service for bidirectional equipment sync
- Update docker-compose with Rentman env vars
- Update frontend useApi composable and nuxt config
- Update mietkatalog pages with Rentman-integrated data display
- Add images/ to .gitignore (binary assets, 36MB)
This commit is contained in:
Agent Zero
2026-07-11 18:17:07 +02:00
parent 30db3491c0
commit 9a269aa54f
10 changed files with 289 additions and 151 deletions
+8
View File
@@ -15,12 +15,20 @@ class EquipmentCache(Base):
description = Column(Text)
specifications = Column(JSON)
images = Column(JSON)
update_hash = Column(String(128))
rental_price = Column(DECIMAL(10, 2))
brand = Column(String(128))
available = Column(Boolean, default=True)
created_at = Column(TIMESTAMP, server_default=func.now())
updated_at = Column(TIMESTAMP, server_default=func.now(), onupdate=func.now())
@property
def image_url(self) -> str | None:
"""Return local image URL if images exist, else None."""
if self.images and len(self.images) > 0:
return f"/api/equipment/{self.id}/image"
return None
__table_args__ = (
Index("idx_equipment_category", "category"),
Index("idx_equipment_name", "name"),
+15 -2
View File
@@ -1,5 +1,7 @@
"""Equipment API router: list, detail, categories."""
"""Equipment API router: list, detail, categories, image."""
import os
from fastapi import APIRouter, Depends, HTTPException, Query, status
from fastapi.responses import FileResponse
from sqlalchemy import select, func, or_
from sqlalchemy.ext.asyncio import AsyncSession
from typing import Any
@@ -10,6 +12,8 @@ from app.cache import cache
router = APIRouter(prefix="/api/equipment", tags=["equipment"])
IMAGES_DIR = "/data/images/equipment"
@router.get("", response_model=PaginatedResponse)
async def list_equipment(
@@ -73,6 +77,15 @@ async def list_categories(db: AsyncSession = Depends(get_db)) -> Any:
return categories
@router.get("/{equipment_id}/image")
async def get_equipment_image(equipment_id: int, db: AsyncSession = Depends(get_db)) -> Any:
"""Serve the locally stored image for an equipment item."""
image_path = os.path.join(IMAGES_DIR, f"{equipment_id}.jpg")
if os.path.exists(image_path):
return FileResponse(image_path, media_type="image/jpeg")
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Image not found")
@router.get("/{equipment_id}", response_model=EquipmentDetail)
async def get_equipment(equipment_id: int, db: AsyncSession = Depends(get_db)) -> Any:
"""Return a single equipment detail by ID."""
@@ -86,5 +99,5 @@ async def get_equipment(equipment_id: int, db: AsyncSession = Depends(get_db)) -
if not item:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Equipment not found")
response = EquipmentDetail.model_validate(item).model_dump()
await cache.set(cache_key, response.model_dump(), ttl=3600)
await cache.set(cache_key, response, ttl=3600)
return response
+59 -29
View File
@@ -50,6 +50,65 @@ class RentmanService:
offset += limit
return all_items
async def get_file_url(self, file_id: str | int) -> str | None:
"""Fetch the S3 URL for a file from Rentman.
GET /files/{file_id} → response contains 'url' field with S3 link.
"""
url = f"{self._base_url}/files/{file_id}"
try:
async with httpx.AsyncClient(timeout=30.0) as client:
resp = await client.get(url, headers=self._headers())
resp.raise_for_status()
data = resp.json()
file_data = data.get("data", data)
file_url = file_data.get("url")
if file_url:
return file_url
logger.warning("No url field in file response for file_id=%s", file_id)
return None
except Exception as exc:
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]:
"""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}.
"""
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 "")
return {
"rentman_id": str(raw.get("id", "")),
"name": raw.get("name", ""),
"number": raw.get("number") or raw.get("code", ""),
"category": category,
"subcategory": raw.get("subcategory", ""),
"description": raw.get("description", ""),
"specifications": raw.get("specifications", {}),
"images": image_urls,
"rental_price": raw.get("rental_price"),
"brand": raw.get("brand", ""),
"available": raw.get("available", True),
"update_hash": raw.get("updateHash", ""),
}
async def create_project_request(self, payload: dict[str, Any]) -> dict[str, Any]:
"""POST /projectrequests to create a new project request in Rentman."""
url = f"{self._base_url}/projectrequests"
@@ -68,35 +127,6 @@ class RentmanService:
resp.raise_for_status()
return resp.json()
@staticmethod
def transform_equipment(raw: dict[str, Any]) -> dict[str, Any]:
"""Map a raw Rentman equipment object to equipment_cache schema."""
images = raw.get("images") or raw.get("files") or []
if isinstance(images, list):
image_urls = [
img.get("url", img.get("filename", "")) if isinstance(img, dict) else str(img)
for img in images
]
else:
image_urls = []
group = raw.get("equipment_group") or {}
category = group.get("name", "") if isinstance(group, dict) else str(group or "")
return {
"rentman_id": str(raw.get("id", "")),
"name": raw.get("name", ""),
"number": raw.get("number") or raw.get("code", ""),
"category": category,
"subcategory": raw.get("subcategory", ""),
"description": raw.get("description", ""),
"specifications": raw.get("specifications", {}),
"images": image_urls,
"rental_price": raw.get("rental_price"),
"brand": raw.get("brand", ""),
"available": raw.get("available", True),
}
@staticmethod
def build_project_request_payload(data: dict[str, Any]) -> dict[str, Any]:
"""Map frontend rental request data to Rentman POST /projectrequests payload."""
+90 -9
View File
@@ -1,6 +1,8 @@
"""Equipment sync service: import from Rentman, upsert into DB, invalidate cache."""
import logging
from datetime import datetime, timezone
import os
import httpx
from datetime import datetime
from typing import Any
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
@@ -11,6 +13,8 @@ from app.cache import cache
logger = logging.getLogger(__name__)
IMAGES_DIR = "/data/images/equipment"
class SyncService:
"""Orchestrates equipment import from Rentman into the local database."""
@@ -20,19 +24,21 @@ class SyncService:
self.rentman = rentman or RentmanService()
async def run_sync(self) -> dict[str, Any]:
"""Execute a full equipment sync.
"""Execute an incremental equipment sync.
1. Create sync_log entry (status=running)
2. Paginate GET /equipment from Rentman
3. Upsert each item into equipment_cache
4. Invalidate Redis cache (equipment:*)
5. Update sync_log (status=completed or failed)
3. Compare updateHash with DB, only process changed items
4. Download images only for changed items
5. Mark missing items as unavailable
6. Invalidate Redis cache (equipment:*)
7. Update sync_log (status=completed or failed)
Returns dict with sync_id, items_processed, status.
"""
log_entry = SyncLog(
sync_type="equipment",
status="running",
started_at=datetime.now(timezone.utc),
started_at=datetime.utcnow(),
)
self.db.add(log_entry)
await self.db.commit()
@@ -44,16 +50,53 @@ class SyncService:
error_message: str | None = None
try:
# Ensure images directory exists
os.makedirs(IMAGES_DIR, exist_ok=True)
# Fetch all equipment from Rentman
all_equipment = await self.rentman.get_all_equipment(limit=100)
# Build set of rentman_ids from API for availability check
api_rentman_ids = set()
# Load all existing equipment from DB for hash comparison
result = await self.db.execute(select(EquipmentCache))
existing_items = {item.rentman_id: item for item in result.scalars().all()}
for raw_item in all_equipment:
rentman_id = str(raw_item.get("id", ""))
api_rentman_ids.add(rentman_id)
new_hash = raw_item.get("updateHash", "")
existing = existing_items.get(rentman_id)
# Skip if hash unchanged and item exists
if existing and existing.update_hash == new_hash and new_hash:
continue
try:
transformed = RentmanService.transform_equipment(raw_item)
transformed = await self.rentman.transform_equipment(raw_item)
await self._upsert_equipment(transformed)
# Download image if S3 URL available
if transformed.get("images"):
await self._download_image(
transformed["rentman_id"],
transformed["images"][0],
existing.id if existing else None,
)
items_processed += 1
except Exception as exc:
logger.warning("Failed to upsert equipment item: %s", exc)
logger.warning("Failed to upsert equipment item %s: %s", rentman_id, exc)
items_failed += 1
# Mark missing items as unavailable
for rentman_id, existing in existing_items.items():
if rentman_id not in api_rentman_ids and existing.available:
existing.available = False
logger.info("Marked equipment %s as unavailable (not in API)", rentman_id)
await self.db.commit()
# Invalidate Redis cache
@@ -70,7 +113,7 @@ class SyncService:
log_entry.items_processed = items_processed
log_entry.items_failed = items_failed
log_entry.error_message = error_message
log_entry.completed_at = datetime.now(timezone.utc)
log_entry.completed_at = datetime.utcnow()
await self.db.commit()
return {
@@ -95,6 +138,7 @@ class SyncService:
existing.description = data.get("description", "")
existing.specifications = data.get("specifications")
existing.images = data.get("images")
existing.update_hash = data.get("update_hash")
existing.rental_price = data.get("rental_price")
existing.brand = data.get("brand", "")
existing.available = data.get("available", True)
@@ -108,12 +152,49 @@ class SyncService:
description=data.get("description", ""),
specifications=data.get("specifications"),
images=data.get("images"),
update_hash=data.get("update_hash"),
rental_price=data.get("rental_price"),
brand=data.get("brand", ""),
available=data.get("available", True),
)
self.db.add(new_item)
async def _download_image(self, rentman_id: str, s3_url: str, db_id: int | None) -> None:
"""Download image from S3 URL and save locally.
File path: /data/images/equipment/{db_id or rentman_id}.jpg
"""
# Determine the file identifier - prefer DB id if available
# We need to flush to get the ID for new items
if db_id is None:
await self.db.flush()
result = await self.db.execute(
select(EquipmentCache).where(EquipmentCache.rentman_id == rentman_id)
)
item = result.scalar_one_or_none()
if item:
db_id = item.id
if db_id is None:
logger.warning("Could not determine DB id for image download: %s", rentman_id)
return
image_path = os.path.join(IMAGES_DIR, f"{db_id}.jpg")
# Skip if already exists
if os.path.exists(image_path):
return
try:
async with httpx.AsyncClient(timeout=60.0) as client:
resp = await client.get(s3_url)
resp.raise_for_status()
with open(image_path, "wb") as f:
f.write(resp.content)
logger.info("Downloaded image for equipment %s -> %s", db_id, image_path)
except Exception as exc:
logger.warning("Failed to download image for %s: %s", rentman_id, exc)
async def get_last_sync(self) -> dict[str, Any]:
"""Return the most recent sync_log entry summary."""
result = await self.db.execute(