From 5def8f9e8412bc05b7ee23b8afe493a1eca33bba Mon Sep 17 00:00:00 2001 From: Vadym Samoilenko Date: Sun, 1 Mar 2026 19:01:52 +0000 Subject: [PATCH] Phase 7: Apply design system to all admin pages + fix test stubs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Frontend — consistent HSL token usage across remaining pages: - Users: shared Card, Badge with success/error tokens, h2 typography, animate-fadeIn - Audit: shared Card, muted-foreground text, animate-fadeIn - Clients: shared Card, Badge active/inactive, hsl(--primary) icon color - Storage: shared Card, StatusBadge for status pills, hsl warning/primary bars replacing hardcoded amber/blue, all gray text → muted-foreground - Login: hsl(--surface) bg, hsl(--primary) submit button, brand mark icon, animate-scaleIn card entry, hsl(--warning) dev notice Backend tests — convert print-only stubs to real assertions: - test_pptx_creator: mkdir, deterministic save path, assert file exists + slide count - test_gemini_schema_support: direct google.genai client, skipif guard on GOOGLE_API_KEY, JSON parse + Pydantic model validation assertions - test_openai_schema_support: clean skip (OpenAI removed in Phase 6) Co-Authored-By: Claude Sonnet 4.6 --- backend/tests/test_gemini_schema_support.py | 75 +++++++++---------- backend/tests/test_openai_schema_support.py | 70 ++---------------- backend/tests/test_pptx_creator.py | 15 +++- frontend/app/admin/audit/page.tsx | 17 ++--- frontend/app/admin/clients/page.tsx | 38 ++++++---- frontend/app/admin/storage/page.tsx | 80 ++++++++++----------- frontend/app/admin/users/page.tsx | 33 +++++---- frontend/app/login/page.tsx | 37 ++++++---- 8 files changed, 171 insertions(+), 194 deletions(-) diff --git a/backend/tests/test_gemini_schema_support.py b/backend/tests/test_gemini_schema_support.py index cd66083..cce50dc 100644 --- a/backend/tests/test_gemini_schema_support.py +++ b/backend/tests/test_gemini_schema_support.py @@ -1,28 +1,8 @@ import json +import os +import pytest from typing import Optional from pydantic import BaseModel, Field -from google.genai.types import GenerateContentResponse, GenerateContentConfig - - -from utils.llm_provider import get_google_llm_client, get_large_model - - -class HeadingDescription(BaseModel): - heading: str = Field( - description="Heading of the slide", min_length=10, max_length=20 - ) - description: str = Field( - description="Description of the slide", min_length=40, max_length=200 - ) - - -class SlideContentTest(BaseModel): - title: str = Field(description="Title of the slide", min_length=10, max_length=20) - first_content: HeadingDescription = Field(description="First content of the slide") - second_content: HeadingDescription = Field( - description="Second content of the slide" - ) - third_content: HeadingDescription = Field(description="Third content of the slide") class ColumnContentModel(BaseModel): @@ -37,34 +17,55 @@ class TwoColumnSlideModel(BaseModel): description="Title of the slide", ) subtitle: Optional[str] = Field( + default=None, min_length=3, max_length=150, description="Optional subtitle or description", ) - leftColumn: ColumnContentModel = Field( - description="Left column content", - ) - rightColumn: ColumnContentModel = Field( - description="Right column content", - ) + leftColumn: ColumnContentModel = Field(description="Left column content") + rightColumn: ColumnContentModel = Field(description="Right column content") backgroundImage: Optional[str] = Field( - description="URL to background image for the slide" + default=None, + description="URL to background image for the slide", ) +@pytest.mark.skipif( + not os.getenv("GOOGLE_API_KEY"), + reason="GOOGLE_API_KEY not set — skipping Gemini API test", +) def test_gemini_schema_support(): - response: GenerateContentResponse = get_google_llm_client().models.generate_content( - model=get_large_model(), + import google.genai as genai + from google.genai.types import GenerateContentConfig + + client = genai.Client(api_key=os.environ["GOOGLE_API_KEY"]) + model = os.getenv("GOOGLE_MODEL", "gemini-2.0-flash") + + response = client.models.generate_content( + model=model, contents=[ - "Generate a slide for a presentation", - "The slide should have a title and two contents", - "The title should be a short title for the slide", - "The contents should be a heading and a description", - "The heading should be a short heading for the slide", + "Generate a slide for a presentation about renewable energy.", + "The slide should have a title, subtitle, and two columns (left and right).", ], config=GenerateContentConfig( response_mime_type="application/json", response_schema=TwoColumnSlideModel.model_json_schema(), ), ) - print(response.text) + + assert response is not None, "Response from Gemini was None" + assert response.text, "Response text is empty" + + data = json.loads(response.text) + assert "title" in data, "Response missing 'title' field" + assert "leftColumn" in data, "Response missing 'leftColumn' field" + assert "rightColumn" in data, "Response missing 'rightColumn' field" + assert isinstance(data["leftColumn"], dict), "'leftColumn' should be an object" + assert "title" in data["leftColumn"], "'leftColumn' missing 'title'" + assert "content" in data["leftColumn"], "'leftColumn' missing 'content'" + + # Validate full model parse + slide = TwoColumnSlideModel.model_validate(data) + assert len(slide.title) >= 3, "Title too short" + assert len(slide.leftColumn.content) >= 10, "Left column content too short" + assert len(slide.rightColumn.content) >= 10, "Right column content too short" diff --git a/backend/tests/test_openai_schema_support.py b/backend/tests/test_openai_schema_support.py index f7d00b7..e146385 100644 --- a/backend/tests/test_openai_schema_support.py +++ b/backend/tests/test_openai_schema_support.py @@ -1,66 +1,10 @@ -import asyncio -import json -from typing import Optional -from pydantic import BaseModel, Field - - -from utils.llm_provider import get_llm_client, get_large_model - - -class HeadingDescription(BaseModel): - heading: str = Field( - description="Heading of the slide", min_length=10, max_length=20 - ) - description: str = Field( - description="Description of the slide", min_length=40, max_length=200 - ) - - -class SlideContentTest(BaseModel): - title: str = Field(description="Title of the slide", min_length=10, max_length=20) - first_content: HeadingDescription = Field(description="First content of the slide") - second_content: HeadingDescription = Field( - description="Second content of the slide" - ) - third_content: HeadingDescription = Field(description="Third content of the slide") - - -class ColumnContentModel(BaseModel): - title: str = Field(min_length=3, max_length=100, description="Column title") - content: str = Field(min_length=10, max_length=800, description="Column content") - - -class TwoColumnSlideModel(BaseModel): - title: str = Field( - min_length=3, - max_length=100, - description="Title of the slide", - ) - subtitle: Optional[str] = Field( - min_length=3, - max_length=150, - description="Optional subtitle or description", - ) - leftColumn: ColumnContentModel = Field( - description="Left column content", - ) - rightColumn: ColumnContentModel = Field( - description="Right column content", - ) - backgroundImage: Optional[str] = Field( - description="URL to background image for the slide" - ) +import pytest + +# This project uses a Gemini-only LLM stack (Phase 6 migration). +# OpenAI structured output is no longer part of the stack. +# See test_gemini_schema_support.py for the equivalent test. +@pytest.mark.skip(reason="OpenAI removed in Phase 6 Gemini-only migration — see test_gemini_schema_support.py") def test_openai_schema_support(): - response = asyncio.run( - get_llm_client().beta.chat.completions.parse( - model=get_large_model(), - messages=[ - {"role": "system", "content": "You are a helpful assistant."}, - {"role": "user", "content": "Generate a slide for a presentation"}, - ], - response_format=TwoColumnSlideModel, - ) - ) - print(response.choices[0].message.parsed) + pass diff --git a/backend/tests/test_pptx_creator.py b/backend/tests/test_pptx_creator.py index a10b199..9d8c621 100644 --- a/backend/tests/test_pptx_creator.py +++ b/backend/tests/test_pptx_creator.py @@ -1,4 +1,5 @@ import asyncio +import os from models.pptx_models import ( PptxAutoShapeBoxModel, PptxFillModel, @@ -8,6 +9,7 @@ from models.pptx_models import ( ) from services.pptx_presentation_creator import PptxPresentationCreator from pptx.enum.shapes import MSO_AUTO_SHAPE_TYPE +from pptx import Presentation pptx_model = PptxPresentationModel( @@ -34,7 +36,16 @@ pptx_model = PptxPresentationModel( def test_pptx_creator(): - temp_dir = "/tmp/deckforge" + temp_dir = "/tmp/deckforge_test" + os.makedirs(temp_dir, exist_ok=True) + save_path = "/tmp/deckforge_test/test_output.pptx" + pptx_creator = PptxPresentationCreator(pptx_model, temp_dir) asyncio.run(pptx_creator.create_ppt()) - pptx_creator.save("debug/test.pptx") + pptx_creator.save(save_path) + + assert os.path.exists(save_path), f"PPTX file was not created at {save_path}" + assert os.path.getsize(save_path) > 0, "PPTX file is empty" + + prs = Presentation(save_path) + assert len(prs.slides) == 1, f"Expected 1 slide, got {len(prs.slides)}" diff --git a/frontend/app/admin/audit/page.tsx b/frontend/app/admin/audit/page.tsx index 17be401..e6de59f 100644 --- a/frontend/app/admin/audit/page.tsx +++ b/frontend/app/admin/audit/page.tsx @@ -14,6 +14,7 @@ import { } from '@/components/ui/table'; import { Input } from '@/components/ui/input'; import { Button } from '@/components/ui/button'; +import { Card } from '@/components/shared/Card'; import DataExportButton from '../components/DataExportButton'; import { Search } from 'lucide-react'; @@ -40,9 +41,9 @@ export default function AuditLogPage() { }; return ( -
+
-

Audit Log

+

Audit Log

@@ -58,11 +59,11 @@ export default function AuditLogPage() {
-
+ {loading ? (
{[...Array(5)].map((_, i) => ( -
+
))}
) : ( @@ -79,18 +80,18 @@ export default function AuditLogPage() { {auditLogs.map((log) => ( - + {log.created_at ? new Date(log.created_at).toLocaleString() : '—'} {log.action} {log.resource_type} {log.user_id || 'System'} - {log.ip_address || '—'} + {log.ip_address || '—'} ))} {auditLogs.length === 0 && ( - + No audit log entries found. @@ -98,7 +99,7 @@ export default function AuditLogPage() { )} -
+
); } diff --git a/frontend/app/admin/clients/page.tsx b/frontend/app/admin/clients/page.tsx index 7e3b0d4..a38fa5c 100644 --- a/frontend/app/admin/clients/page.tsx +++ b/frontend/app/admin/clients/page.tsx @@ -5,7 +5,8 @@ import { useDispatch, useSelector } from 'react-redux'; import { AppDispatch, RootState } from '@/store/store'; import { fetchClients } from '@/store/slices/adminSlice'; import { Button } from '@/components/ui/button'; -import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Badge } from '@/components/ui/badge'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/shared/Card'; import { Plus, Building2 } from 'lucide-react'; import Link from 'next/link'; import CreateClientDialog from '../components/CreateClientDialog'; @@ -22,11 +23,11 @@ export default function ClientsPage() { if (isLoading) { return ( -
-

Clients

+
+

Clients

{[...Array(3)].map((_, i) => ( -
+
))}
@@ -34,9 +35,9 @@ export default function ClientsPage() { } return ( -
+
-

Clients

+

Clients

{user?.role === 'super_admin' && (
{clients.length === 0 ? ( -
- +
+

No clients yet.

{user?.role === 'super_admin' && (

Create your first client to get started.

@@ -57,21 +58,28 @@ export default function ClientsPage() {
{clients.map((client) => ( - - + + - + {client.name} -
+
/{client.slug} - + {client.is_active ? 'Active' : 'Inactive'} - +
-

+

Policy: {client.review_policy}

diff --git a/frontend/app/admin/storage/page.tsx b/frontend/app/admin/storage/page.tsx index ffd3b7a..0ac49ae 100644 --- a/frontend/app/admin/storage/page.tsx +++ b/frontend/app/admin/storage/page.tsx @@ -16,7 +16,8 @@ import { AlertTriangle, RefreshCw, } from 'lucide-react'; -import { Card } from '@/components/ui/card'; +import { Card } from '@/components/shared/Card'; +import { StatusBadge } from '@/components/shared/StatusBadge'; import { Button } from '@/components/ui/button'; import { Dialog, @@ -68,11 +69,6 @@ function formatBytes(bytes: number): string { return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`; } -const STATUS_COLORS: Record = { - draft: 'bg-yellow-100 text-yellow-700', - in_review: 'bg-blue-100 text-blue-700', - approved: 'bg-green-100 text-green-700', -}; export default function StoragePage() { const user = useSelector((state: RootState) => state.auth.user); @@ -219,7 +215,7 @@ export default function StoragePage() { } return ( -
+

Storage

@@ -251,14 +247,14 @@ export default function StoragePage() { {/* Summary Cards */} {summary && ( -
+
-

Presentations

+

Presentations

{summary.total_presentations}

-
+
@@ -266,10 +262,10 @@ export default function StoragePage() {
-

Export Files

+

Export Files

{summary.total_files}

-
+
@@ -277,15 +273,15 @@ export default function StoragePage() {
-

Master Decks

+

Master Decks

{summary.total_master_decks} - + ({summary.master_deck_files} files)

-
+
@@ -293,12 +289,12 @@ export default function StoragePage() {
-

Total Size

+

Total Size

{formatBytes(summary.total_size_bytes + (summary.master_deck_size_bytes || 0))}

-
+
@@ -308,8 +304,8 @@ export default function StoragePage() { {/* Soft-deleted notice + purge */} {summary && summary.total_deleted > 0 && isSuperAdmin && ( -
-
+
+
{summary.total_deleted} soft-deleted presentation(s) with files still on disk.
@@ -318,7 +314,7 @@ export default function StoragePage() { size="sm" onClick={handlePurge} disabled={purging} - className="text-amber-700 border-amber-300 hover:bg-amber-100" + className="text-[hsl(var(--warning))] border-[hsl(var(--warning)/0.3)] hover:bg-[hsl(var(--warning)/0.08)]" > {purging ? : } Purge Files @@ -328,8 +324,8 @@ export default function StoragePage() { {/* Bulk actions toolbar */} {selectedIds.size > 0 && ( -
- +
+ {selectedIds.size} selected
+
); } diff --git a/frontend/app/login/page.tsx b/frontend/app/login/page.tsx index 0be2e68..9ad3a58 100644 --- a/frontend/app/login/page.tsx +++ b/frontend/app/login/page.tsx @@ -52,14 +52,23 @@ export default function LoginPage() { }; return ( -
-
-
+
+
+
-

+ {/* Brand mark */} +
+ + + + + + +
+

OLIVER DeckForge

-

+

AI-powered presentation generation for enterprise teams

@@ -81,8 +90,8 @@ export default function LoginPage() { {isDevMode && (
-
-

+

+

Development Mode — Azure AD not configured

@@ -90,7 +99,7 @@ export default function LoginPage() {
@@ -100,7 +109,7 @@ export default function LoginPage() { value={email} onChange={(e) => setEmail(e.target.value)} required - className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent" + className="w-full rounded-lg border border-border px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-[hsl(var(--primary))] focus:border-transparent" placeholder="admin@deckforge.dev" />
@@ -108,7 +117,7 @@ export default function LoginPage() {
@@ -118,19 +127,19 @@ export default function LoginPage() { value={password} onChange={(e) => setPassword(e.target.value)} required - className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent" - placeholder="devpass123" + className="w-full rounded-lg border border-border px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-[hsl(var(--primary))] focus:border-transparent" + placeholder="••••••••" />
{error && ( -

{error}

+

{error}

)}