From e66bcc70586b22c85579cbc395d23ed3c58dd2f6 Mon Sep 17 00:00:00 2001 From: Agent Zero Date: Thu, 24 Sep 2026 21:41:35 +0200 Subject: [PATCH] fix: seed admin user on startup and run sync as background task - Seed/update admin user from ADMIN_USERNAME/ADMIN_PASSWORD settings on startup (admin_users table was empty, login was impossible) - POST /api/admin/sync now returns immediately and runs the equipment sync via BackgroundTasks (full sync took minutes and hit nginx 504) - run_sync accepts optional sync_log_id to reuse a pre-created log entry - Adapt admin router test to background sync behavior --- backend/app/main.py | 24 ++++++++++++++++++- backend/app/routers/admin.py | 36 ++++++++++++++++++++++++---- backend/app/services/sync_service.py | 24 ++++++++++++------- backend/tests/test_admin_router.py | 11 +++++---- 4 files changed, 77 insertions(+), 18 deletions(-) diff --git a/backend/app/main.py b/backend/app/main.py index b1060b2..06398fc 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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() diff --git a/backend/app/routers/admin.py b/backend/app/routers/admin.py index 4f74ad5..b749e55 100644 --- a/backend/app/routers/admin.py +++ b/backend/app/routers/admin.py @@ -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) diff --git a/backend/app/services/sync_service.py b/backend/app/services/sync_service.py index da4f9d3..16c9d58 100644 --- a/backend/app/services/sync_service.py +++ b/backend/app/services/sync_service.py @@ -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 diff --git a/backend/tests/test_admin_router.py b/backend/tests/test_admin_router.py index 6cde529..2402c32 100644 --- a/backend/tests/test_admin_router.py +++ b/backend/tests/test_admin_router.py @@ -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