Compare commits
6 Commits
main
..
hms-office
| Author | SHA1 | Date | |
|---|---|---|---|
| e66bcc7058 | |||
| 709190719f | |||
| df50ea8b4b | |||
| 654ec09ce1 | |||
| 345230a9fc | |||
| 1b77f5815e |
+23
-1
@@ -9,6 +9,9 @@ from app.config import get_settings
|
|||||||
from app.database import engine, init_db, async_session
|
from app.database import engine, init_db, async_session
|
||||||
from app.cache import cache
|
from app.cache import cache
|
||||||
from app.routers import equipment, health, admin, rental_requests, contact
|
from app.routers import equipment, health, admin, rental_requests, contact
|
||||||
|
from app.models.admin_user import AdminUser
|
||||||
|
from app.auth import get_password_hash
|
||||||
|
from sqlalchemy import select
|
||||||
from app.services.sync_service import SyncService
|
from app.services.sync_service import SyncService
|
||||||
from app.services.email_service import EmailService
|
from app.services.email_service import EmailService
|
||||||
from app.mcp_server import get_mcp_app, mcp_session_manager
|
from app.mcp_server import get_mcp_app, mcp_session_manager
|
||||||
@@ -36,10 +39,29 @@ async def run_email_retry() -> None:
|
|||||||
logger.info("Email retry complete: %d emails sent", sent)
|
logger.info("Email retry complete: %d emails sent", sent)
|
||||||
|
|
||||||
|
|
||||||
|
async def seed_admin_user() -> None:
|
||||||
|
"""Ensure the admin user from settings exists (create or update password)."""
|
||||||
|
async with async_session() as db:
|
||||||
|
result = await db.execute(
|
||||||
|
select(AdminUser).where(AdminUser.username == settings.admin_username)
|
||||||
|
)
|
||||||
|
user = result.scalar_one_or_none()
|
||||||
|
if not user:
|
||||||
|
db.add(AdminUser(
|
||||||
|
username=settings.admin_username,
|
||||||
|
password_hash=get_password_hash(settings.admin_password),
|
||||||
|
))
|
||||||
|
logger.info("Seeded admin user %s", settings.admin_username)
|
||||||
|
else:
|
||||||
|
user.password_hash = get_password_hash(settings.admin_password)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def lifespan(app: FastAPI):
|
async def lifespan(app: FastAPI):
|
||||||
"""Application lifespan: init DB, start scheduler, connect cache."""
|
"""Application lifespan: init DB, seed admin, start scheduler, connect cache."""
|
||||||
await init_db()
|
await init_db()
|
||||||
|
await seed_admin_user()
|
||||||
await cache.connect()
|
await cache.connect()
|
||||||
|
|
||||||
_mcp_cm = mcp_session_manager.run()
|
_mcp_cm = mcp_session_manager.run()
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
"""Admin router: login, sync endpoints, sync log."""
|
"""Admin router: login, sync endpoints, sync log."""
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Response, Query, status
|
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Response, Query, status
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from typing import Any
|
from typing import Any
|
||||||
@@ -10,7 +10,9 @@ from app.schemas.auth import LoginRequest, TokenResponse, AdminInfo
|
|||||||
from app.schemas.sync import SyncStatus, SyncLogEntry, SyncTriggerResponse
|
from app.schemas.sync import SyncStatus, SyncLogEntry, SyncTriggerResponse
|
||||||
from app.auth import verify_password, create_access_token, get_current_user
|
from app.auth import verify_password, create_access_token, get_current_user
|
||||||
from app.services.sync_service import SyncService
|
from app.services.sync_service import SyncService
|
||||||
|
from app.database import async_session
|
||||||
from app.cache import cache
|
from app.cache import cache
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/admin", tags=["admin"])
|
router = APIRouter(prefix="/api/admin", tags=["admin"])
|
||||||
|
|
||||||
@@ -54,13 +56,37 @@ async def get_me(user: AdminUser = Depends(get_current_user)) -> Any:
|
|||||||
|
|
||||||
@router.post("/sync", response_model=SyncTriggerResponse)
|
@router.post("/sync", response_model=SyncTriggerResponse)
|
||||||
async def trigger_sync(
|
async def trigger_sync(
|
||||||
|
background_tasks: BackgroundTasks,
|
||||||
user: AdminUser = Depends(get_current_user),
|
user: AdminUser = Depends(get_current_user),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
) -> Any:
|
) -> Any:
|
||||||
"""Trigger a manual equipment sync (admin only)."""
|
"""Trigger a manual equipment sync in the background (admin only).
|
||||||
sync_service = SyncService(db)
|
|
||||||
result = await sync_service.run_sync()
|
Runs the sync via BackgroundTasks so the HTTP request returns immediately
|
||||||
return SyncTriggerResponse(sync_id=result["sync_id"], status=result["status"])
|
(a full sync of 700+ items incl. image downloads takes minutes and
|
||||||
|
would otherwise hit proxy timeouts).
|
||||||
|
Poll GET /api/admin/sync-status for progress.
|
||||||
|
"""
|
||||||
|
log_entry = SyncLog(sync_type="equipment", status="running", started_at=datetime.utcnow())
|
||||||
|
db.add(log_entry)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(log_entry)
|
||||||
|
|
||||||
|
async def _run_sync(sync_id: int) -> None:
|
||||||
|
async with async_session() as sync_db:
|
||||||
|
sync_service = SyncService(sync_db)
|
||||||
|
try:
|
||||||
|
await sync_service.run_sync(sync_log_id=sync_id)
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
log = await sync_db.get(SyncLog, sync_id)
|
||||||
|
if log:
|
||||||
|
log.status = "failed"
|
||||||
|
log.error_message = str(exc)
|
||||||
|
log.completed_at = datetime.utcnow()
|
||||||
|
await sync_db.commit()
|
||||||
|
|
||||||
|
background_tasks.add_task(_run_sync, log_entry.id)
|
||||||
|
return SyncTriggerResponse(sync_id=log_entry.id, status="running")
|
||||||
|
|
||||||
|
|
||||||
@router.get("/sync-status", response_model=SyncStatus)
|
@router.get("/sync-status", response_model=SyncStatus)
|
||||||
|
|||||||
@@ -30,8 +30,8 @@ async def list_equipment(
|
|||||||
if cached:
|
if cached:
|
||||||
return cached
|
return cached
|
||||||
|
|
||||||
query = select(EquipmentCache)
|
query = select(EquipmentCache).where(EquipmentCache.available == True) # noqa: E712
|
||||||
count_query = select(func.count(EquipmentCache.id))
|
count_query = select(func.count(EquipmentCache.id)).where(EquipmentCache.available == True) # noqa: E712
|
||||||
|
|
||||||
if search:
|
if search:
|
||||||
query = query.where(EquipmentCache.name.ilike(f"%{search}%"))
|
query = query.where(EquipmentCache.name.ilike(f"%{search}%"))
|
||||||
@@ -42,6 +42,10 @@ async def list_equipment(
|
|||||||
|
|
||||||
if sort == "name_desc":
|
if sort == "name_desc":
|
||||||
query = query.order_by(EquipmentCache.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:
|
else:
|
||||||
query = query.order_by(EquipmentCache.name.asc())
|
query = query.order_by(EquipmentCache.name.asc())
|
||||||
|
|
||||||
@@ -70,7 +74,9 @@ async def list_categories(db: AsyncSession = Depends(get_db)) -> Any:
|
|||||||
if cached:
|
if cached:
|
||||||
return cached
|
return cached
|
||||||
result = await db.execute(
|
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]]
|
categories = [row[0] for row in result.fetchall() if row[0]]
|
||||||
await cache.set("equipment:categories", categories, ttl=3600)
|
await cache.set("equipment:categories", categories, ttl=3600)
|
||||||
|
|||||||
@@ -50,6 +50,53 @@ class RentmanService:
|
|||||||
offset += limit
|
offset += limit
|
||||||
return all_items
|
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:
|
async def get_file_url(self, file_id: str | int) -> str | None:
|
||||||
"""Fetch the S3 URL for a file from Rentman.
|
"""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)
|
logger.warning("Failed to fetch file URL for file_id=%s: %s", file_id, exc)
|
||||||
return None
|
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"<br\s*/?>", "\n", text, flags=re.IGNORECASE)
|
||||||
|
text = re.sub(r"</p>", "\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.
|
"""Map a raw Rentman equipment object to equipment_cache schema.
|
||||||
|
|
||||||
Uses 'image' (singular) field which contains a relative path like '/files/3173'.
|
Uses the real Rentman field names:
|
||||||
Fetches the actual S3 URL via GET /files/{file_id}.
|
- 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_path = raw.get("image")
|
||||||
image_urls: list[str] = []
|
image_urls: list[str] = []
|
||||||
|
|
||||||
if image_path and isinstance(image_path, 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)
|
match = re.search(r"/files/(\d+)", image_path)
|
||||||
if match:
|
if match:
|
||||||
file_id = match.group(1)
|
file_id = match.group(1)
|
||||||
s3_url = await self.get_file_url(file_id)
|
s3_url = await self.get_file_url(file_id)
|
||||||
if s3_url:
|
if s3_url:
|
||||||
image_urls = [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, subcategory = self._resolve_category(raw.get("folder"), folder_map)
|
||||||
category = group.get("name", "") if isinstance(group, dict) else str(group or "")
|
|
||||||
|
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 {
|
return {
|
||||||
"rentman_id": str(raw.get("id", "")),
|
"rentman_id": str(raw.get("id", "")),
|
||||||
"name": raw.get("name", ""),
|
"name": raw.get("name", "") or raw.get("displayname", ""),
|
||||||
"number": raw.get("number") or raw.get("code", ""),
|
"number": raw.get("code", "") or raw.get("number", ""),
|
||||||
"category": category,
|
"category": category,
|
||||||
"subcategory": raw.get("subcategory", ""),
|
"subcategory": subcategory,
|
||||||
"description": raw.get("description", ""),
|
"description": description,
|
||||||
"specifications": raw.get("specifications", {}),
|
"specifications": self._build_specifications(raw) or None,
|
||||||
"images": image_urls,
|
"images": image_urls,
|
||||||
"rental_price": raw.get("rental_price"),
|
"rental_price": price,
|
||||||
"brand": raw.get("brand", ""),
|
"brand": self._extract_brand(raw.get("name", "")),
|
||||||
"available": raw.get("available", True),
|
"available": available,
|
||||||
"update_hash": raw.get("updateHash", ""),
|
"update_hash": raw.get("updateHash", ""),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -23,10 +23,10 @@ class SyncService:
|
|||||||
self.db = db
|
self.db = db
|
||||||
self.rentman = rentman or RentmanService()
|
self.rentman = rentman or RentmanService()
|
||||||
|
|
||||||
async def run_sync(self) -> dict[str, Any]:
|
async def run_sync(self, sync_log_id: int | None = None) -> dict[str, Any]:
|
||||||
"""Execute an incremental equipment sync.
|
"""Execute an incremental equipment sync.
|
||||||
|
|
||||||
1. Create sync_log entry (status=running)
|
1. Create sync_log entry (status=running) or reuse sync_log_id
|
||||||
2. Paginate GET /equipment from Rentman
|
2. Paginate GET /equipment from Rentman
|
||||||
3. Compare updateHash with DB, only process changed items
|
3. Compare updateHash with DB, only process changed items
|
||||||
4. Download images only for changed items
|
4. Download images only for changed items
|
||||||
@@ -35,12 +35,20 @@ class SyncService:
|
|||||||
7. Update sync_log (status=completed or failed)
|
7. Update sync_log (status=completed or failed)
|
||||||
Returns dict with sync_id, items_processed, status.
|
Returns dict with sync_id, items_processed, status.
|
||||||
"""
|
"""
|
||||||
log_entry = SyncLog(
|
if sync_log_id is not None:
|
||||||
sync_type="equipment",
|
log_entry = await self.db.get(SyncLog, sync_log_id)
|
||||||
status="running",
|
if not log_entry:
|
||||||
started_at=datetime.utcnow(),
|
log_entry = None
|
||||||
)
|
if sync_log_id is None or log_entry is None:
|
||||||
self.db.add(log_entry)
|
log_entry = SyncLog(
|
||||||
|
sync_type="equipment",
|
||||||
|
status="running",
|
||||||
|
started_at=datetime.utcnow(),
|
||||||
|
)
|
||||||
|
self.db.add(log_entry)
|
||||||
|
else:
|
||||||
|
log_entry.status = "running"
|
||||||
|
log_entry.started_at = datetime.utcnow()
|
||||||
await self.db.commit()
|
await self.db.commit()
|
||||||
await self.db.refresh(log_entry)
|
await self.db.refresh(log_entry)
|
||||||
sync_id = log_entry.id
|
sync_id = log_entry.id
|
||||||
@@ -53,8 +61,9 @@ class SyncService:
|
|||||||
# Ensure images directory exists
|
# Ensure images directory exists
|
||||||
os.makedirs(IMAGES_DIR, exist_ok=True)
|
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)
|
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
|
# Build set of rentman_ids from API for availability check
|
||||||
api_rentman_ids = set()
|
api_rentman_ids = set()
|
||||||
@@ -75,7 +84,7 @@ class SyncService:
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
try:
|
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)
|
await self._upsert_equipment(transformed)
|
||||||
|
|
||||||
# Download image if S3 URL available
|
# Download image if S3 URL available
|
||||||
|
|||||||
@@ -16,4 +16,4 @@ pytest>=8.0.0
|
|||||||
pytest-asyncio>=0.23.0
|
pytest-asyncio>=0.23.0
|
||||||
pytest-cov>=5.0.0
|
pytest-cov>=5.0.0
|
||||||
httpx>=0.27.0
|
httpx>=0.27.0
|
||||||
mcp>=1.0.0
|
mcp>=1.0.0,<2
|
||||||
|
|||||||
@@ -35,15 +35,18 @@ async def test_sync_with_valid_token(client, seeded_admin, test_db):
|
|||||||
})
|
})
|
||||||
token = login_resp.json()["access_token"]
|
token = login_resp.json()["access_token"]
|
||||||
|
|
||||||
# Mock sync service
|
# Mock sync service (background task)
|
||||||
with patch.object(SyncService, "run_sync", new_callable=AsyncMock) as mock_sync:
|
with patch.object(SyncService, "run_sync", new_callable=AsyncMock) as mock_sync:
|
||||||
mock_sync.return_value = {"sync_id": 42, "items_processed": 10, "items_failed": 0, "status": "completed"}
|
mock_sync.return_value = {"sync_id": 1, "items_processed": 10, "items_failed": 0, "status": "completed"}
|
||||||
resp = await client.post("/api/admin/sync", cookies={"hms_admin_token": token})
|
resp = await client.post("/api/admin/sync", cookies={"hms_admin_token": token})
|
||||||
|
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
data = resp.json()
|
data = resp.json()
|
||||||
assert data["sync_id"] == 42
|
# Background sync: request returns immediately with a running log entry
|
||||||
assert data["status"] == "completed"
|
assert data["sync_id"] == 1
|
||||||
|
assert data["status"] == "running"
|
||||||
|
# Background task executed by TestClient: run_sync was called once
|
||||||
|
mock_sync.assert_awaited_once()
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
|
|||||||
@@ -10,31 +10,63 @@ from app.services.rentman_service import RentmanService
|
|||||||
|
|
||||||
|
|
||||||
def make_raw_equipment(rid: str, name: str, category: str = "Lautsprecher") -> dict:
|
def make_raw_equipment(rid: str, name: str, category: str = "Lautsprecher") -> dict:
|
||||||
|
"""Raw equipment item using REAL Rentman API field names."""
|
||||||
return {
|
return {
|
||||||
"id": rid,
|
"id": rid,
|
||||||
"name": name,
|
"name": name,
|
||||||
"number": f"{name[:3].upper()}-001",
|
"displayname": name,
|
||||||
"code": f"{name[:3].upper()}-001",
|
"code": f"{name[:3].upper()}-001",
|
||||||
"equipment_group": {"name": category},
|
"folder": f"/folders/{int(rid) % 5 + 1}",
|
||||||
"description": f"Description for {name}",
|
"shop_description_long": f"<p>Description for {name}</p>",
|
||||||
"specifications": {"weight": 50, "power": 750},
|
"external_remark": "",
|
||||||
"images": [{"url": f"https://example.com/{rid}.jpg"}],
|
"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,
|
"rental_price": 150.00,
|
||||||
"brand": "L-Acoustics",
|
"brand": "",
|
||||||
"available": True,
|
"available": True,
|
||||||
|
"update_hash": raw.get("updateHash", ""),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_transform_equipment():
|
async def test_transform_equipment():
|
||||||
raw = make_raw_equipment("42", "K2 Line Array", "Lautsprecher")
|
raw = make_raw_equipment("42", "Shure SM58 Mikrofon dynamisch")
|
||||||
result = RentmanService.transform_equipment(raw)
|
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["rentman_id"] == "42"
|
||||||
assert result["name"] == "K2 Line Array"
|
assert result["name"] == "Shure SM58 Mikrofon dynamisch"
|
||||||
assert result["category"] == "Lautsprecher"
|
assert result["category"] == "Tontechnik"
|
||||||
assert result["images"] == ["https://example.com/42.jpg"]
|
assert result["subcategory"] == "Mikrofone"
|
||||||
assert result["brand"] == "L-Acoustics"
|
assert result["rental_price"] == 150.00
|
||||||
|
assert result["brand"] == "Shure"
|
||||||
assert result["available"] is True
|
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
|
@pytest.mark.asyncio
|
||||||
@@ -49,6 +81,8 @@ async def test_paginated_import(test_db):
|
|||||||
mock_rentman.get_all_equipment = AsyncMock(return_value=[
|
mock_rentman.get_all_equipment = AsyncMock(return_value=[
|
||||||
*[make_raw_equipment(str(i), f"Item {i}") for i in range(250)]
|
*[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:
|
with patch("app.services.sync_service.cache") as mock_cache:
|
||||||
mock_cache.delete_pattern = AsyncMock(return_value=0)
|
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=[
|
mock_rentman.get_all_equipment = AsyncMock(return_value=[
|
||||||
make_raw_equipment("100", "New Name", "Lautsprecher")
|
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:
|
with patch("app.services.sync_service.cache") as mock_cache:
|
||||||
mock_cache.delete_pattern = AsyncMock(return_value=0)
|
mock_cache.delete_pattern = AsyncMock(return_value=0)
|
||||||
|
|||||||
+3
-1
@@ -2,7 +2,7 @@ services:
|
|||||||
frontend:
|
frontend:
|
||||||
build: ./frontend
|
build: ./frontend
|
||||||
labels:
|
labels:
|
||||||
- traefik.http.services.https-0-wvus7va5u0f9dmg27ggca7rl-frontend.loadbalancer.server.port=3000
|
- traefik.http.services.https-0-d9tyazu90ywaixl6l77wh7zd-frontend.loadbalancer.server.port=3000
|
||||||
depends_on:
|
depends_on:
|
||||||
backend:
|
backend:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
@@ -41,6 +41,7 @@ services:
|
|||||||
- MCP_AUTH_TOKEN=${MCP_AUTH_TOKEN:-}
|
- MCP_AUTH_TOKEN=${MCP_AUTH_TOKEN:-}
|
||||||
volumes:
|
volumes:
|
||||||
- /var/run/docker.sock:/var/run/docker.sock
|
- /var/run/docker.sock:/var/run/docker.sock
|
||||||
|
- equipment_images:/data/images
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test:
|
test:
|
||||||
@@ -98,3 +99,4 @@ networks:
|
|||||||
volumes:
|
volumes:
|
||||||
postgres_data: null
|
postgres_data: null
|
||||||
redis_data: null
|
redis_data: null
|
||||||
|
equipment_images: null
|
||||||
|
|||||||
+5
-1
@@ -2,7 +2,10 @@ server {
|
|||||||
listen 3000;
|
listen 3000;
|
||||||
server_name _;
|
server_name _;
|
||||||
root /usr/share/nginx/html;
|
root /usr/share/nginx/html;
|
||||||
index index.html;
|
index index index.html;
|
||||||
|
|
||||||
|
# Staging: keep out of search engines (remove when going live on official domain)
|
||||||
|
add_header X-Robots-Tag "noindex, nofollow" always;
|
||||||
|
|
||||||
location /api/ {
|
location /api/ {
|
||||||
proxy_pass http://backend:8000/api/;
|
proxy_pass http://backend:8000/api/;
|
||||||
@@ -31,5 +34,6 @@ server {
|
|||||||
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
|
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
|
||||||
expires 1y;
|
expires 1y;
|
||||||
add_header Cache-Control "public, immutable";
|
add_header Cache-Control "public, immutable";
|
||||||
|
add_header X-Robots-Tag "noindex, nofollow" always;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+2541
File diff suppressed because it is too large
Load Diff
@@ -20,8 +20,10 @@ export default function EquipmentCard({ item, onClick, onAddToCart }: Props) {
|
|||||||
<div className="p-4">
|
<div className="p-4">
|
||||||
<h3 className="font-semibold text-sm mb-1 truncate" style={{ color: 'var(--text)' }}>{item.name}</h3>
|
<h3 className="font-semibold text-sm mb-1 truncate" style={{ color: 'var(--text)' }}>{item.name}</h3>
|
||||||
<p className="text-xs mb-3 line-clamp-2" style={{ color: 'var(--secondary)' }}>{item.description}</p>
|
<p className="text-xs mb-3 line-clamp-2" style={{ color: 'var(--secondary)' }}>{item.description}</p>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between gap-2">
|
||||||
<span className="text-xs" style={{ color: 'var(--secondary)' }}>{item.code}</span>
|
<span className="text-xs font-medium" style={{ color: 'var(--text)' }}>
|
||||||
|
{item.rental_price != null ? `${Number(item.rental_price).toFixed(2).replace('.', ',')} € / Tag` : item.code}
|
||||||
|
</span>
|
||||||
<button onClick={e => { e.stopPropagation(); onAddToCart(item); }} className="hms-btn hms-btn-ghost text-xs px-3 py-1.5" style={{ color: 'var(--color-accent)' }} aria-label={item.name + ' zum Warenkorb hinzufügen'}>+ Hinzufügen</button>
|
<button onClick={e => { e.stopPropagation(); onAddToCart(item); }} className="hms-btn hms-btn-ghost text-xs px-3 py-1.5" style={{ color: 'var(--color-accent)' }} aria-label={item.name + ' zum Warenkorb hinzufügen'}>+ Hinzufügen</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ export default function EquipmentDetail() {
|
|||||||
addItemByFields({
|
addItemByFields({
|
||||||
equipment_id: item.id,
|
equipment_id: item.id,
|
||||||
name: item.name,
|
name: item.name,
|
||||||
rental_price: null,
|
rental_price: item.rental_price ?? null,
|
||||||
image_url: item.image_url || null,
|
image_url: item.image_url || null,
|
||||||
});
|
});
|
||||||
navigateTo('/warenkorb');
|
navigateTo('/warenkorb');
|
||||||
@@ -99,6 +99,12 @@ export default function EquipmentDetail() {
|
|||||||
)}
|
)}
|
||||||
<div className="hms-card p-6">
|
<div className="hms-card p-6">
|
||||||
<h2 className="text-sm font-semibold mb-4" style={{ color: 'var(--text)' }}>Mietanfrage</h2>
|
<h2 className="text-sm font-semibold mb-4" style={{ color: 'var(--text)' }}>Mietanfrage</h2>
|
||||||
|
{item.rental_price != null && (
|
||||||
|
<div className="flex items-baseline justify-between mb-4 pb-4 border-b" style={{ borderColor: 'var(--border)' }}>
|
||||||
|
<span className="text-sm" style={{ color: 'var(--secondary)' }}>Mietpreis pro Tag</span>
|
||||||
|
<span className="text-2xl font-bold" style={{ color: 'var(--color-accent)' }}>{Number(item.rental_price).toFixed(2).replace('.', ',')} €</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="grid grid-cols-2 gap-3 mb-4">
|
<div className="grid grid-cols-2 gap-3 mb-4">
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="rental-start" className="block text-xs font-medium mb-1" style={{ color: 'var(--secondary)' }}>Mietbeginn</label>
|
<label htmlFor="rental-start" className="block text-xs font-medium mb-1" style={{ color: 'var(--secondary)' }}>Mietbeginn</label>
|
||||||
@@ -121,7 +127,7 @@ export default function EquipmentDetail() {
|
|||||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M3 3h2l.4 2M7 13h10l4-8H5.4M7 13L5.4 5M7 13l-2.293 2.293c-.63.63-.184 1.707.707 1.707H17m0 0a2 2 0 100 4 2 2 0 000-4zm-8 2a2 2 0 11-4 0 2 2 0 014 0z"/></svg>
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M3 3h2l.4 2M7 13h10l4-8H5.4M7 13L5.4 5M7 13l-2.293 2.293c-.63.63-.184 1.707.707 1.707H17m0 0a2 2 0 100 4 2 2 0 000-4zm-8 2a2 2 0 11-4 0 2 2 0 014 0z"/></svg>
|
||||||
Zur Mietanfrage hinzufügen
|
Zur Mietanfrage hinzufügen
|
||||||
</button>
|
</button>
|
||||||
<p className="text-xs mt-3 text-center" style={{ color: 'var(--secondary)' }}>Preise auf Anfrage – unverbindliche Mietanfrage</p>
|
<p className="text-xs mt-3 text-center" style={{ color: 'var(--secondary)' }}>{item.rental_price != null ? 'Unverbindliche Mietanfrage – Endpreis inkl. Rabatt nach Prüfung' : 'Preise auf Anfrage – unverbindliche Mietanfrage'}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -85,7 +85,7 @@ export default function Mietkatalog() {
|
|||||||
addItemByFields({
|
addItemByFields({
|
||||||
equipment_id: item.id,
|
equipment_id: item.id,
|
||||||
name: item.name,
|
name: item.name,
|
||||||
rental_price: null,
|
rental_price: item.rental_price ?? null,
|
||||||
image_url: item.image_url || null,
|
image_url: item.image_url || null,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -120,6 +120,8 @@ export default function Mietkatalog() {
|
|||||||
<select value={sortBy} onChange={e => { setSortBy(e.target.value as SortOption); setCurrentPage(1); reload(); }} className="hms-input lg:w-48" aria-label="Sortieren nach">
|
<select value={sortBy} onChange={e => { setSortBy(e.target.value as SortOption); setCurrentPage(1); reload(); }} className="hms-input lg:w-48" aria-label="Sortieren nach">
|
||||||
<option value="name_asc">Sortieren: Name (A-Z)</option>
|
<option value="name_asc">Sortieren: Name (A-Z)</option>
|
||||||
<option value="name_desc">Sortieren: Name (Z-A)</option>
|
<option value="name_desc">Sortieren: Name (Z-A)</option>
|
||||||
|
<option value="price_asc">Sortieren: Preis (aufsteigend)</option>
|
||||||
|
<option value="price_desc">Sortieren: Preis (absteigend)</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
{categories.length > 1 && (
|
{categories.length > 1 && (
|
||||||
|
|||||||
@@ -74,6 +74,11 @@ export default function Warenkorb() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap items-center gap-3 mt-3">
|
<div className="flex flex-wrap items-center gap-3 mt-3">
|
||||||
|
{item.rental_price != null && (
|
||||||
|
<span className="text-xs font-medium" style={{ color: 'var(--color-accent)' }}>
|
||||||
|
{Number(item.rental_price).toFixed(2).replace('.', ',')} € / Tag × {item.quantity}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<button onClick={() => decrementQuantity(item.equipment_id)} className="w-7 h-7 rounded border flex items-center justify-center" style={{ borderColor: 'var(--border-strong)', color: 'var(--text-muted)' }} aria-label="Anzahl verringern">−</button>
|
<button onClick={() => decrementQuantity(item.equipment_id)} className="w-7 h-7 rounded border flex items-center justify-center" style={{ borderColor: 'var(--border-strong)', color: 'var(--text-muted)' }} aria-label="Anzahl verringern">−</button>
|
||||||
<span className="w-8 text-center text-sm font-medium" style={{ color: 'var(--text)' }}>{item.quantity}</span>
|
<span className="w-8 text-center text-sm font-medium" style={{ color: 'var(--text)' }}>{item.quantity}</span>
|
||||||
@@ -92,6 +97,12 @@ export default function Warenkorb() {
|
|||||||
<div className="text-sm mb-4 pb-4 border-b" style={{ borderColor: 'var(--border)', color: 'var(--secondary)' }}>
|
<div className="text-sm mb-4 pb-4 border-b" style={{ borderColor: 'var(--border)', color: 'var(--secondary)' }}>
|
||||||
<div className="flex justify-between mb-1"><span>Geräte gesamt:</span><span className="font-medium" style={{ color: 'var(--text)' }}>{totalCount}</span></div>
|
<div className="flex justify-between mb-1"><span>Geräte gesamt:</span><span className="font-medium" style={{ color: 'var(--text)' }}>{totalCount}</span></div>
|
||||||
<div className="flex justify-between"><span>Positionen:</span><span className="font-medium" style={{ color: 'var(--text)' }}>{cartItems.length}</span></div>
|
<div className="flex justify-between"><span>Positionen:</span><span className="font-medium" style={{ color: 'var(--text)' }}>{cartItems.length}</span></div>
|
||||||
|
{(() => {
|
||||||
|
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 <div className="flex justify-between mt-1"><span>Zwischensumme (pro Tag, ohne MwSt.):</span><span className="font-medium" style={{ color: 'var(--text)' }}>{sum.toFixed(2).replace('.', ',')} €</span></div>;
|
||||||
|
})()}
|
||||||
</div>
|
</div>
|
||||||
<form onSubmit={submitRequest} noValidate>
|
<form onSubmit={submitRequest} noValidate>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ export interface PaginatedEquipment {
|
|||||||
total_pages: number;
|
total_pages: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type SortOption = "name_asc" | "name_desc";
|
export type SortOption = "name_asc" | "name_desc" | "price_asc" | "price_desc";
|
||||||
|
|
||||||
export interface CartItem {
|
export interface CartItem {
|
||||||
equipment_id: number;
|
equipment_id: number;
|
||||||
|
|||||||
Reference in New Issue
Block a user