Phase 5: Fix export, slide edit, static files; add README
- Fix PPTX/PDF export: Puppeteer URL port mismatch (80 → 3000) - Fix backend export_utils to use NEXT_INTERNAL_URL env var - Add Chromium to frontend Dockerfile for Docker-based export - Fix slide edit socket hang up with asyncio.wait_for() timeouts - Add FastAPI StaticFiles mounts for /static and /app_data - Add Next.js rewrite for /static/ to proxy to backend - Show template thumbnail in master decks admin page - Add error logging to ReviewWorkflow component - Add Docker env vars for web service (APP_DATA_DIRECTORY, app_data volume) - Add project README in English Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
71ebbf3626
commit
ff9cdffc32
13 changed files with 349 additions and 24 deletions
221
README.md
Normal file
221
README.md
Normal file
|
|
@ -0,0 +1,221 @@
|
|||
# Oliver DeckForge
|
||||
|
||||
AI-powered enterprise presentation generator with multi-tenant architecture, custom template support, and role-based access control.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
┌─────────┐
|
||||
│ nginx │ :80
|
||||
└────┬────┘
|
||||
┌─────┴─────┐
|
||||
┌────┴───┐ ┌────┴───┐
|
||||
│ Next.js │ │FastAPI │
|
||||
│ (web) │ │ (api) │
|
||||
│ :3000 │ │ :8000 │
|
||||
└────┬────┘ └───┬────┘
|
||||
│ ┌───┴────┐
|
||||
│ ┌────┴──┐ ┌───┴────┐
|
||||
│ │Worker │ │Postgres│
|
||||
│ │ (arq) │ │ :5432 │
|
||||
│ └───┬───┘ └────────┘
|
||||
│ │
|
||||
└──────┴──── app_data volume
|
||||
```
|
||||
|
||||
| Service | Stack | Purpose |
|
||||
|------------|--------------------------------------|----------------------------------------|
|
||||
| **web** | Next.js 14, Redux Toolkit, Shadcn UI | Frontend SPA + Puppeteer PDF/PPTX export |
|
||||
| **api** | FastAPI, SQLModel, Alembic | REST API, auth, RBAC, SSE streaming |
|
||||
| **worker** | arq (async Redis queue) | Background AI generation jobs |
|
||||
| **postgres** | PostgreSQL 16 | Primary database |
|
||||
| **redis** | Redis 7 | Job queue + caching |
|
||||
| **nginx** | nginx | Reverse proxy, static file serving |
|
||||
|
||||
## Features
|
||||
|
||||
- **AI Presentation Generation** — Upload documents (DOCX, PPTX, images) or provide a URL/topic, get a full slide deck
|
||||
- **Custom Templates** — Upload master PPTX decks, AI parses layouts into reusable React components
|
||||
- **Multi-Tenant** — Client-based data isolation with per-client branding and storage
|
||||
- **RBAC** — Super admin, client admin, and user roles with granular permissions
|
||||
- **SSO** — Azure AD authentication with dev bypass mode for local development
|
||||
- **Review Workflow** — Draft / In Review / Approved status tracking for presentations
|
||||
- **Export** — PDF and PPTX export via headless Chromium
|
||||
- **Admin Panel** — User/team/client management, analytics, storage, settings, audit logs
|
||||
- **Multi-Provider AI** — Anthropic (Claude), OpenAI, Google, Ollama for LLM; multiple image generation providers
|
||||
- **i18n** — Internationalization support via react-i18next
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Docker & Docker Compose
|
||||
- An Anthropic API key (for AI generation)
|
||||
- A Google API key (for image generation, optional)
|
||||
|
||||
### 1. Configure environment
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
Edit `.env` and set your API keys:
|
||||
|
||||
```env
|
||||
ANTHROPIC_API_KEY=sk-ant-...
|
||||
GOOGLE_API_KEY=... # optional, for image generation
|
||||
```
|
||||
|
||||
### 2. Start all services
|
||||
|
||||
```bash
|
||||
make dev
|
||||
```
|
||||
|
||||
This builds and starts all 6 services. The app will be available at:
|
||||
|
||||
- **App**: http://localhost (via nginx) or http://localhost:3000 (direct)
|
||||
- **API docs**: http://localhost/docs
|
||||
- **Database**: localhost:5432
|
||||
|
||||
### 3. Run database migrations
|
||||
|
||||
```bash
|
||||
make migrate
|
||||
```
|
||||
|
||||
### 4. Seed initial data
|
||||
|
||||
```bash
|
||||
make seed
|
||||
```
|
||||
|
||||
This creates the default super admin user and a sample client.
|
||||
|
||||
### 5. Log in
|
||||
|
||||
With Azure AD credentials not configured, the app uses dev auth bypass mode. Log in at http://localhost/login with the password set in `DEV_AUTH_PASSWORD` (default: `devpass123`).
|
||||
|
||||
## Local Development (without Docker)
|
||||
|
||||
### Backend
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
python -m venv venv
|
||||
source venv/bin/activate
|
||||
pip install -r requirements.txt
|
||||
|
||||
# Set environment variables
|
||||
export DATABASE_URL="postgresql+asyncpg://deckforge:deckforge@localhost:5432/deckforge"
|
||||
export REDIS_URL="redis://localhost:6379/0"
|
||||
|
||||
# Run API server
|
||||
uvicorn api.main:app --reload --port 8000
|
||||
|
||||
# Run worker (separate terminal)
|
||||
python -m arq workers.main.WorkerSettings
|
||||
```
|
||||
|
||||
### Frontend
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
The frontend dev server runs on port 3000 and proxies API requests to the backend via Next.js rewrites.
|
||||
|
||||
## Makefile Commands
|
||||
|
||||
| Command | Description |
|
||||
|---------------|--------------------------------------|
|
||||
| `make dev` | Build and start all services |
|
||||
| `make build` | Build Docker images |
|
||||
| `make up` | Start services (detached) |
|
||||
| `make down` | Stop all services |
|
||||
| `make migrate`| Run Alembic database migrations |
|
||||
| `make seed` | Seed initial data |
|
||||
| `make test` | Run backend pytest suite |
|
||||
| `make test-e2e`| Run Cypress E2E tests |
|
||||
| `make logs` | Tail all service logs |
|
||||
| `make shell-api` | Shell into API container |
|
||||
| `make shell-db` | psql into PostgreSQL |
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
├── backend/
|
||||
│ ├── api/ # FastAPI app, routers, middlewares
|
||||
│ │ ├── v1/
|
||||
│ │ │ ├── admin/ # Admin panel endpoints
|
||||
│ │ │ ├── auth/ # Authentication (Azure AD + dev bypass)
|
||||
│ │ │ └── ppt/ # Presentation CRUD, generation, export
|
||||
│ │ └── middlewares/ # Auth, RBAC, audit middlewares
|
||||
│ ├── models/ # SQLModel database models
|
||||
│ ├── services/ # Business logic (AI, templates, settings)
|
||||
│ ├── workers/ # arq background job definitions
|
||||
│ ├── utils/ # Helpers (export, layout, env)
|
||||
│ ├── alembic/ # Database migrations
|
||||
│ └── Dockerfile
|
||||
├── frontend/
|
||||
│ ├── app/ # Next.js 14 App Router pages
|
||||
│ │ ├── (presentation-generator)/ # Main app routes
|
||||
│ │ ├── admin/ # Admin panel pages
|
||||
│ │ └── api/ # API routes (export via Puppeteer)
|
||||
│ ├── components/ui/ # Shadcn UI components
|
||||
│ ├── store/slices/ # Redux Toolkit state management
|
||||
│ ├── locales/ # i18n translation files
|
||||
│ └── Dockerfile
|
||||
├── docker-compose.yml
|
||||
├── nginx.conf
|
||||
├── Makefile
|
||||
└── .env.example
|
||||
```
|
||||
|
||||
## Environment Variables
|
||||
|
||||
| Variable | Required | Default | Description |
|
||||
|------------------------|----------|--------------------|------------------------------------------|
|
||||
| `ANTHROPIC_API_KEY` | Yes | — | Anthropic API key for Claude |
|
||||
| `ANTHROPIC_MODEL` | No | claude-sonnet-4-6 | Claude model ID |
|
||||
| `GOOGLE_API_KEY` | No | — | Google API key for image generation |
|
||||
| `IMAGE_PROVIDER` | No | nanobanana_pro | Image provider (see below) |
|
||||
| `POSTGRES_PASSWORD` | No | deckforge | PostgreSQL password |
|
||||
| `JWT_SECRET_KEY` | Yes | — | Secret for JWT token signing |
|
||||
| `AZURE_AD_TENANT_ID` | No | — | Azure AD tenant (blank = dev auth mode) |
|
||||
| `AZURE_AD_CLIENT_ID` | No | — | Azure AD app client ID |
|
||||
| `AZURE_AD_CLIENT_SECRET`| No | — | Azure AD app secret |
|
||||
| `DEV_AUTH_PASSWORD` | No | devpass123 | Password for dev auth bypass |
|
||||
| `APP_DATA_DIRECTORY` | No | ./data | Directory for generated files |
|
||||
|
||||
### Supported AI Providers
|
||||
|
||||
**LLM**: `anthropic` (default), `openai`, `google`, `ollama`, `custom`
|
||||
|
||||
**Image generation**: `nanobanana_pro` (default), `gemini_flash`, `dall-e-3`, `gpt-image-1.5`, `pexels`, `pixabay`, `comfyui`
|
||||
|
||||
## Authentication
|
||||
|
||||
### Azure AD SSO (Production)
|
||||
|
||||
Configure `AZURE_AD_TENANT_ID`, `AZURE_AD_CLIENT_ID`, and `AZURE_AD_CLIENT_SECRET` in `.env`. Users authenticate via Microsoft login flow.
|
||||
|
||||
### Dev Bypass (Local Development)
|
||||
|
||||
When `AZURE_AD_TENANT_ID` is not set, the app uses a simple password-based login. Set the password via `DEV_AUTH_PASSWORD`.
|
||||
|
||||
## Custom Templates
|
||||
|
||||
1. Navigate to **Admin > Clients > [Client] > Master Decks**
|
||||
2. Upload a PPTX file — the system will:
|
||||
- Extract slide layouts and XML definitions
|
||||
- Generate PDF screenshots via LibreOffice
|
||||
- Use AI (LLM vision) to convert each layout into a React TSX component
|
||||
3. The parsed layouts appear as available templates during presentation generation
|
||||
4. Manage layouts: delete unwanted ones, filter by type, toggle visibility
|
||||
|
||||
## License
|
||||
|
||||
Proprietary. All rights reserved.
|
||||
|
|
@ -1,5 +1,8 @@
|
|||
import os
|
||||
|
||||
from fastapi import APIRouter, FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from api.lifespan import app_lifespan
|
||||
from api.middlewares import UserConfigEnvUpdateMiddleware
|
||||
from api.middlewares.auth_middleware import AuthMiddleware
|
||||
|
|
@ -46,6 +49,17 @@ app.include_router(EXPORT_ROUTER)
|
|||
app.include_router(API_V1_WEBHOOK_ROUTER)
|
||||
app.include_router(API_V1_MOCK_ROUTER)
|
||||
|
||||
# Serve static assets (placeholder images, etc.)
|
||||
_static_dir = os.path.join(os.path.dirname(__file__), "..", "static")
|
||||
if os.path.isdir(_static_dir):
|
||||
app.mount("/static", StaticFiles(directory=_static_dir), name="static")
|
||||
|
||||
# Serve data directory (images, exports, uploads) for local dev
|
||||
# In Docker, nginx serves these directly; this is a fallback for dev mode
|
||||
_data_dir = os.environ.get("APP_DATA_DIRECTORY", os.path.join(os.path.dirname(__file__), "..", "data"))
|
||||
os.makedirs(_data_dir, exist_ok=True)
|
||||
app.mount("/app_data", StaticFiles(directory=_data_dir), name="app_data")
|
||||
|
||||
# Middlewares (executed in reverse order: last added = first executed)
|
||||
# 1. CORS must run first (handles preflight OPTIONS)
|
||||
origins = ["*"]
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
import asyncio
|
||||
import traceback
|
||||
from typing import Annotated, Optional
|
||||
from fastapi import APIRouter, Body, Depends, HTTPException
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
|
@ -12,7 +14,6 @@ from utils.llm_calls.edit_slide import get_edited_slide_content
|
|||
from utils.llm_calls.edit_slide_html import get_edited_slide_html
|
||||
from utils.llm_calls.select_slide_type_on_edit import get_slide_layout_from_prompt
|
||||
from utils.process_slides import process_old_and_new_slides_and_fetch_assets
|
||||
import uuid
|
||||
|
||||
|
||||
SLIDE_ROUTER = APIRouter(prefix="/slide", tags=["Slide"])
|
||||
|
|
@ -31,22 +32,43 @@ async def edit_slide(
|
|||
if not presentation:
|
||||
raise HTTPException(status_code=404, detail="Presentation not found")
|
||||
|
||||
try:
|
||||
presentation_layout = presentation.get_layout()
|
||||
slide_layout = await get_slide_layout_from_prompt(
|
||||
prompt, presentation_layout, slide
|
||||
slide_layout = await asyncio.wait_for(
|
||||
get_slide_layout_from_prompt(prompt, presentation_layout, slide),
|
||||
timeout=60,
|
||||
)
|
||||
|
||||
edited_slide_content = await get_edited_slide_content(
|
||||
edited_slide_content = await asyncio.wait_for(
|
||||
get_edited_slide_content(
|
||||
prompt, slide, presentation.language, slide_layout
|
||||
),
|
||||
timeout=90,
|
||||
)
|
||||
|
||||
image_generation_service = ImageGenerationService(get_images_directory())
|
||||
|
||||
# This will mutate edited_slide_content
|
||||
new_assets = await process_old_and_new_slides_and_fetch_assets(
|
||||
new_assets = await asyncio.wait_for(
|
||||
process_old_and_new_slides_and_fetch_assets(
|
||||
image_generation_service,
|
||||
slide.content,
|
||||
edited_slide_content,
|
||||
),
|
||||
timeout=120,
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
raise HTTPException(
|
||||
status_code=504,
|
||||
detail="Slide editing timed out. The AI model or image generation took too long. Please try again.",
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
traceback.print_exc()
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Failed to edit slide: {str(e)}",
|
||||
)
|
||||
|
||||
# Always assign a new unique id to the slide
|
||||
|
|
|
|||
|
|
@ -21,12 +21,14 @@ async def export_presentation(
|
|||
client_id: Optional[uuid.UUID] = None,
|
||||
session: Optional[AsyncSession] = None,
|
||||
) -> PresentationAndPath:
|
||||
next_url = os.environ.get("NEXT_INTERNAL_URL", "http://localhost:3000")
|
||||
|
||||
if export_as == "pptx":
|
||||
|
||||
# Get the converted PPTX model from the Next.js service
|
||||
async with aiohttp.ClientSession() as http:
|
||||
async with http.get(
|
||||
f"http://localhost/api/presentation_to_pptx_model?id={presentation_id}"
|
||||
f"{next_url}/api/presentation_to_pptx_model?id={presentation_id}"
|
||||
) as response:
|
||||
if response.status != 200:
|
||||
error_text = await response.text()
|
||||
|
|
@ -76,7 +78,7 @@ async def export_presentation(
|
|||
else:
|
||||
async with aiohttp.ClientSession() as http:
|
||||
async with http.post(
|
||||
"http://localhost/api/export-as-pdf",
|
||||
f"{next_url}/api/export-as-pdf",
|
||||
json={
|
||||
"id": str(presentation_id),
|
||||
"title": sanitize_filename(title or str(uuid.uuid4())),
|
||||
|
|
|
|||
|
|
@ -39,6 +39,7 @@ services:
|
|||
REDIS_URL: redis://redis:6379/0
|
||||
APP_DATA_DIRECTORY: /app_data
|
||||
TEMP_DIRECTORY: /tmp/deckforge
|
||||
NEXT_INTERNAL_URL: "http://web:3000"
|
||||
volumes:
|
||||
- app_data:/app_data
|
||||
depends_on:
|
||||
|
|
@ -76,6 +77,10 @@ services:
|
|||
environment:
|
||||
CAN_CHANGE_KEYS: "false"
|
||||
API_INTERNAL_URL: "http://api:8000"
|
||||
APP_DATA_DIRECTORY: /app_data
|
||||
TEMP_DIRECTORY: /tmp/deckforge
|
||||
volumes:
|
||||
- app_data:/app_data
|
||||
depends_on:
|
||||
- api
|
||||
|
||||
|
|
|
|||
|
|
@ -9,6 +9,14 @@ RUN npm run build
|
|||
|
||||
FROM node:20-alpine
|
||||
|
||||
# Install Chromium for Puppeteer (used by PDF/PPTX export)
|
||||
RUN apk add --no-cache chromium nss freetype harfbuzz ca-certificates ttf-freefont
|
||||
|
||||
ENV PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium-browser
|
||||
ENV PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true
|
||||
ENV APP_DATA_DIRECTORY=/app_data
|
||||
ENV TEMP_DIRECTORY=/tmp/deckforge
|
||||
|
||||
WORKDIR /app
|
||||
COPY --from=builder /app/.next-build ./.next-build
|
||||
COPY --from=builder /app/node_modules ./node_modules
|
||||
|
|
|
|||
|
|
@ -62,8 +62,8 @@ export default function ReviewWorkflow({ presentationId }: ReviewWorkflowProps)
|
|||
});
|
||||
const data = await ApiResponseHandler.handleResponse(response, "Failed to fetch review info");
|
||||
setInfo(data);
|
||||
} catch {
|
||||
// Silently fail — review info is supplementary
|
||||
} catch (err) {
|
||||
console.error("ReviewWorkflow: failed to fetch review info", err);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -402,7 +402,18 @@ function DeckCard({
|
|||
<button onClick={onToggle} className="text-gray-400 hover:text-gray-600">
|
||||
{expanded ? <ChevronDown className="w-5 h-5" /> : <ChevronRight className="w-5 h-5" />}
|
||||
</button>
|
||||
<Layers className="w-5 h-5 text-[#5146E5] flex-shrink-0" />
|
||||
{deck.thumbnail_path ? (
|
||||
<img
|
||||
src={`/api/v1/admin/master-decks/${deck.id}/screenshot/${deck.thumbnail_path.split('/').pop()}`}
|
||||
alt={deck.name}
|
||||
className="w-16 h-10 object-cover rounded border flex-shrink-0 bg-white"
|
||||
onError={(e) => {
|
||||
(e.target as HTMLImageElement).style.display = 'none';
|
||||
(e.target as HTMLImageElement).nextElementSibling?.classList.remove('hidden');
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
<Layers className={`w-5 h-5 text-[#5146E5] flex-shrink-0 ${deck.thumbnail_path ? 'hidden' : ''}`} />
|
||||
<div className="min-w-0">
|
||||
<p className="font-medium truncate">{deck.name}</p>
|
||||
<p className="text-xs text-gray-500">
|
||||
|
|
|
|||
|
|
@ -34,7 +34,8 @@ export async function POST(req: NextRequest) {
|
|||
page.setDefaultNavigationTimeout(300000);
|
||||
page.setDefaultTimeout(300000);
|
||||
|
||||
await page.goto(`http://localhost/pdf-maker?id=${id}`, {
|
||||
const baseUrl = process.env.PUPPETEER_BASE_URL || `http://localhost:${process.env.PORT || 3000}`;
|
||||
await page.goto(`${baseUrl}/pdf-maker?id=${id}`, {
|
||||
waitUntil: "networkidle0",
|
||||
timeout: 300000,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -98,7 +98,8 @@ async function getBrowserAndPage(id: string): Promise<[Browser, Page]> {
|
|||
await page.setViewport({ width: 1280, height: 720, deviceScaleFactor: 1 });
|
||||
page.setDefaultNavigationTimeout(300000);
|
||||
page.setDefaultTimeout(300000);
|
||||
await page.goto(`http://localhost/pdf-maker?id=${id}`, {
|
||||
const baseUrl = process.env.PUPPETEER_BASE_URL || `http://localhost:${process.env.PORT || 3000}`;
|
||||
await page.goto(`${baseUrl}/pdf-maker?id=${id}`, {
|
||||
waitUntil: "networkidle0",
|
||||
timeout: 300000,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -572,7 +572,7 @@ thead {
|
|||
box-shadow: 0 -0.25em 0 hsl(30,90%,80%) inset,
|
||||
0.75em -1.55em 0 hsl(30,90%,90%) inset;
|
||||
top: 0;
|
||||
left: -2em;
|
||||
left: -1.5em;
|
||||
width: 2.75em;
|
||||
height: 2.5em;
|
||||
transform-origin: 100% 50%;
|
||||
|
|
@ -616,7 +616,7 @@ thead {
|
|||
box-shadow: 0.1em 0.75em 0 hsl(30,90%,55%) inset,
|
||||
0.15em -0.5em 0 hsl(30,90%,80%) inset;
|
||||
top: 0.25em;
|
||||
left: 2em;
|
||||
left: 1.5em;
|
||||
width: 4.5em;
|
||||
height: 3em;
|
||||
transform-origin: 17% 50%;
|
||||
|
|
|
|||
36
frontend/components/ui/checkbox.tsx
Normal file
36
frontend/components/ui/checkbox.tsx
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Check } from "lucide-react"
|
||||
|
||||
export interface CheckboxProps
|
||||
extends Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, "onChange"> {
|
||||
checked?: boolean
|
||||
onCheckedChange?: (checked: boolean) => void
|
||||
}
|
||||
|
||||
const Checkbox = React.forwardRef<HTMLButtonElement, CheckboxProps>(
|
||||
({ className, checked = false, onCheckedChange, ...props }, ref) => {
|
||||
return (
|
||||
<button
|
||||
ref={ref}
|
||||
role="checkbox"
|
||||
aria-checked={checked}
|
||||
type="button"
|
||||
onClick={() => onCheckedChange?.(!checked)}
|
||||
className={cn(
|
||||
"peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
checked && "bg-primary text-primary-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{checked && <Check className="h-3.5 w-3.5" />}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
)
|
||||
Checkbox.displayName = "Checkbox"
|
||||
|
||||
export { Checkbox }
|
||||
|
|
@ -17,6 +17,10 @@ const nextConfig = {
|
|||
source: '/app_data/:path*',
|
||||
destination: `${API_URL}/app_data/:path*`,
|
||||
},
|
||||
{
|
||||
source: '/static/:path*',
|
||||
destination: `${API_URL}/static/:path*`,
|
||||
},
|
||||
];
|
||||
},
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue