diff --git a/backend/app/api/v1/clients.py b/backend/app/api/v1/clients.py index 52f80ba..d001c90 100644 --- a/backend/app/api/v1/clients.py +++ b/backend/app/api/v1/clients.py @@ -4,7 +4,7 @@ from fastapi import APIRouter, Depends, HTTPException, Query, status from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession -from app.dependencies import get_db, require_role +from app.dependencies import get_current_user, get_db, require_role from app.models.client import Client from app.schemas.client import ClientCreate, ClientResponse, ClientUpdate from app.schemas.common import PaginatedResponse @@ -34,9 +34,9 @@ async def list_clients( page: int = Query(1, ge=1), page_size: int = Query(20, ge=1, le=100), db: AsyncSession = Depends(get_db), - current_user: dict = Depends(require_role(["admin"])), + current_user: dict = Depends(get_current_user), ) -> PaginatedResponse[ClientResponse]: - """List all clients (admin only).""" + """List all clients.""" count_result = await db.execute(select(func.count(Client.id))) total = count_result.scalar() or 0 @@ -58,9 +58,9 @@ async def list_clients( async def get_client( client_id: UUID, db: AsyncSession = Depends(get_db), - current_user: dict = Depends(require_role(["admin"])), + current_user: dict = Depends(get_current_user), ) -> ClientResponse: - """Get a client by ID (admin only).""" + """Get a client by ID.""" result = await db.execute(select(Client).where(Client.id == client_id)) client = result.scalar_one_or_none() if client is None: diff --git a/backend/app/api/v1/jobs.py b/backend/app/api/v1/jobs.py index e16d5d0..5de30e4 100644 --- a/backend/app/api/v1/jobs.py +++ b/backend/app/api/v1/jobs.py @@ -17,6 +17,15 @@ file_service = FileService() audit_service = AuditService() +def _enrich_job_response(job) -> JobResponse: + """Build JobResponse with computed fields from relationships.""" + resp = JobResponse.model_validate(job) + resp.client_name = job.client.name if job.client else None + resp.created_by_name = job.creator.name if job.creator else None + resp.source_line_count = len(job.source_lines) if hasattr(job, "source_lines") and job.source_lines else 0 + return resp + + @router.post("", response_model=JobResponse, status_code=status.HTTP_201_CREATED) async def create_job( body: JobCreate, @@ -29,7 +38,9 @@ async def create_job( db, "create", "job", str(job.id), current_user["user_id"], details={"campaign_name": body.campaign_name}, ) - return JobResponse.model_validate(job) + # Re-fetch with relationships loaded for enrichment + job = await job_service.get_job(db, job.id) + return _enrich_job_response(job) @router.get("", response_model=PaginatedResponse[JobListResponse]) @@ -73,6 +84,8 @@ async def list_jobs( locale_count=len(job.locale_instances) if job.locale_instances else 0, created_at=job.created_at, updated_at=job.updated_at, + client_name=job.client.name if job.client else None, + created_by_name=job.creator.name if job.creator else None, ) items.append(item) @@ -92,7 +105,7 @@ async def get_job( job = await job_service.get_job(db, job_id) if job is None: raise HTTPException(status_code=404, detail="Job not found") - return JobResponse.model_validate(job) + return _enrich_job_response(job) @router.put("/{job_id}/source") @@ -158,7 +171,9 @@ async def launch_job( await audit_service.log( db, "launch", "job", str(job_id), current_user["user_id"], ) - return JobResponse.model_validate(job) + # Re-fetch with relationships for enrichment + job = await job_service.get_job(db, job_id) + return _enrich_job_response(job) @router.post("/{job_id}/cancel", response_model=JobResponse) @@ -178,7 +193,8 @@ async def cancel_job( await audit_service.log( db, "cancel", "job", str(job_id), current_user["user_id"], ) - return JobResponse.model_validate(job) + job = await job_service.get_job(db, job_id) + return _enrich_job_response(job) @router.post( diff --git a/backend/app/pipeline/modules/source_file_parser.py b/backend/app/pipeline/modules/source_file_parser.py index 724629d..242c719 100644 --- a/backend/app/pipeline/modules/source_file_parser.py +++ b/backend/app/pipeline/modules/source_file_parser.py @@ -1,7 +1,8 @@ """Parse source xlsx files into structured source line data. -Validates the expected 5 column headers (case sensitive): -EN_GB, Copy Type, Creative Guidance, Visual Ref, Char Limit +Validates the expected column headers with flexible alias support: +EN_GB (required), Copy Type / Line type, Creative Guidance / Context notes, +Visual Ref, Char Limit. Skips rows where EN_GB is empty. Detects \\n in EN_GB for is_display_format. """ @@ -10,7 +11,16 @@ from typing import Any from openpyxl import load_workbook -REQUIRED_HEADERS = ["EN_GB", "Copy Type", "Creative Guidance", "Visual Ref", "Char Limit"] +# Map of canonical field name -> list of accepted header variations (case-insensitive) +HEADER_ALIASES: dict[str, list[str]] = { + "en_gb": ["en_gb"], + "copy_type": ["copy type", "line type", "copy_type", "linetype"], + "creative_guidance": ["creative guidance", "context notes", "creative_guidance", "context_notes", "guidance"], + "visual_ref": ["visual ref", "visual_ref", "visual reference"], + "char_limit": ["char limit", "char_limit", "character limit", "charlimit"], +} + +REQUIRED_FIELDS = ["en_gb"] class SourceFileParseError(Exception): @@ -18,6 +28,23 @@ class SourceFileParseError(Exception): pass +def _resolve_headers(raw_headers: list[str]) -> dict[str, int]: + """Match raw file headers to canonical field names using aliases. + + Returns a dict mapping canonical field name -> column index. + """ + resolved: dict[str, int] = {} + lower_headers = [h.lower().strip() for h in raw_headers] + + for field, aliases in HEADER_ALIASES.items(): + for alias in aliases: + if alias in lower_headers: + resolved[field] = lower_headers.index(alias) + break + + return resolved + + def parse_source_file(file_path: str) -> list[dict[str, Any]]: """Parse a source xlsx file and return a list of source line dicts. @@ -29,7 +56,7 @@ def parse_source_file(file_path: str) -> list[dict[str, Any]]: visual_ref, char_limit, is_display_format. Raises: - SourceFileParseError: If headers are invalid or file cannot be read. + SourceFileParseError: If required headers are missing or file cannot be read. """ try: wb = load_workbook(file_path, read_only=True, data_only=True) @@ -46,24 +73,22 @@ def parse_source_file(file_path: str) -> list[dict[str, Any]]: if first_row is None: raise SourceFileParseError("File is empty - no header row found") - headers = [str(cell).strip() if cell else "" for cell in first_row] + raw_headers = [str(cell).strip() if cell else "" for cell in first_row] + col_map = _resolve_headers(raw_headers) - # Validate all required headers exist (case sensitive) - for required in REQUIRED_HEADERS: - if required not in headers: + # Validate required fields + for field in REQUIRED_FIELDS: + if field not in col_map: raise SourceFileParseError( - f"Missing required header '{required}'. " - f"Found headers: {headers}. " - f"Expected: {REQUIRED_HEADERS}" + f"Missing required column matching '{field}'. " + f"Found headers: {raw_headers}. " + f"Accepted aliases: {HEADER_ALIASES[field]}" ) - # Build column index map - col_map = {header: idx for idx, header in enumerate(headers)} - # Parse data rows source_lines: list[dict[str, Any]] = [] for row in ws.iter_rows(min_row=2, values_only=True): - en_gb_idx = col_map["EN_GB"] + en_gb_idx = col_map["en_gb"] en_gb_raw = row[en_gb_idx] if en_gb_idx < len(row) else None # Skip rows where EN_GB is empty @@ -75,8 +100,8 @@ def parse_source_file(file_path: str) -> list[dict[str, Any]]: # Detect display format: presence of \n in EN_GB text is_display_format = "\n" in en_gb - def _get_cell(header: str) -> str | None: - idx = col_map.get(header) + def _get_cell(field: str) -> str | None: + idx = col_map.get(field) if idx is None or idx >= len(row): return None val = row[idx] @@ -86,10 +111,10 @@ def parse_source_file(file_path: str) -> list[dict[str, Any]]: source_lines.append({ "en_gb": en_gb, - "copy_type": _get_cell("Copy Type"), - "creative_guidance": _get_cell("Creative Guidance"), - "visual_ref": _get_cell("Visual Ref"), - "char_limit": _get_cell("Char Limit"), + "copy_type": _get_cell("copy_type"), + "creative_guidance": _get_cell("creative_guidance"), + "visual_ref": _get_cell("visual_ref"), + "char_limit": _get_cell("char_limit"), "is_display_format": is_display_format, }) diff --git a/backend/app/schemas/job.py b/backend/app/schemas/job.py index fbf698d..1ce0f2c 100644 --- a/backend/app/schemas/job.py +++ b/backend/app/schemas/job.py @@ -72,6 +72,10 @@ class JobResponse(BaseModel): locale_instances: list[LocaleInstanceResponse] = [] created_at: datetime updated_at: datetime + # Enrichment fields (populated by API layer) + client_name: str | None = None + created_by_name: str | None = None + source_line_count: int = 0 model_config = {"from_attributes": True} @@ -90,5 +94,8 @@ class JobListResponse(BaseModel): locale_count: int = 0 created_at: datetime updated_at: datetime + # Enrichment fields (populated by API layer) + client_name: str | None = None + created_by_name: str | None = None model_config = {"from_attributes": True} diff --git a/backend/app/services/job_service.py b/backend/app/services/job_service.py index 1e51f4c..4f44033 100644 --- a/backend/app/services/job_service.py +++ b/backend/app/services/job_service.py @@ -49,10 +49,15 @@ class JobService: return job async def get_job(self, db: AsyncSession, job_id: UUID) -> Job | None: - """Get a job by ID with locale instances.""" + """Get a job by ID with locale instances and related data.""" result = await db.execute( select(Job) - .options(selectinload(Job.locale_instances)) + .options( + selectinload(Job.locale_instances), + selectinload(Job.client), + selectinload(Job.creator), + selectinload(Job.source_lines), + ) .where(Job.id == job_id) ) return result.scalar_one_or_none() @@ -70,7 +75,11 @@ class JobService: page_size: int = 20, ) -> tuple[list[Job], int]: """List jobs with filters and pagination.""" - query = select(Job).options(selectinload(Job.locale_instances)) + query = select(Job).options( + selectinload(Job.locale_instances), + selectinload(Job.client), + selectinload(Job.creator), + ) if client_id: query = query.where(Job.client_id == client_id) diff --git a/frontend/src/app/jobs/[jobId]/page.tsx b/frontend/src/app/jobs/[jobId]/page.tsx index 3426cb0..8613711 100644 --- a/frontend/src/app/jobs/[jobId]/page.tsx +++ b/frontend/src/app/jobs/[jobId]/page.tsx @@ -1,6 +1,6 @@ "use client"; -import React from "react"; +import React, { useEffect } from "react"; import { useParams } from "next/navigation"; import { AppShell } from "@/components/layout/AppShell"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; @@ -8,6 +8,7 @@ import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { LocaleInstanceCard } from "@/components/jobs/MonitoringHUD/LocaleInstanceCard"; import { useJob } from "@/hooks/useJobs"; +import { cancelJob } from "@/lib/api"; import { Clock, User, @@ -34,9 +35,32 @@ const statusConfig: Record< export default function JobMonitoringPage() { const params = useParams(); const jobId = params.jobId as string; - const { job, loading } = useJob(jobId); + const { job, loading, error, refetch } = useJob(jobId); - if (loading || !job) { + // Poll for updates while job is active + useEffect(() => { + if (!job) return; + const activeStatuses = ["QUEUED", "RUNNING"]; + if (!activeStatuses.includes(job.status)) return; + + const interval = setInterval(() => { + refetch(); + }, 3000); + + return () => clearInterval(interval); + }, [job?.status, refetch]); + + const handleCancel = async () => { + if (!job) return; + try { + await cancelJob(job.id); + refetch(); + } catch { + // Error handled by interceptor + } + }; + + if (loading && !job) { return (
@@ -49,6 +73,19 @@ export default function JobMonitoringPage() { ); } + if (error || !job) { + return ( + + + + +

{error || "Job not found"}

+
+
+
+ ); + } + const status = statusConfig[job.status] || statusConfig.DRAFT; return ( @@ -91,7 +128,7 @@ export default function JobMonitoringPage() { {job.created_by} - {job.total_lines && ( + {(job.total_lines ?? 0) > 0 && ( {job.total_lines} source lines @@ -102,8 +139,8 @@ export default function JobMonitoringPage() { {/* Actions */}
- {job.status === "RUNNING" && ( - @@ -126,15 +163,23 @@ export default function JobMonitoringPage() {

Locale Progress

-
- {job.locale_instances.map((li) => ( - - ))} -
+ {job.locale_instances.length > 0 ? ( +
+ {job.locale_instances.map((li) => ( + + ))} +
+ ) : ( + + + No locale instances yet. + + + )}
{/* Summary stats */} diff --git a/frontend/src/components/jobs/JobWizard/StepConfigure.tsx b/frontend/src/components/jobs/JobWizard/StepConfigure.tsx index a28f27f..fb4e616 100644 --- a/frontend/src/components/jobs/JobWizard/StepConfigure.tsx +++ b/frontend/src/components/jobs/JobWizard/StepConfigure.tsx @@ -1,6 +1,6 @@ "use client"; -import React from "react"; +import React, { useState, useEffect } from "react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; @@ -15,6 +15,8 @@ import { SelectValue, } from "@/components/ui/select"; import type { JobFormData } from "@/app/jobs/new/page"; +import type { Client } from "@/lib/types"; +import { getClients } from "@/lib/api"; const MAIN_LOCALES = [ "de-DE", @@ -29,13 +31,6 @@ const MAIN_LOCALES = [ const DERIVED_LOCALES = ["de-AT", "fr-BE", "nl-BE", "ca-ES"]; -const CLIENTS = [ - { id: "client-001", name: "OMG" }, - { id: "client-002", name: "Mindshare" }, - { id: "client-003", name: "PHD" }, - { id: "client-004", name: "EssenceMediacom" }, -]; - const CHANNELS = ["MASS", "VALUE", "ONSITE", "OUTBOUND", "BDA", "UEFA"]; const SUB_CHANNELS = [ @@ -56,6 +51,14 @@ interface StepConfigureProps { } export function StepConfigure({ data, onChange, onNext }: StepConfigureProps) { + const [clients, setClients] = useState([]); + + useEffect(() => { + getClients() + .then((data) => setClients(data)) + .catch(() => {}); + }, []); + const locales = data.job_type === "MAIN" ? MAIN_LOCALES : DERIVED_LOCALES; @@ -89,7 +92,7 @@ export function StepConfigure({ data, onChange, onNext }: StepConfigureProps) {