From 9a269aa54ff3cab307d209ccc44c39a924889fe9 Mon Sep 17 00:00:00 2001 From: Agent Zero Date: Sat, 11 Jul 2026 18:17:07 +0200 Subject: [PATCH] =?UTF-8?q?feat(T04):=20Rentman=20integration=20=E2=80=93?= =?UTF-8?q?=20equipment=20sync,=20API=20endpoints,=20frontend=20updates?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- .gitignore | 1 + backend/app/models/equipment.py | 8 ++ backend/app/routers/equipment.py | 17 +++- backend/app/services/rentman_service.py | 88 ++++++++++++------- backend/app/services/sync_service.py | 99 +++++++++++++++++++-- docker-compose.yml | 22 ++++- frontend/composables/useApi.ts | 10 +-- frontend/nuxt.config.ts | 11 ++- frontend/pages/mietkatalog.vue | 111 ++++++++++++++---------- frontend/pages/mietkatalog/[id].vue | 73 ++++------------ 10 files changed, 289 insertions(+), 151 deletions(-) diff --git a/.gitignore b/.gitignore index 70c185c..16fd2b8 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,4 @@ htmlcov/ node_modules/ .nuxt/ dist/ +images/ diff --git a/backend/app/models/equipment.py b/backend/app/models/equipment.py index 6c1e320..33f423c 100644 --- a/backend/app/models/equipment.py +++ b/backend/app/models/equipment.py @@ -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"), diff --git a/backend/app/routers/equipment.py b/backend/app/routers/equipment.py index ea501dc..590e709 100644 --- a/backend/app/routers/equipment.py +++ b/backend/app/routers/equipment.py @@ -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 diff --git a/backend/app/services/rentman_service.py b/backend/app/services/rentman_service.py index d5895d7..5aafd74 100644 --- a/backend/app/services/rentman_service.py +++ b/backend/app/services/rentman_service.py @@ -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.""" diff --git a/backend/app/services/sync_service.py b/backend/app/services/sync_service.py index ca8b072..ce77013 100644 --- a/backend/app/services/sync_service.py +++ b/backend/app/services/sync_service.py @@ -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( diff --git a/docker-compose.yml b/docker-compose.yml index 967bcb4..40aedeb 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -7,7 +7,20 @@ services: backend: condition: service_healthy environment: - - NUXT_PUBLIC_API_BASE=${NUXT_PUBLIC_API_BASE:-http://backend:8000} + - NUXT_PUBLIC_API_BASE=/api + labels: + - "traefik.enable=true" + - "traefik.http.middlewares.hms-gzip.compress=true" + - "traefik.http.middlewares.hms-redirect.redirectscheme.scheme=https" + - "traefik.http.routers.hms-http.entryPoints=http" + - "traefik.http.routers.hms-http.middlewares=hms-redirect" + - "traefik.http.routers.hms-http.rule=Host(`hms.media-on.de`) && PathPrefix(`/`)" + - "traefik.http.routers.hms-https.entryPoints=https" + - "traefik.http.routers.hms-https.middlewares=hms-gzip" + - "traefik.http.routers.hms-https.rule=Host(`hms.media-on.de`) && PathPrefix(`/`)" + - "traefik.http.routers.hms-https.tls=true" + - "traefik.http.routers.hms-https.tls.certresolver=letsencrypt" + - "traefik.http.services.hms.loadbalancer.server.port=3000" restart: unless-stopped healthcheck: test: ["CMD", "node", "-e", "fetch('http://localhost:3000/').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"] @@ -17,11 +30,10 @@ services: start_period: 15s networks: - hms-network + - coolify backend: build: ./backend - ports: - - "8000:8000" depends_on: postgres: condition: service_healthy @@ -40,6 +52,8 @@ services: - CORS_ORIGINS=${CORS_ORIGINS:-https://hms.media-on.de,http://localhost:3000} - ADMIN_USERNAME=${ADMIN_USERNAME:-admin} - ADMIN_PASSWORD=${ADMIN_PASSWORD} + volumes: + - ./images:/data/images restart: unless-stopped healthcheck: test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/api/health')"] @@ -85,6 +99,8 @@ services: networks: hms-network: driver: bridge + coolify: + external: true volumes: postgres_data: diff --git a/frontend/composables/useApi.ts b/frontend/composables/useApi.ts index b6b4f64..4ef3352 100644 --- a/frontend/composables/useApi.ts +++ b/frontend/composables/useApi.ts @@ -2,16 +2,14 @@ import type { $Fetch } from "nitropack"; /** * Base API client composable. - * Wraps $fetch with the configured API base URL from runtime config. - * Provides typed GET, POST, PUT, DELETE helpers. + * Uses server-side apiBase for SSR requests, public apiBase for client-side. */ export function useApi() { const config = useRuntimeConfig(); - const apiBase = config.public.apiBase; + // On server: config.apiBase (http://backend:8000) + // On client: config.public.apiBase (/api) + const apiBase = import.meta.server ? config.apiBase : config.public.apiBase; - /** - * Typed $fetch wrapper bound to API base URL. - */ const client: $Fetch = $fetch.create({ baseURL: apiBase, headers: { diff --git a/frontend/nuxt.config.ts b/frontend/nuxt.config.ts index 6b67d20..459ac97 100644 --- a/frontend/nuxt.config.ts +++ b/frontend/nuxt.config.ts @@ -51,9 +51,11 @@ export default defineNuxtConfig({ }, }, runtimeConfig: { - apiBase: process.env.API_BASE_URL || "http://localhost:8000", + // Server-only: used by SSR to call backend in Docker network + apiBase: process.env.API_BASE_URL || "http://backend:8000", public: { - apiBase: process.env.API_BASE_URL || "http://localhost:8000", + // Client-side: relative URL goes through Nuxt server proxy + apiBase: "/api", }, }, routeRules: { @@ -73,5 +75,10 @@ export default defineNuxtConfig({ }, nitro: { compressPublicAssets: true, + routeRules: { + "/api/**": { + proxy: "http://backend:8000/api/**", + }, + }, }, }); diff --git a/frontend/pages/mietkatalog.vue b/frontend/pages/mietkatalog.vue index e5d98dc..1dd8838 100644 --- a/frontend/pages/mietkatalog.vue +++ b/frontend/pages/mietkatalog.vue @@ -6,61 +6,85 @@
-
- +
+
-
+
+
+
{{ total }} {{ total === 1 ? 'Gerät' : 'Geräte' }} gefunden
+ + + +
+
+ + Seite {{ currentPage }} von {{ totalPages }} +
-
{{ filteredEquipment.length }} {{ filteredEquipment.length === 1 ? 'Gerät' : 'Geräte' }} gefunden
- - - -
diff --git a/frontend/pages/mietkatalog/[id].vue b/frontend/pages/mietkatalog/[id].vue index a583b7c..acb374e 100644 --- a/frontend/pages/mietkatalog/[id].vue +++ b/frontend/pages/mietkatalog/[id].vue @@ -1,21 +1,21 @@