feat: T03+T04 backend - FastAPI, DB models, Rentman integration, admin auth, APScheduler
This commit is contained in:
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,110 @@
|
||||
"""Email service using aiosmtplib for contact and rental confirmation emails."""
|
||||
import logging
|
||||
from email.mime.text import MIMEText
|
||||
from email.mime.multipart import MIMEMultipart
|
||||
from typing import Any
|
||||
import aiosmtplib
|
||||
from app.config import get_settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
settings = get_settings()
|
||||
|
||||
|
||||
class EmailService:
|
||||
"""Send transactional emails via SMTP."""
|
||||
|
||||
async def send_email(
|
||||
self,
|
||||
to_addr: str,
|
||||
subject: str,
|
||||
html_body: str,
|
||||
text_body: str = "",
|
||||
reply_to: str | None = None,
|
||||
) -> bool:
|
||||
"""Send an email via aiosmtplib. Returns True on success."""
|
||||
msg = MIMEMultipart("alternative")
|
||||
msg["From"] = settings.smtp_from
|
||||
msg["To"] = to_addr
|
||||
msg["Subject"] = subject
|
||||
if reply_to:
|
||||
msg["Reply-To"] = reply_to
|
||||
msg.attach(MIMEText(text_body or html_body, "plain"))
|
||||
msg.attach(MIMEText(html_body, "html"))
|
||||
|
||||
try:
|
||||
await aiosmtplib.send(
|
||||
msg,
|
||||
hostname=settings.smtp_host,
|
||||
port=settings.smtp_port,
|
||||
username=settings.smtp_user,
|
||||
password=settings.smtp_password,
|
||||
start_tls=True,
|
||||
)
|
||||
return True
|
||||
except Exception as exc:
|
||||
logger.error("Email send failed to %s: %s", to_addr, exc)
|
||||
return False
|
||||
|
||||
async def send_contact_email(self, contact_data: dict[str, Any]) -> bool:
|
||||
"""Send contact form data to info@hms-licht-ton.de."""
|
||||
html = f"""
|
||||
<h2>Neue Kontaktanfrage</h2>
|
||||
<p><strong>Name:</strong> {contact_data.get('name', '')}</p>
|
||||
<p><strong>Email:</strong> {contact_data.get('email', '')}</p>
|
||||
<p><strong>Telefon:</strong> {contact_data.get('phone', '')}</p>
|
||||
<p><strong>Nachricht:</strong></p>
|
||||
<p>{contact_data.get('message', '')}</p>
|
||||
"""
|
||||
text = (
|
||||
f"Neue Kontaktanfrage\n"
|
||||
f"Name: {contact_data.get('name', '')}\n"
|
||||
f"Email: {contact_data.get('email', '')}\n"
|
||||
f"Telefon: {contact_data.get('phone', '')}\n"
|
||||
f"Nachricht: {contact_data.get('message', '')}\n"
|
||||
)
|
||||
return await self.send_email(
|
||||
to_addr=settings.smtp_from,
|
||||
subject="Neue Kontaktanfrage ueber hms-licht-ton.de",
|
||||
html_body=html,
|
||||
text_body=text,
|
||||
reply_to=contact_data.get("email"),
|
||||
)
|
||||
|
||||
async def send_rental_confirmation(
|
||||
self,
|
||||
to_addr: str,
|
||||
reference_number: str,
|
||||
event_name: str,
|
||||
items: list[dict[str, Any]],
|
||||
) -> bool:
|
||||
"""Send rental request confirmation to customer."""
|
||||
items_html = "".join(
|
||||
f"<li>{item.get('equipment_name', 'Unbekannt')} - Menge: {item.get('quantity', 1)}</li>"
|
||||
for item in items
|
||||
)
|
||||
html = f"""
|
||||
<h2>Mietanfrage bestätigt – {reference_number}</h2>
|
||||
<p>Vielen Dank für Ihre Mietanfrage bei HMS Licht & Ton.</p>
|
||||
<p><strong>Veranstaltung:</strong> {event_name}</p>
|
||||
<p><strong>Referenznummer:</strong> {reference_number}</p>
|
||||
<h3>Artikel:</h3>
|
||||
<ul>{items_html}</ul>
|
||||
<p>Wir melden uns in Kürze bei Ihnen.</p>
|
||||
<p>Ihr HMS Licht & Ton Team</p>
|
||||
"""
|
||||
text = (
|
||||
f"Mietanfrage bestaetigt - {reference_number}\n"
|
||||
f"Veranstaltung: {event_name}\n"
|
||||
f"Referenznummer: {reference_number}\n"
|
||||
f"Artikel:\n"
|
||||
+ "\n".join(
|
||||
f"- {item.get('equipment_name', 'Unbekannt')}: {item.get('quantity', 1)}"
|
||||
for item in items
|
||||
)
|
||||
)
|
||||
return await self.send_email(
|
||||
to_addr=to_addr,
|
||||
subject=f"Mietanfrage bestaetigt – {reference_number}",
|
||||
html_body=html,
|
||||
text_body=text,
|
||||
)
|
||||
@@ -0,0 +1,146 @@
|
||||
"""Rentman API client wrapper for equipment import and request submission."""
|
||||
import httpx
|
||||
import logging
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from app.config import get_settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
settings = get_settings()
|
||||
|
||||
RENTMAN_BASE_URL = "https://api.rentman.net"
|
||||
|
||||
|
||||
class RentmanService:
|
||||
"""Wrapper around the Rentman REST API using httpx."""
|
||||
|
||||
def __init__(self, token: str | None = None) -> None:
|
||||
self._token = token or settings.rentman_api_token
|
||||
self._base_url = RENTMAN_BASE_URL
|
||||
|
||||
def _headers(self) -> dict[str, str]:
|
||||
return {
|
||||
"Authorization": f"Bearer {self._token}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
async def get_equipment_page(self, limit: int = 100, offset: int = 0) -> dict[str, Any]:
|
||||
"""Fetch a single page of equipment from Rentman.
|
||||
|
||||
Returns the raw JSON response dict with 'data' and optional 'itemCount'.
|
||||
"""
|
||||
url = f"{self._base_url}/equipment"
|
||||
params = {"limit": limit, "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()
|
||||
return resp.json()
|
||||
|
||||
async def get_all_equipment(self, limit: int = 100) -> list[dict[str, Any]]:
|
||||
"""Paginate through all equipment pages until data is empty."""
|
||||
all_items: list[dict[str, Any]] = []
|
||||
offset = 0
|
||||
while True:
|
||||
page = await self.get_equipment_page(limit=limit, offset=offset)
|
||||
data = page.get("data", [])
|
||||
if not data:
|
||||
break
|
||||
all_items.extend(data)
|
||||
offset += limit
|
||||
return all_items
|
||||
|
||||
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"
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
resp = await client.post(url, headers=self._headers(), json=payload)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
async def add_equipment_to_request(
|
||||
self, request_id: str, equipment_payload: dict[str, Any]
|
||||
) -> dict[str, Any]:
|
||||
"""POST /projectrequests/{id}/projectrequestequipment for a single item."""
|
||||
url = f"{self._base_url}/projectrequests/{request_id}/projectrequestequipment"
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
resp = await client.post(url, headers=self._headers(), json=equipment_payload)
|
||||
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."""
|
||||
date_start = data.get("date_start")
|
||||
date_end = data.get("date_end")
|
||||
|
||||
contact_name = data.get("contact_name", "")
|
||||
parts = contact_name.strip().split(" ", 1)
|
||||
first_name = parts[0] if parts else ""
|
||||
last_name = parts[1] if len(parts) > 1 else ""
|
||||
|
||||
street = data.get("contact_street", "")
|
||||
house_number = ""
|
||||
if street:
|
||||
match = re.search(r"(\d+[a-zA-Z]*)", street)
|
||||
if match:
|
||||
house_number = match.group(1)
|
||||
|
||||
return {
|
||||
"name": data.get("event_name", ""),
|
||||
"planperiod_start": f"{date_start}T08:00:00+02:00" if date_start else None,
|
||||
"planperiod_end": f"{date_end}T02:00:00+02:00" if date_end else None,
|
||||
"usageperiod_start": f"{date_start}T18:00:00+02:00" if date_start else None,
|
||||
"usageperiod_end": f"{date_end}T23:59:00+02:00" if date_end else None,
|
||||
"contact_name": data.get("contact_company") or data.get("contact_name", ""),
|
||||
"contact_person_first_name": first_name,
|
||||
"contact_person_lastname": last_name,
|
||||
"contact_person_email": data.get("contact_email", ""),
|
||||
"contact_person_phone": data.get("contact_phone", ""),
|
||||
"location_name": data.get("location", ""),
|
||||
"location_mailing_street": street,
|
||||
"location_mailing_number": house_number,
|
||||
"location_mailing_postalcode": data.get("contact_postalcode", ""),
|
||||
"location_mailing_city": data.get("contact_city") or data.get("location", ""),
|
||||
"remark": data.get("message", ""),
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def build_equipment_payload(item: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Map a single rental request item to Rentman projectrequestequipment payload."""
|
||||
return {
|
||||
"name": item.get("equipment_name", ""),
|
||||
"quantity": item.get("quantity", 1),
|
||||
"quantity_total": item.get("quantity", 1),
|
||||
"unit_price": item.get("unit_price", 0),
|
||||
"linked_equipment": f"/equipment/{item.get('rentman_equipment_id', '')}" if item.get("rentman_equipment_id") else None,
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
"""Equipment sync service: import from Rentman, upsert into DB, invalidate cache."""
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.models.equipment import EquipmentCache
|
||||
from app.models.sync_log import SyncLog
|
||||
from app.services.rentman_service import RentmanService
|
||||
from app.cache import cache
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SyncService:
|
||||
"""Orchestrates equipment import from Rentman into the local database."""
|
||||
|
||||
def __init__(self, db: AsyncSession, rentman: RentmanService | None = None) -> None:
|
||||
self.db = db
|
||||
self.rentman = rentman or RentmanService()
|
||||
|
||||
async def run_sync(self) -> dict[str, Any]:
|
||||
"""Execute a full 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)
|
||||
Returns dict with sync_id, items_processed, status.
|
||||
"""
|
||||
log_entry = SyncLog(
|
||||
sync_type="equipment",
|
||||
status="running",
|
||||
started_at=datetime.now(timezone.utc),
|
||||
)
|
||||
self.db.add(log_entry)
|
||||
await self.db.commit()
|
||||
await self.db.refresh(log_entry)
|
||||
sync_id = log_entry.id
|
||||
|
||||
items_processed = 0
|
||||
items_failed = 0
|
||||
error_message: str | None = None
|
||||
|
||||
try:
|
||||
all_equipment = await self.rentman.get_all_equipment(limit=100)
|
||||
for raw_item in all_equipment:
|
||||
try:
|
||||
transformed = RentmanService.transform_equipment(raw_item)
|
||||
await self._upsert_equipment(transformed)
|
||||
items_processed += 1
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to upsert equipment item: %s", exc)
|
||||
items_failed += 1
|
||||
|
||||
await self.db.commit()
|
||||
|
||||
# Invalidate Redis cache
|
||||
await cache.delete_pattern("equipment:*")
|
||||
|
||||
status_val = "completed"
|
||||
except Exception as exc:
|
||||
logger.error("Equipment sync failed: %s", exc)
|
||||
error_message = str(exc)
|
||||
status_val = "failed"
|
||||
|
||||
# Update log entry
|
||||
log_entry.status = status_val
|
||||
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)
|
||||
await self.db.commit()
|
||||
|
||||
return {
|
||||
"sync_id": sync_id,
|
||||
"items_processed": items_processed,
|
||||
"items_failed": items_failed,
|
||||
"status": status_val,
|
||||
}
|
||||
|
||||
async def _upsert_equipment(self, data: dict[str, Any]) -> None:
|
||||
"""Insert or update a single equipment row by rentman_id."""
|
||||
result = await self.db.execute(
|
||||
select(EquipmentCache).where(EquipmentCache.rentman_id == data["rentman_id"])
|
||||
)
|
||||
existing = result.scalar_one_or_none()
|
||||
|
||||
if existing:
|
||||
existing.name = data["name"]
|
||||
existing.number = data.get("number", "")
|
||||
existing.category = data.get("category", "")
|
||||
existing.subcategory = data.get("subcategory", "")
|
||||
existing.description = data.get("description", "")
|
||||
existing.specifications = data.get("specifications")
|
||||
existing.images = data.get("images")
|
||||
existing.rental_price = data.get("rental_price")
|
||||
existing.brand = data.get("brand", "")
|
||||
existing.available = data.get("available", True)
|
||||
else:
|
||||
new_item = EquipmentCache(
|
||||
rentman_id=data["rentman_id"],
|
||||
name=data["name"],
|
||||
number=data.get("number", ""),
|
||||
category=data.get("category", ""),
|
||||
subcategory=data.get("subcategory", ""),
|
||||
description=data.get("description", ""),
|
||||
specifications=data.get("specifications"),
|
||||
images=data.get("images"),
|
||||
rental_price=data.get("rental_price"),
|
||||
brand=data.get("brand", ""),
|
||||
available=data.get("available", True),
|
||||
)
|
||||
self.db.add(new_item)
|
||||
|
||||
async def get_last_sync(self) -> dict[str, Any]:
|
||||
"""Return the most recent sync_log entry summary."""
|
||||
result = await self.db.execute(
|
||||
select(SyncLog).order_by(SyncLog.started_at.desc()).limit(1)
|
||||
)
|
||||
log = result.scalar_one_or_none()
|
||||
if not log:
|
||||
return {"last_sync": None, "items_processed": 0, "status": "never"}
|
||||
return {
|
||||
"last_sync": log.started_at,
|
||||
"items_processed": log.items_processed,
|
||||
"status": log.status,
|
||||
}
|
||||
|
||||
async def get_sync_log_paginated(self, page: int = 1, page_size: int = 20) -> dict[str, Any]:
|
||||
"""Return paginated sync log entries."""
|
||||
offset = (page - 1) * page_size
|
||||
result = await self.db.execute(
|
||||
select(SyncLog)
|
||||
.order_by(SyncLog.started_at.desc())
|
||||
.offset(offset)
|
||||
.limit(page_size)
|
||||
)
|
||||
logs = result.scalars().all()
|
||||
count_result = await self.db.execute(select(SyncLog))
|
||||
total = len(count_result.scalars().all())
|
||||
return {
|
||||
"items": logs,
|
||||
"total": total,
|
||||
"page": page,
|
||||
"page_size": page_size,
|
||||
"total_pages": (total + page_size - 1) // page_size if page_size > 0 else 0,
|
||||
}
|
||||
Reference in New Issue
Block a user