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.cache import cache
|
||||
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.email_service import EmailService
|
||||
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)
|
||||
|
||||
|
||||
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
|
||||
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 seed_admin_user()
|
||||
await cache.connect()
|
||||
|
||||
_mcp_cm = mcp_session_manager.run()
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
"""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.ext.asyncio import AsyncSession
|
||||
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.auth import verify_password, create_access_token, get_current_user
|
||||
from app.services.sync_service import SyncService
|
||||
from app.database import async_session
|
||||
from app.cache import cache
|
||||
from datetime import datetime
|
||||
|
||||
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)
|
||||
async def trigger_sync(
|
||||
background_tasks: BackgroundTasks,
|
||||
user: AdminUser = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> Any:
|
||||
"""Trigger a manual equipment sync (admin only)."""
|
||||
sync_service = SyncService(db)
|
||||
result = await sync_service.run_sync()
|
||||
return SyncTriggerResponse(sync_id=result["sync_id"], status=result["status"])
|
||||
"""Trigger a manual equipment sync in the background (admin only).
|
||||
|
||||
Runs the sync via BackgroundTasks so the HTTP request returns immediately
|
||||
(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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"<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.
|
||||
|
||||
Uses 'image' (singular) field which contains a relative path like '/files/3173'.
|
||||
Fetches the actual S3 URL via GET /files/{file_id}.
|
||||
Uses the real Rentman field names:
|
||||
- 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_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 "")
|
||||
category, subcategory = self._resolve_category(raw.get("folder"), folder_map)
|
||||
|
||||
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 {
|
||||
"rentman_id": str(raw.get("id", "")),
|
||||
"name": raw.get("name", ""),
|
||||
"number": raw.get("number") or raw.get("code", ""),
|
||||
"name": raw.get("name", "") or raw.get("displayname", ""),
|
||||
"number": raw.get("code", "") or raw.get("number", ""),
|
||||
"category": category,
|
||||
"subcategory": raw.get("subcategory", ""),
|
||||
"description": raw.get("description", ""),
|
||||
"specifications": raw.get("specifications", {}),
|
||||
"subcategory": subcategory,
|
||||
"description": description,
|
||||
"specifications": self._build_specifications(raw) or None,
|
||||
"images": image_urls,
|
||||
"rental_price": raw.get("rental_price"),
|
||||
"brand": raw.get("brand", ""),
|
||||
"available": raw.get("available", True),
|
||||
"rental_price": price,
|
||||
"brand": self._extract_brand(raw.get("name", "")),
|
||||
"available": available,
|
||||
"update_hash": raw.get("updateHash", ""),
|
||||
}
|
||||
|
||||
|
||||
@@ -23,10 +23,10 @@ class SyncService:
|
||||
self.db = db
|
||||
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.
|
||||
|
||||
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
|
||||
3. Compare updateHash with DB, only process changed items
|
||||
4. Download images only for changed items
|
||||
@@ -35,12 +35,20 @@ class SyncService:
|
||||
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.utcnow(),
|
||||
)
|
||||
self.db.add(log_entry)
|
||||
if sync_log_id is not None:
|
||||
log_entry = await self.db.get(SyncLog, sync_log_id)
|
||||
if not log_entry:
|
||||
log_entry = None
|
||||
if sync_log_id is None or log_entry is None:
|
||||
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.refresh(log_entry)
|
||||
sync_id = log_entry.id
|
||||
@@ -53,8 +61,9 @@ class SyncService:
|
||||
# Ensure images directory exists
|
||||
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)
|
||||
folder_map = await self.rentman.get_folder_map()
|
||||
|
||||
# Build set of rentman_ids from API for availability check
|
||||
api_rentman_ids = set()
|
||||
@@ -75,7 +84,7 @@ class SyncService:
|
||||
continue
|
||||
|
||||
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)
|
||||
|
||||
# Download image if S3 URL available
|
||||
|
||||
@@ -16,4 +16,4 @@ pytest>=8.0.0
|
||||
pytest-asyncio>=0.23.0
|
||||
pytest-cov>=5.0.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"]
|
||||
|
||||
# Mock sync service
|
||||
# Mock sync service (background task)
|
||||
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})
|
||||
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["sync_id"] == 42
|
||||
assert data["status"] == "completed"
|
||||
# Background sync: request returns immediately with a running log entry
|
||||
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
|
||||
|
||||
@@ -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)
|
||||
|
||||
+3
-1
@@ -2,7 +2,7 @@ services:
|
||||
frontend:
|
||||
build: ./frontend
|
||||
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:
|
||||
backend:
|
||||
condition: service_healthy
|
||||
@@ -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
|
||||
|
||||
+5
-1
@@ -2,7 +2,10 @@ server {
|
||||
listen 3000;
|
||||
server_name _;
|
||||
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/ {
|
||||
proxy_pass http://backend:8000/api/;
|
||||
@@ -31,5 +34,6 @@ server {
|
||||
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
|
||||
expires 1y;
|
||||
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">
|
||||
<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>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs" style={{ color: 'var(--secondary)' }}>{item.code}</span>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -41,7 +41,7 @@ export default function EquipmentDetail() {
|
||||
addItemByFields({
|
||||
equipment_id: item.id,
|
||||
name: item.name,
|
||||
rental_price: null,
|
||||
rental_price: item.rental_price ?? null,
|
||||
image_url: item.image_url || null,
|
||||
});
|
||||
navigateTo('/warenkorb');
|
||||
@@ -99,6 +99,12 @@ export default function EquipmentDetail() {
|
||||
)}
|
||||
<div className="hms-card p-6">
|
||||
<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>
|
||||
<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>
|
||||
Zur Mietanfrage hinzufügen
|
||||
</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>
|
||||
|
||||
@@ -85,7 +85,7 @@ export default function Mietkatalog() {
|
||||
addItemByFields({
|
||||
equipment_id: item.id,
|
||||
name: item.name,
|
||||
rental_price: null,
|
||||
rental_price: item.rental_price ?? 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">
|
||||
<option value="name_asc">Sortieren: Name (A-Z)</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>
|
||||
</div>
|
||||
{categories.length > 1 && (
|
||||
|
||||
@@ -74,6 +74,11 @@ export default function Warenkorb() {
|
||||
</button>
|
||||
</div>
|
||||
<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">
|
||||
<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>
|
||||
@@ -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="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>
|
||||
{(() => {
|
||||
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>
|
||||
<form onSubmit={submitRequest} noValidate>
|
||||
<div className="space-y-3">
|
||||
|
||||
@@ -33,7 +33,7 @@ export interface PaginatedEquipment {
|
||||
total_pages: number;
|
||||
}
|
||||
|
||||
export type SortOption = "name_asc" | "name_desc";
|
||||
export type SortOption = "name_asc" | "name_desc" | "price_asc" | "price_desc";
|
||||
|
||||
export interface CartItem {
|
||||
equipment_id: number;
|
||||
|
||||
Reference in New Issue
Block a user