Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b5dda5ed97 | ||
|
|
a4b874598d |
No files matched your search
+13
-13
@@ -1,13 +1,13 @@
|
|||||||
# Copy this file to .env and fill in the values
|
# Copy this file to .env and fill in the values
|
||||||
|
|
||||||
# openrouter.ai API key
|
# openrouter.ai API key
|
||||||
OPENROUTER_API_KEY=
|
OPENROUTER_API_KEY=
|
||||||
|
|
||||||
# JWT secret for signing tokens (generate with: python -c "import secrets; print(secrets.token_hex(32))")
|
# JWT secret for signing tokens (generate with: python -c "import secrets; print(secrets.token_hex(32))")
|
||||||
JWT_SECRET=
|
JWT_SECRET=
|
||||||
|
|
||||||
# Path to DuckDB database file
|
# Path to DuckDB database file
|
||||||
DB_PATH=data/app.db
|
DB_PATH=data/app.db
|
||||||
|
|
||||||
# Log level (DEBUG, INFO, WARNING, ERROR)
|
# Log level (DEBUG, INFO, WARNING, ERROR)
|
||||||
LOG_LEVEL=INFO
|
LOG_LEVEL=INFO
|
||||||
+52
-52
@@ -1,52 +1,52 @@
|
|||||||
# Python
|
# Python
|
||||||
__pycache__/
|
__pycache__/
|
||||||
*.py[cod]
|
*.py[cod]
|
||||||
*.pyo
|
*.pyo
|
||||||
*.pyd
|
*.pyd
|
||||||
.Python
|
.Python
|
||||||
*.egg
|
*.egg
|
||||||
*.egg-info/
|
*.egg-info/
|
||||||
dist/
|
dist/
|
||||||
build/
|
build/
|
||||||
eggs/
|
eggs/
|
||||||
parts/
|
parts/
|
||||||
var/
|
var/
|
||||||
sdist/
|
sdist/
|
||||||
wheels/
|
wheels/
|
||||||
pip-wheel-metadata/
|
pip-wheel-metadata/
|
||||||
|
|
||||||
# Virtual environments
|
# Virtual environments
|
||||||
.venv/
|
.venv/
|
||||||
venv/
|
venv/
|
||||||
env/
|
env/
|
||||||
ENV/
|
ENV/
|
||||||
|
|
||||||
# Environment variables
|
# Environment variables
|
||||||
.env
|
.env
|
||||||
.env.*
|
.env.*
|
||||||
!.env.example
|
!.env.example
|
||||||
|
|
||||||
# DuckDB data files
|
# DuckDB data files
|
||||||
data/*.db
|
data/*.db
|
||||||
data/*.duckdb
|
data/*.duckdb
|
||||||
|
|
||||||
# pytest
|
# pytest
|
||||||
.pytest_cache/
|
.pytest_cache/
|
||||||
.coverage
|
.coverage
|
||||||
htmlcov/
|
htmlcov/
|
||||||
|
|
||||||
# IDE
|
# IDE
|
||||||
.vscode/
|
.vscode/
|
||||||
.idea/
|
.idea/
|
||||||
|
|
||||||
# OS
|
# OS
|
||||||
.DS_Store
|
.DS_Store
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
|
|
||||||
# instructions
|
# instructions
|
||||||
.github/instructions/
|
.github/instructions/
|
||||||
|
|
||||||
# Logs and generated data
|
# Logs and generated data
|
||||||
logs/
|
logs/
|
||||||
data/
|
data/
|
||||||
backend/data/
|
backend/data/
|
||||||
+43
-36
@@ -1,36 +1,43 @@
|
|||||||
## [Unreleased] - 2026-05-31
|
## [Unreleased] - 2026-09-03
|
||||||
|
|
||||||
### Added
|
### Fixed
|
||||||
|
|
||||||
- Added admin video job management with a dedicated admin page, job table, status filters, cancel and purge actions, and backend endpoints for video job listing, cancellation, and cleanup.
|
- Fixed the backend video-generation test suite so it reflects the queue + background-worker architecture (POST `/generate/video` and `/generate/video/from-image` now queue jobs and return a `db_id`; the worker assigns the provider `job_id` and persists status transitions, and upstream OpenRouter errors mark jobs `failed`). All 120 backend tests pass.
|
||||||
- Added full gallery support for generated images, generated videos, and uploaded images with new routes, detail pages, templates, and thumbnail previews.
|
- Fixed the frontend test suite, which had become stale after the app-factory/blueprint/helpers refactor: mock target updated to the module that actually performs HTTP calls, per-request token validation stubbed, and video-job-submission tests updated to expect the gallery redirect. All 48 frontend tests pass.
|
||||||
- Added backend endpoints for single-generated-image retrieval, single-video-job retrieval, and uploaded image retrieval.
|
|
||||||
- Added queued video generation workflow: backend job creation, background worker polling, OpenRouter job submission, status transitions from queued to processing to completed/failed, and timeout detection for stale jobs.
|
## [Unreleased] - 2026-05-31
|
||||||
- Added model cache management with backend OpenRouter model retrieval, DuckDB caching, cache-status endpoints, manual refresh endpoint, and frontend admin UI for model inspection.
|
|
||||||
- Added frontend generation pages and forms for text, image, and video generation, including dropdown model selection by modality and support for image upload input on video generation flows.
|
### Added
|
||||||
- Added user-facing profile and admin sections in the Flask frontend, including profile update forms and admin user management actions.
|
|
||||||
- Added documentation updates and architecture content refresh across README and docs, plus Coolify deployment guidance.
|
- Added admin video job management with a dedicated admin page, job table, status filters, cancel and purge actions, and backend endpoints for video job listing, cancellation, and cleanup.
|
||||||
- Added an initial admin user account `ai@allucanget.biz` with password `admin123` in the database initialization script.
|
- Added full gallery support for generated images, generated videos, and uploaded images with new routes, detail pages, templates, and thumbnail previews.
|
||||||
|
- Added backend endpoints for single-generated-image retrieval, single-video-job retrieval, and uploaded image retrieval.
|
||||||
### Changed
|
- Added queued video generation workflow: backend job creation, background worker polling, OpenRouter job submission, status transitions from queued to processing to completed/failed, and timeout detection for stale jobs.
|
||||||
|
- Added model cache management with backend OpenRouter model retrieval, DuckDB caching, cache-status endpoints, manual refresh endpoint, and frontend admin UI for model inspection.
|
||||||
- Changed frontend API helper to support per-call timeout overrides.
|
- Added frontend generation pages and forms for text, image, and video generation, including dropdown model selection by modality and support for image upload input on video generation flows.
|
||||||
- Changed dashboard behavior to separate pending video jobs from completed jobs and make pending workflows more visible.
|
- Added user-facing profile and admin sections in the Flask frontend, including profile update forms and admin user management actions.
|
||||||
- Changed gallery presentation to show cards with thumbnails and links instead of raw list output.
|
- Added documentation updates and architecture content refresh across README and docs, plus Coolify deployment guidance.
|
||||||
- Changed admin navigation and UI to expose video jobs and model management pages more directly.
|
- Added an initial admin user account `ai@allucanget.biz` with password `admin123` in the database initialization script.
|
||||||
- Changed backend video generation responses to include database IDs and redirect users to video detail pages after job creation.
|
|
||||||
- Changed documentation and architecture files to reflect recent backend, frontend, and deployment changes.
|
### Changed
|
||||||
|
|
||||||
### Fixed
|
- Changed frontend API helper to support per-call timeout overrides.
|
||||||
|
- Changed dashboard behavior to separate pending video jobs from completed jobs and make pending workflows more visible.
|
||||||
- Fixed gallery spinner logic so an empty gallery shows a "No images yet" message instead of a spinner.
|
- Changed gallery presentation to show cards with thumbnails and links instead of raw list output.
|
||||||
- Fixed gallery image display issues so generated content is shown as cards with proper previews and detail links.
|
- Changed admin navigation and UI to expose video jobs and model management pages more directly.
|
||||||
- Fixed video polling logic to handle cancelled jobs and avoid infinite polling loops.
|
- Changed backend video generation responses to include database IDs and redirect users to video detail pages after job creation.
|
||||||
- Fixed failed video job handling with user-safe error feedback in both backend and frontend.
|
- Changed documentation and architecture files to reflect recent backend, frontend, and deployment changes.
|
||||||
- Fixed frontend image-generation timeout failures by catching `httpx.ReadTimeout` and surfacing a safe error message.
|
|
||||||
- Fixed frontend image generation route to handle backend timeout/request errors gracefully instead of returning 500 tracebacks.
|
### Fixed
|
||||||
- Fixed backend service behavior for actual video job processing and stale job timeout detection.
|
|
||||||
|
- Fixed gallery spinner logic so an empty gallery shows a "No images yet" message instead of a spinner.
|
||||||
### Removed
|
- Fixed gallery image display issues so generated content is shown as cards with proper previews and detail links.
|
||||||
|
- Fixed video polling logic to handle cancelled jobs and avoid infinite polling loops.
|
||||||
- None.
|
- Fixed failed video job handling with user-safe error feedback in both backend and frontend.
|
||||||
|
- Fixed frontend image-generation timeout failures by catching `httpx.ReadTimeout` and surfacing a safe error message.
|
||||||
|
- Fixed frontend image generation route to handle backend timeout/request errors gracefully instead of returning 500 tracebacks.
|
||||||
|
- Fixed backend service behavior for actual video job processing and stale job timeout detection.
|
||||||
|
|
||||||
|
### Removed
|
||||||
|
|
||||||
|
- None.
|
||||||
@@ -1,143 +1,143 @@
|
|||||||
# All You Can GET AI
|
# All You Can GET AI
|
||||||
|
|
||||||
A multi-modal AI web application. Users can choose between different AI models for text generation, text-to-image, text-to-video, and image-to-video generation, powered by [openrouter.ai](https://openrouter.ai).
|
A multi-modal AI web application. Users can choose between different AI models for text generation, text-to-image, text-to-video, and image-to-video generation, powered by [openrouter.ai](https://openrouter.ai).
|
||||||
|
|
||||||
Key features:
|
Key features:
|
||||||
|
|
||||||
- Multi-modal AI generation (text, images, videos)
|
- Multi-modal AI generation (text, images, videos)
|
||||||
- User authentication and role-based access control
|
- User authentication and role-based access control
|
||||||
- Admin dashboard for managing users, models, and video jobs
|
- Admin dashboard for managing users, models, and video jobs
|
||||||
- Gallery for viewing generated images and videos
|
- Gallery for viewing generated images and videos
|
||||||
- Chat interface with message history
|
- Chat interface with message history
|
||||||
- Image upload and preview functionality
|
- Image upload and preview functionality
|
||||||
|
|
||||||
## Components
|
## Components
|
||||||
|
|
||||||
| Component | Technology | Description |
|
| Component | Technology | Description |
|
||||||
| --------- | ----------------- | -------------------------------------------------------- |
|
| --------- | ----------------- | -------------------------------------------------------- |
|
||||||
| Backend | FastAPI + uvicorn | REST API for auth, user management, and AI generation |
|
| Backend | FastAPI + uvicorn | REST API for auth, user management, and AI generation |
|
||||||
| Database | DuckDB | Lightweight embedded storage for users and session data |
|
| Database | DuckDB | Lightweight embedded storage for users and session data |
|
||||||
| Frontend | Flask + Jinja2 | Server-rendered UI served at <https://ai.allucanget.biz> |
|
| Frontend | Flask + Jinja2 | Server-rendered UI served at <https://ai.allucanget.biz> |
|
||||||
|
|
||||||
## Getting Started
|
## Getting Started
|
||||||
|
|
||||||
### Prerequisites
|
### Prerequisites
|
||||||
|
|
||||||
- Python 3.12+
|
- Python 3.12+
|
||||||
- An [openrouter.ai](https://openrouter.ai) API key
|
- An [openrouter.ai](https://openrouter.ai) API key
|
||||||
|
|
||||||
### Setup
|
### Setup
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Clone the repo
|
# Clone the repo
|
||||||
git clone https://git.allucanget.biz/allucanget/ai.allucanget.biz.git
|
git clone https://git.allucanget.biz/allucanget/ai.allucanget.biz.git
|
||||||
cd ai.allucanget.biz
|
cd ai.allucanget.biz
|
||||||
|
|
||||||
# Create and activate virtual environment
|
# Create and activate virtual environment
|
||||||
python -m venv .venv
|
python -m venv .venv
|
||||||
# Windows
|
# Windows
|
||||||
.venv\Scripts\activate
|
.venv\Scripts\activate
|
||||||
# Linux/macOS
|
# Linux/macOS
|
||||||
source .venv/bin/activate
|
source .venv/bin/activate
|
||||||
|
|
||||||
# Install core dependencies
|
# Install core dependencies
|
||||||
pip install -r requirements.txt
|
pip install -r requirements.txt
|
||||||
|
|
||||||
# Install development dependencies
|
# Install development dependencies
|
||||||
pip install -r backend/requirements-dev.txt
|
pip install -r backend/requirements-dev.txt
|
||||||
pip install -r frontend/requirements-dev.txt
|
pip install -r frontend/requirements-dev.txt
|
||||||
|
|
||||||
# Copy environment variables file
|
# Copy environment variables file
|
||||||
cp .env.example .env
|
cp .env.example .env
|
||||||
|
|
||||||
# Edit .env file and add your OpenRouter API key and configure other settings
|
# Edit .env file and add your OpenRouter API key and configure other settings
|
||||||
nano .env
|
nano .env
|
||||||
```
|
```
|
||||||
|
|
||||||
### Running the application locally
|
### Running the application locally
|
||||||
|
|
||||||
#### Backend (FastAPI + Uvicorn)
|
#### Backend (FastAPI + Uvicorn)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd backend
|
cd backend
|
||||||
uvicorn app.main:app --reload --port 12015
|
uvicorn app.main:app --reload --port 12015
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Frontend (Flask)
|
#### Frontend (Flask)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd frontend
|
cd frontend
|
||||||
flask --app app.main run --port 12016 --debug
|
flask --app app.main run --port 12016 --debug
|
||||||
```
|
```
|
||||||
|
|
||||||
### Running tests
|
### Running tests
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Run all tests
|
# Run all tests
|
||||||
pytest
|
pytest
|
||||||
|
|
||||||
# Run backend tests only
|
# Run backend tests only
|
||||||
pytest backend/tests/
|
pytest backend/tests/
|
||||||
|
|
||||||
# Run frontend tests only
|
# Run frontend tests only
|
||||||
pytest frontend/tests/
|
pytest frontend/tests/
|
||||||
```
|
```
|
||||||
|
|
||||||
### Available Environment Variables
|
### Available Environment Variables
|
||||||
|
|
||||||
| Variable | Description | Default |
|
| Variable | Description | Default |
|
||||||
| -------------------- | --------------------------- | ------------------- |
|
| -------------------- | --------------------------- | ------------------- |
|
||||||
| `OPENROUTER_API_KEY` | Your OpenRouter API key | _Required_ |
|
| `OPENROUTER_API_KEY` | Your OpenRouter API key | _Required_ |
|
||||||
| `ADMIN_EMAIL` | Default admin user email | `ai@allucanget.biz` |
|
| `ADMIN_EMAIL` | Default admin user email | `ai@allucanget.biz` |
|
||||||
| `ADMIN_PASSWORD` | Default admin user password | `admin123` |
|
| `ADMIN_PASSWORD` | Default admin user password | `admin123` |
|
||||||
| `DATABASE_URL` | DuckDB database path | `../data/app.db` |
|
| `DATABASE_URL` | DuckDB database path | `../data/app.db` |
|
||||||
|
|
||||||
## Default admin user
|
## Default admin user
|
||||||
|
|
||||||
On first startup a default admin account is created:
|
On first startup a default admin account is created:
|
||||||
|
|
||||||
| Field | Value |
|
| Field | Value |
|
||||||
| -------- | ------------------- |
|
| -------- | ------------------- |
|
||||||
| Email | `ai@allucanget.biz` |
|
| Email | `ai@allucanget.biz` |
|
||||||
| Password | `admin123` |
|
| Password | `admin123` |
|
||||||
| Role | `admin` |
|
| Role | `admin` |
|
||||||
|
|
||||||
Override via environment variables `ADMIN_EMAIL` and `ADMIN_PASSWORD` before first run.
|
Override via environment variables `ADMIN_EMAIL` and `ADMIN_PASSWORD` before first run.
|
||||||
|
|
||||||
## Deployment
|
## Deployment
|
||||||
|
|
||||||
Deployed on [Coolify](https://coolify.io) using Nixpacks. See [docs/deployment/coolify.md](docs/deployment/coolify.md) for full instructions.
|
Deployed on [Coolify](https://coolify.io) using Nixpacks. See [docs/deployment/coolify.md](docs/deployment/coolify.md) for full instructions.
|
||||||
|
|
||||||
## Project Structure
|
## Project Structure
|
||||||
|
|
||||||
```txt
|
```txt
|
||||||
backend/ FastAPI backend
|
backend/ FastAPI backend
|
||||||
app/
|
app/
|
||||||
__init__.py Package initialization
|
__init__.py Package initialization
|
||||||
db.py Database connection and operations
|
db.py Database connection and operations
|
||||||
dependencies.py Dependency injection
|
dependencies.py Dependency injection
|
||||||
main.py FastAPI application entrypoint
|
main.py FastAPI application entrypoint
|
||||||
models/ Pydantic and database models
|
models/ Pydantic and database models
|
||||||
routers/ API route handlers (auth, users, admin, generate, gallery)
|
routers/ API route handlers (auth, users, admin, generate, gallery)
|
||||||
services/ Business logic for AI generation, users, admin, etc.
|
services/ Business logic for AI generation, users, admin, etc.
|
||||||
tests/ Backend test suite
|
tests/ Backend test suite
|
||||||
frontend/ Flask frontend
|
frontend/ Flask frontend
|
||||||
app/
|
app/
|
||||||
__init__.py App factory (create_app)
|
__init__.py App factory (create_app)
|
||||||
main.py Flask application entrypoint
|
main.py Flask application entrypoint
|
||||||
config.py Configuration
|
config.py Configuration
|
||||||
filters.py Jinja2 template filters
|
filters.py Jinja2 template filters
|
||||||
helpers.py API client, auth decorators, model helpers
|
helpers.py API client, auth decorators, model helpers
|
||||||
routes/ Blueprint route handlers (auth, dashboard, gallery, generate, admin, profile)
|
routes/ Blueprint route handlers (auth, dashboard, gallery, generate, admin, profile)
|
||||||
templates/ Jinja2 HTML templates
|
templates/ Jinja2 HTML templates
|
||||||
static/ CSS, JS, images
|
static/ CSS, JS, images
|
||||||
tests/ Frontend test suite
|
tests/ Frontend test suite
|
||||||
data/ DuckDB database files, uploaded media, and generated content
|
data/ DuckDB database files, uploaded media, and generated content
|
||||||
logs/ Application logs
|
logs/ Application logs
|
||||||
docs/ Architecture documentation (arc42 template)
|
docs/ Architecture documentation (arc42 template)
|
||||||
nginx/ Nginx configuration for Coolify deployment
|
nginx/ Nginx configuration for Coolify deployment
|
||||||
```
|
```
|
||||||
|
|
||||||
## Documentation
|
## Documentation
|
||||||
|
|
||||||
For the full architecture documentation see [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md), which links to all 12 arc42 sections.
|
For the full architecture documentation see [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md), which links to all 12 arc42 sections.
|
||||||
+22
-22
@@ -1,22 +1,22 @@
|
|||||||
FROM python:3.12-slim
|
FROM python:3.12-slim
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Install system dependencies
|
# Install system dependencies
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
gcc \
|
gcc \
|
||||||
curl \
|
curl \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
# Copy requirements and install Python dependencies
|
# Copy requirements and install Python dependencies
|
||||||
COPY requirements.txt .
|
COPY requirements.txt .
|
||||||
RUN pip install --no-cache-dir -r requirements.txt
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
# Copy application code
|
# Copy application code
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
# Expose port
|
# Expose port
|
||||||
EXPOSE 12015
|
EXPOSE 12015
|
||||||
|
|
||||||
# Run the application
|
# Run the application
|
||||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "12015"]
|
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "12015"]
|
||||||
+68
-68
@@ -1,68 +1,68 @@
|
|||||||
# Backend — FastAPI REST API
|
# Backend — FastAPI REST API
|
||||||
|
|
||||||
REST API for authentication, user management, AI generation (text/image/video), and admin operations.
|
REST API for authentication, user management, AI generation (text/image/video), and admin operations.
|
||||||
|
|
||||||
## Tech Stack
|
## Tech Stack
|
||||||
|
|
||||||
| Layer | Choice |
|
| Layer | Choice |
|
||||||
| ----------- | ----------------------------- |
|
| ----------- | ----------------------------- |
|
||||||
| Framework | FastAPI |
|
| Framework | FastAPI |
|
||||||
| Database | DuckDB (embedded) |
|
| Database | DuckDB (embedded) |
|
||||||
| HTTP client | httpx (OpenRouter proxy) |
|
| HTTP client | httpx (OpenRouter proxy) |
|
||||||
| Auth | JWT (access + refresh tokens) |
|
| Auth | JWT (access + refresh tokens) |
|
||||||
| Server | uvicorn |
|
| Server | uvicorn |
|
||||||
|
|
||||||
## Project Structure
|
## Project Structure
|
||||||
|
|
||||||
```txt
|
```txt
|
||||||
backend/
|
backend/
|
||||||
app/
|
app/
|
||||||
__init__.py Package init (empty)
|
__init__.py Package init (empty)
|
||||||
main.py FastAPI app — lifespan, CORS, router registration
|
main.py FastAPI app — lifespan, CORS, router registration
|
||||||
db.py DuckDB singleton, schema migrations, write lock
|
db.py DuckDB singleton, schema migrations, write lock
|
||||||
dependencies.py Dependency injection (get_current_user, etc.)
|
dependencies.py Dependency injection (get_current_user, etc.)
|
||||||
models/ Pydantic + DB models
|
models/ Pydantic + DB models
|
||||||
__init__.py
|
__init__.py
|
||||||
auth.py Auth schemas (login, register, token)
|
auth.py Auth schemas (login, register, token)
|
||||||
users.py User schemas
|
users.py User schemas
|
||||||
ai.py AI generation request/response schemas
|
ai.py AI generation request/response schemas
|
||||||
routers/ API route handlers
|
routers/ API route handlers
|
||||||
__init__.py
|
__init__.py
|
||||||
auth.py POST /auth/login, /auth/register, /auth/logout
|
auth.py POST /auth/login, /auth/register, /auth/logout
|
||||||
users.py GET/PUT /users/me, admin user management
|
users.py GET/PUT /users/me, admin user management
|
||||||
admin.py GET /admin/stats, video job admin CRUD
|
admin.py GET /admin/stats, video job admin CRUD
|
||||||
ai.py GET /models
|
ai.py GET /models
|
||||||
generate.py POST /generate/text, /generate/image, /generate/video
|
generate.py POST /generate/text, /generate/image, /generate/video
|
||||||
images.py GET/POST /images, /images/<id>/file
|
images.py GET/POST /images, /images/<id>/file
|
||||||
models.py Model cache management
|
models.py Model cache management
|
||||||
services/ Business logic
|
services/ Business logic
|
||||||
__init__.py
|
__init__.py
|
||||||
auth.py Auth logic (password hashing, JWT)
|
auth.py Auth logic (password hashing, JWT)
|
||||||
users.py User CRUD
|
users.py User CRUD
|
||||||
openrouter.py OpenRouter API client
|
openrouter.py OpenRouter API client
|
||||||
models.py Model listing and cache
|
models.py Model listing and cache
|
||||||
video_worker.py Background video generation worker
|
video_worker.py Background video generation worker
|
||||||
tests/ pytest suite
|
tests/ pytest suite
|
||||||
Dockerfile Production container
|
Dockerfile Production container
|
||||||
requirements.txt Runtime dependencies
|
requirements.txt Runtime dependencies
|
||||||
requirements-dev.txt Dev dependencies
|
requirements-dev.txt Dev dependencies
|
||||||
```
|
```
|
||||||
|
|
||||||
## Running locally
|
## Running locally
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd backend
|
cd backend
|
||||||
uvicorn app.main:app --reload --port 12015
|
uvicorn app.main:app --reload --port 12015
|
||||||
```
|
```
|
||||||
|
|
||||||
API docs at [http://localhost:12015/docs](http://localhost:12015/docs).
|
API docs at [http://localhost:12015/docs](http://localhost:12015/docs).
|
||||||
|
|
||||||
## Running tests
|
## Running tests
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pytest backend/tests/
|
pytest backend/tests/
|
||||||
```
|
```
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
See [docs/ARCHITECTURE.md](../docs/ARCHITECTURE.md) for full documentation (arc42 template).
|
See [docs/ARCHITECTURE.md](../docs/ARCHITECTURE.md) for full documentation (arc42 template).
|
||||||
+149
-149
@@ -1,149 +1,149 @@
|
|||||||
"""DuckDB singleton connection with asyncio write lock and schema migrations."""
|
"""DuckDB singleton connection with asyncio write lock and schema migrations."""
|
||||||
import asyncio
|
import asyncio
|
||||||
import os
|
import os
|
||||||
import duckdb
|
import duckdb
|
||||||
|
|
||||||
_conn: duckdb.DuckDBPyConnection | None = None
|
_conn: duckdb.DuckDBPyConnection | None = None
|
||||||
_write_lock = asyncio.Lock()
|
_write_lock = asyncio.Lock()
|
||||||
|
|
||||||
|
|
||||||
def get_db_path() -> str:
|
def get_db_path() -> str:
|
||||||
return os.getenv("DB_PATH", "data/app.db")
|
return os.getenv("DB_PATH", "data/app.db")
|
||||||
|
|
||||||
|
|
||||||
def init_db(path: str | None = None) -> duckdb.DuckDBPyConnection:
|
def init_db(path: str | None = None) -> duckdb.DuckDBPyConnection:
|
||||||
"""Open (or reuse) the DuckDB connection and run schema migrations."""
|
"""Open (or reuse) the DuckDB connection and run schema migrations."""
|
||||||
global _conn
|
global _conn
|
||||||
if _conn is not None:
|
if _conn is not None:
|
||||||
return _conn
|
return _conn
|
||||||
db_path = path or get_db_path()
|
db_path = path or get_db_path()
|
||||||
if db_path != ":memory:":
|
if db_path != ":memory:":
|
||||||
os.makedirs(os.path.dirname(db_path), exist_ok=True)
|
os.makedirs(os.path.dirname(db_path), exist_ok=True)
|
||||||
_conn = duckdb.connect(db_path)
|
_conn = duckdb.connect(db_path)
|
||||||
_run_migrations(_conn)
|
_run_migrations(_conn)
|
||||||
return _conn
|
return _conn
|
||||||
|
|
||||||
|
|
||||||
def get_conn() -> duckdb.DuckDBPyConnection:
|
def get_conn() -> duckdb.DuckDBPyConnection:
|
||||||
"""Return the active connection; raises if not yet initialised."""
|
"""Return the active connection; raises if not yet initialised."""
|
||||||
if _conn is None:
|
if _conn is None:
|
||||||
raise RuntimeError("Database not initialised. Call init_db() first.")
|
raise RuntimeError("Database not initialised. Call init_db() first.")
|
||||||
return _conn
|
return _conn
|
||||||
|
|
||||||
|
|
||||||
def close_db() -> None:
|
def close_db() -> None:
|
||||||
"""Close the connection (called on app shutdown)."""
|
"""Close the connection (called on app shutdown)."""
|
||||||
global _conn
|
global _conn
|
||||||
if _conn is not None:
|
if _conn is not None:
|
||||||
_conn.close()
|
_conn.close()
|
||||||
_conn = None
|
_conn = None
|
||||||
|
|
||||||
|
|
||||||
def get_write_lock() -> asyncio.Lock:
|
def get_write_lock() -> asyncio.Lock:
|
||||||
"""Return the asyncio lock that serialises write operations."""
|
"""Return the asyncio lock that serialises write operations."""
|
||||||
return _write_lock
|
return _write_lock
|
||||||
|
|
||||||
|
|
||||||
def _run_migrations(conn: duckdb.DuckDBPyConnection) -> None:
|
def _run_migrations(conn: duckdb.DuckDBPyConnection) -> None:
|
||||||
conn.execute("""
|
conn.execute("""
|
||||||
CREATE TABLE IF NOT EXISTS users (
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
id UUID DEFAULT uuid() PRIMARY KEY,
|
id UUID DEFAULT uuid() PRIMARY KEY,
|
||||||
email VARCHAR NOT NULL UNIQUE,
|
email VARCHAR NOT NULL UNIQUE,
|
||||||
password_hash VARCHAR NOT NULL,
|
password_hash VARCHAR NOT NULL,
|
||||||
role VARCHAR DEFAULT 'user',
|
role VARCHAR DEFAULT 'user',
|
||||||
created_at TIMESTAMP DEFAULT now(),
|
created_at TIMESTAMP DEFAULT now(),
|
||||||
updated_at TIMESTAMP DEFAULT now()
|
updated_at TIMESTAMP DEFAULT now()
|
||||||
)
|
)
|
||||||
""")
|
""")
|
||||||
conn.execute("""
|
conn.execute("""
|
||||||
CREATE TABLE IF NOT EXISTS refresh_tokens (
|
CREATE TABLE IF NOT EXISTS refresh_tokens (
|
||||||
jti UUID DEFAULT uuid() PRIMARY KEY,
|
jti UUID DEFAULT uuid() PRIMARY KEY,
|
||||||
user_id UUID NOT NULL,
|
user_id UUID NOT NULL,
|
||||||
issued_at TIMESTAMP DEFAULT now(),
|
issued_at TIMESTAMP DEFAULT now(),
|
||||||
expires_at TIMESTAMP NOT NULL,
|
expires_at TIMESTAMP NOT NULL,
|
||||||
revoked BOOLEAN DEFAULT false
|
revoked BOOLEAN DEFAULT false
|
||||||
)
|
)
|
||||||
""")
|
""")
|
||||||
conn.execute("""
|
conn.execute("""
|
||||||
CREATE TABLE IF NOT EXISTS uploaded_images (
|
CREATE TABLE IF NOT EXISTS uploaded_images (
|
||||||
id UUID DEFAULT uuid() PRIMARY KEY,
|
id UUID DEFAULT uuid() PRIMARY KEY,
|
||||||
user_id UUID NOT NULL,
|
user_id UUID NOT NULL,
|
||||||
filename VARCHAR NOT NULL,
|
filename VARCHAR NOT NULL,
|
||||||
content_type VARCHAR NOT NULL,
|
content_type VARCHAR NOT NULL,
|
||||||
file_path VARCHAR NOT NULL,
|
file_path VARCHAR NOT NULL,
|
||||||
size_bytes BIGINT NOT NULL,
|
size_bytes BIGINT NOT NULL,
|
||||||
created_at TIMESTAMP DEFAULT now()
|
created_at TIMESTAMP DEFAULT now()
|
||||||
)
|
)
|
||||||
""")
|
""")
|
||||||
conn.execute("""
|
conn.execute("""
|
||||||
CREATE TABLE IF NOT EXISTS models_cache (
|
CREATE TABLE IF NOT EXISTS models_cache (
|
||||||
id UUID DEFAULT uuid() PRIMARY KEY,
|
id UUID DEFAULT uuid() PRIMARY KEY,
|
||||||
model_id VARCHAR NOT NULL UNIQUE,
|
model_id VARCHAR NOT NULL UNIQUE,
|
||||||
name VARCHAR NOT NULL,
|
name VARCHAR NOT NULL,
|
||||||
modality VARCHAR NOT NULL,
|
modality VARCHAR NOT NULL,
|
||||||
context_length BIGINT,
|
context_length BIGINT,
|
||||||
pricing JSON,
|
pricing JSON,
|
||||||
fetched_at TIMESTAMP NOT NULL
|
fetched_at TIMESTAMP NOT NULL
|
||||||
)
|
)
|
||||||
""")
|
""")
|
||||||
conn.execute("""
|
conn.execute("""
|
||||||
CREATE TABLE IF NOT EXISTS generated_images (
|
CREATE TABLE IF NOT EXISTS generated_images (
|
||||||
id UUID DEFAULT uuid() PRIMARY KEY,
|
id UUID DEFAULT uuid() PRIMARY KEY,
|
||||||
user_id UUID NOT NULL,
|
user_id UUID NOT NULL,
|
||||||
model_id VARCHAR NOT NULL,
|
model_id VARCHAR NOT NULL,
|
||||||
prompt VARCHAR NOT NULL,
|
prompt VARCHAR NOT NULL,
|
||||||
image_data VARCHAR NOT NULL,
|
image_data VARCHAR NOT NULL,
|
||||||
created_at TIMESTAMP DEFAULT now()
|
created_at TIMESTAMP DEFAULT now()
|
||||||
)
|
)
|
||||||
""")
|
""")
|
||||||
conn.execute("""
|
conn.execute("""
|
||||||
CREATE TABLE IF NOT EXISTS generated_videos (
|
CREATE TABLE IF NOT EXISTS generated_videos (
|
||||||
id UUID DEFAULT uuid() PRIMARY KEY,
|
id UUID DEFAULT uuid() PRIMARY KEY,
|
||||||
user_id UUID NOT NULL,
|
user_id UUID NOT NULL,
|
||||||
job_id VARCHAR NOT NULL,
|
job_id VARCHAR NOT NULL,
|
||||||
model_id VARCHAR NOT NULL,
|
model_id VARCHAR NOT NULL,
|
||||||
prompt VARCHAR NOT NULL,
|
prompt VARCHAR NOT NULL,
|
||||||
polling_url VARCHAR,
|
polling_url VARCHAR,
|
||||||
status VARCHAR NOT NULL DEFAULT 'pending',
|
status VARCHAR NOT NULL DEFAULT 'pending',
|
||||||
video_url VARCHAR,
|
video_url VARCHAR,
|
||||||
created_at TIMESTAMP DEFAULT now(),
|
created_at TIMESTAMP DEFAULT now(),
|
||||||
updated_at TIMESTAMP DEFAULT now()
|
updated_at TIMESTAMP DEFAULT now()
|
||||||
)
|
)
|
||||||
""")
|
""")
|
||||||
# Migration: add output_modalities column if absent (stores JSON array string)
|
# Migration: add output_modalities column if absent (stores JSON array string)
|
||||||
conn.execute("""
|
conn.execute("""
|
||||||
ALTER TABLE models_cache ADD COLUMN IF NOT EXISTS output_modalities VARCHAR
|
ALTER TABLE models_cache ADD COLUMN IF NOT EXISTS output_modalities VARCHAR
|
||||||
""")
|
""")
|
||||||
# Migration: add video job request params + generation type
|
# Migration: add video job request params + generation type
|
||||||
conn.execute("""
|
conn.execute("""
|
||||||
ALTER TABLE generated_videos ADD COLUMN IF NOT EXISTS request_params VARCHAR
|
ALTER TABLE generated_videos ADD COLUMN IF NOT EXISTS request_params VARCHAR
|
||||||
""")
|
""")
|
||||||
conn.execute("""
|
conn.execute("""
|
||||||
ALTER TABLE generated_videos ADD COLUMN IF NOT EXISTS generation_type VARCHAR DEFAULT 'text_to_video'
|
ALTER TABLE generated_videos ADD COLUMN IF NOT EXISTS generation_type VARCHAR DEFAULT 'text_to_video'
|
||||||
""")
|
""")
|
||||||
conn.execute("""
|
conn.execute("""
|
||||||
ALTER TABLE generated_videos ADD COLUMN IF NOT EXISTS error VARCHAR
|
ALTER TABLE generated_videos ADD COLUMN IF NOT EXISTS error VARCHAR
|
||||||
""")
|
""")
|
||||||
_seed_admin(conn)
|
_seed_admin(conn)
|
||||||
|
|
||||||
|
|
||||||
def _seed_admin(conn: duckdb.DuckDBPyConnection) -> None:
|
def _seed_admin(conn: duckdb.DuckDBPyConnection) -> None:
|
||||||
"""Insert the default admin user if it doesn't already exist."""
|
"""Insert the default admin user if it doesn't already exist."""
|
||||||
from passlib.context import CryptContext
|
from passlib.context import CryptContext
|
||||||
_pwd = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
_pwd = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||||
|
|
||||||
email = os.getenv("ADMIN_EMAIL", "ai@allucanget.biz")
|
email = os.getenv("ADMIN_EMAIL", "ai@allucanget.biz")
|
||||||
password = os.getenv("ADMIN_PASSWORD", "admin123")
|
password = os.getenv("ADMIN_PASSWORD", "admin123")
|
||||||
|
|
||||||
existing = conn.execute(
|
existing = conn.execute(
|
||||||
"SELECT id FROM users WHERE email = ?", [email]
|
"SELECT id FROM users WHERE email = ?", [email]
|
||||||
).fetchone()
|
).fetchone()
|
||||||
if existing is None:
|
if existing is None:
|
||||||
password_hash = _pwd.hash(password)
|
password_hash = _pwd.hash(password)
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"""
|
"""
|
||||||
INSERT INTO users (email, password_hash, role)
|
INSERT INTO users (email, password_hash, role)
|
||||||
VALUES (?, ?, 'admin')
|
VALUES (?, ?, 'admin')
|
||||||
""",
|
""",
|
||||||
[email, password_hash],
|
[email, password_hash],
|
||||||
)
|
)
|
||||||
+42
-42
@@ -1,42 +1,42 @@
|
|||||||
"""FastAPI dependencies (e.g. authenticated user extraction)."""
|
"""FastAPI dependencies (e.g. authenticated user extraction)."""
|
||||||
from fastapi import Depends, HTTPException, status
|
from fastapi import Depends, HTTPException, status
|
||||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||||
from jose import JWTError
|
from jose import JWTError
|
||||||
|
|
||||||
from .services.auth import decode_token
|
from .services.auth import decode_token
|
||||||
|
|
||||||
_bearer = HTTPBearer()
|
_bearer = HTTPBearer()
|
||||||
|
|
||||||
|
|
||||||
async def get_current_user(
|
async def get_current_user(
|
||||||
credentials: HTTPAuthorizationCredentials = Depends(_bearer),
|
credentials: HTTPAuthorizationCredentials = Depends(_bearer),
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Extract and validate the Bearer JWT. Returns the token payload."""
|
"""Extract and validate the Bearer JWT. Returns the token payload."""
|
||||||
credentials_error = HTTPException(
|
credentials_error = HTTPException(
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
detail="Could not validate credentials.",
|
detail="Could not validate credentials.",
|
||||||
headers={"WWW-Authenticate": "Bearer"},
|
headers={"WWW-Authenticate": "Bearer"},
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
payload = decode_token(credentials.credentials)
|
payload = decode_token(credentials.credentials)
|
||||||
except JWTError:
|
except JWTError:
|
||||||
raise credentials_error
|
raise credentials_error
|
||||||
|
|
||||||
if payload.get("type") != "access":
|
if payload.get("type") != "access":
|
||||||
raise credentials_error
|
raise credentials_error
|
||||||
|
|
||||||
user_id: str | None = payload.get("sub")
|
user_id: str | None = payload.get("sub")
|
||||||
if user_id is None:
|
if user_id is None:
|
||||||
raise credentials_error
|
raise credentials_error
|
||||||
|
|
||||||
return {"id": user_id, "email": payload.get("email"), "role": payload.get("role")}
|
return {"id": user_id, "email": payload.get("email"), "role": payload.get("role")}
|
||||||
|
|
||||||
|
|
||||||
async def require_admin(current_user: dict = Depends(get_current_user)) -> dict:
|
async def require_admin(current_user: dict = Depends(get_current_user)) -> dict:
|
||||||
"""Raise 403 if the authenticated user is not an admin."""
|
"""Raise 403 if the authenticated user is not an admin."""
|
||||||
if current_user.get("role") != "admin":
|
if current_user.get("role") != "admin":
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
detail="Admin access required.",
|
detail="Admin access required.",
|
||||||
)
|
)
|
||||||
return current_user
|
return current_user
|
||||||
+60
-60
@@ -1,60 +1,60 @@
|
|||||||
from .routers import auth
|
from .routers import auth
|
||||||
from .routers import users
|
from .routers import users
|
||||||
from .routers import admin
|
from .routers import admin
|
||||||
from .routers import ai
|
from .routers import ai
|
||||||
from .routers import generate
|
from .routers import generate
|
||||||
from .routers import images
|
from .routers import images
|
||||||
from .routers import models
|
from .routers import models
|
||||||
from .db import close_db, get_conn, get_write_lock, init_db
|
from .db import close_db, get_conn, get_write_lock, init_db
|
||||||
from .services.video_worker import run_worker
|
from .services.video_worker import run_worker
|
||||||
import asyncio
|
import asyncio
|
||||||
import os
|
import os
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
|
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
load_dotenv()
|
load_dotenv()
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def lifespan(app: FastAPI):
|
async def lifespan(app: FastAPI):
|
||||||
init_db()
|
init_db()
|
||||||
worker_task = asyncio.create_task(run_worker(get_conn(), get_write_lock()))
|
worker_task = asyncio.create_task(run_worker(get_conn(), get_write_lock()))
|
||||||
yield
|
yield
|
||||||
worker_task.cancel()
|
worker_task.cancel()
|
||||||
try:
|
try:
|
||||||
await worker_task
|
await worker_task
|
||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
pass
|
pass
|
||||||
close_db()
|
close_db()
|
||||||
|
|
||||||
|
|
||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
title="All You Can GET AI Biz API",
|
title="All You Can GET AI Biz API",
|
||||||
description="Multi-modal AI generation API powered by openrouter.ai",
|
description="Multi-modal AI generation API powered by openrouter.ai",
|
||||||
version="0.1.0",
|
version="0.1.0",
|
||||||
lifespan=lifespan,
|
lifespan=lifespan,
|
||||||
)
|
)
|
||||||
|
|
||||||
app.add_middleware(
|
app.add_middleware(
|
||||||
CORSMiddleware,
|
CORSMiddleware,
|
||||||
allow_origins=[os.getenv("CORS_ORIGINS", "http://localhost:12016")],
|
allow_origins=[os.getenv("CORS_ORIGINS", "http://localhost:12016")],
|
||||||
allow_credentials=True,
|
allow_credentials=True,
|
||||||
allow_methods=["*"],
|
allow_methods=["*"],
|
||||||
allow_headers=["*"],
|
allow_headers=["*"],
|
||||||
)
|
)
|
||||||
|
|
||||||
app.include_router(auth.router)
|
app.include_router(auth.router)
|
||||||
app.include_router(users.router)
|
app.include_router(users.router)
|
||||||
app.include_router(admin.router)
|
app.include_router(admin.router)
|
||||||
app.include_router(ai.router)
|
app.include_router(ai.router)
|
||||||
app.include_router(generate.router)
|
app.include_router(generate.router)
|
||||||
app.include_router(images.router)
|
app.include_router(images.router)
|
||||||
app.include_router(models.router)
|
app.include_router(models.router)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/health", tags=["health"])
|
@app.get("/health", tags=["health"])
|
||||||
async def health() -> dict:
|
async def health() -> dict:
|
||||||
return {"status": "ok"}
|
return {"status": "ok"}
|
||||||
+102
-102
@@ -1,102 +1,102 @@
|
|||||||
"""Pydantic schemas for AI generation endpoints."""
|
"""Pydantic schemas for AI generation endpoints."""
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
|
||||||
class ChatMessage(BaseModel):
|
class ChatMessage(BaseModel):
|
||||||
role: str # "user" | "assistant" | "system"
|
role: str # "user" | "assistant" | "system"
|
||||||
content: str
|
content: str
|
||||||
|
|
||||||
|
|
||||||
class ChatRequest(BaseModel):
|
class ChatRequest(BaseModel):
|
||||||
model: str
|
model: str
|
||||||
messages: list[ChatMessage]
|
messages: list[ChatMessage]
|
||||||
temperature: float = 0.7
|
temperature: float = 0.7
|
||||||
max_tokens: int = 1024
|
max_tokens: int = 1024
|
||||||
|
|
||||||
|
|
||||||
class ChatResponse(BaseModel):
|
class ChatResponse(BaseModel):
|
||||||
id: str
|
id: str
|
||||||
model: str
|
model: str
|
||||||
content: str
|
content: str
|
||||||
usage: dict[str, Any] | None = None
|
usage: dict[str, Any] | None = None
|
||||||
|
|
||||||
|
|
||||||
class ModelInfo(BaseModel):
|
class ModelInfo(BaseModel):
|
||||||
id: str
|
id: str
|
||||||
name: str
|
name: str
|
||||||
context_length: int | None = None
|
context_length: int | None = None
|
||||||
pricing: dict[str, Any] | None = None
|
pricing: dict[str, Any] | None = None
|
||||||
|
|
||||||
|
|
||||||
# --- Text generation ---
|
# --- Text generation ---
|
||||||
|
|
||||||
class TextRequest(BaseModel):
|
class TextRequest(BaseModel):
|
||||||
model: str
|
model: str
|
||||||
prompt: str = ""
|
prompt: str = ""
|
||||||
system_prompt: str | None = None
|
system_prompt: str | None = None
|
||||||
messages: list[ChatMessage] | None = None
|
messages: list[ChatMessage] | None = None
|
||||||
temperature: float = 0.7
|
temperature: float = 0.7
|
||||||
max_tokens: int = 1024
|
max_tokens: int = 1024
|
||||||
|
|
||||||
|
|
||||||
class TextResponse(BaseModel):
|
class TextResponse(BaseModel):
|
||||||
id: str
|
id: str
|
||||||
model: str
|
model: str
|
||||||
content: str
|
content: str
|
||||||
usage: dict[str, Any] | None = None
|
usage: dict[str, Any] | None = None
|
||||||
|
|
||||||
|
|
||||||
# --- Image generation ---
|
# --- Image generation ---
|
||||||
|
|
||||||
class ImageRequest(BaseModel):
|
class ImageRequest(BaseModel):
|
||||||
model: str
|
model: str
|
||||||
prompt: str
|
prompt: str
|
||||||
n: int = 1
|
n: int = 1
|
||||||
size: str = "1024x1024"
|
size: str = "1024x1024"
|
||||||
aspect_ratio: str | None = None # e.g. "1:1", "16:9", "9:16"
|
aspect_ratio: str | None = None # e.g. "1:1", "16:9", "9:16"
|
||||||
image_size: str | None = None # e.g. "0.5K", "1K", "2K", "4K"
|
image_size: str | None = None # e.g. "0.5K", "1K", "2K", "4K"
|
||||||
|
|
||||||
|
|
||||||
class ImageResult(BaseModel):
|
class ImageResult(BaseModel):
|
||||||
url: str | None = None
|
url: str | None = None
|
||||||
b64_json: str | None = None
|
b64_json: str | None = None
|
||||||
revised_prompt: str | None = None
|
revised_prompt: str | None = None
|
||||||
image_id: str | None = None # UUID of stored row in generated_images
|
image_id: str | None = None # UUID of stored row in generated_images
|
||||||
|
|
||||||
|
|
||||||
class ImageResponse(BaseModel):
|
class ImageResponse(BaseModel):
|
||||||
id: str
|
id: str
|
||||||
model: str
|
model: str
|
||||||
images: list[ImageResult]
|
images: list[ImageResult]
|
||||||
|
|
||||||
|
|
||||||
# --- Video generation ---
|
# --- Video generation ---
|
||||||
|
|
||||||
class VideoRequest(BaseModel):
|
class VideoRequest(BaseModel):
|
||||||
model: str
|
model: str
|
||||||
prompt: str
|
prompt: str
|
||||||
duration_seconds: int | None = None
|
duration_seconds: int | None = None
|
||||||
aspect_ratio: str = "16:9"
|
aspect_ratio: str = "16:9"
|
||||||
resolution: str | None = None # e.g. "480p", "720p", "1080p"
|
resolution: str | None = None # e.g. "480p", "720p", "1080p"
|
||||||
|
|
||||||
|
|
||||||
class VideoFromImageRequest(BaseModel):
|
class VideoFromImageRequest(BaseModel):
|
||||||
model: str
|
model: str
|
||||||
image_url: str
|
image_url: str
|
||||||
prompt: str
|
prompt: str
|
||||||
duration_seconds: int | None = None
|
duration_seconds: int | None = None
|
||||||
aspect_ratio: str = "16:9"
|
aspect_ratio: str = "16:9"
|
||||||
resolution: str | None = None # e.g. "480p", "720p", "1080p"
|
resolution: str | None = None # e.g. "480p", "720p", "1080p"
|
||||||
|
|
||||||
|
|
||||||
class VideoResponse(BaseModel):
|
class VideoResponse(BaseModel):
|
||||||
id: str # This is the job_id from the provider
|
id: str # This is the job_id from the provider
|
||||||
db_id: str | None = None # This is the UUID from our generated_videos table
|
db_id: str | None = None # This is the UUID from our generated_videos table
|
||||||
model: str
|
model: str
|
||||||
status: str # "queued" | "processing" | "completed" | "failed"
|
status: str # "queued" | "processing" | "completed" | "failed"
|
||||||
polling_url: str | None = None
|
polling_url: str | None = None
|
||||||
video_urls: list[str] | None = None
|
video_urls: list[str] | None = None
|
||||||
video_url: str | None = None # first entry of video_urls for convenience
|
video_url: str | None = None # first entry of video_urls for convenience
|
||||||
error: str | None = None
|
error: str | None = None
|
||||||
metadata: dict[str, Any] | None = None
|
metadata: dict[str, Any] | None = None
|
||||||
+22
-22
@@ -1,22 +1,22 @@
|
|||||||
"""Pydantic schemas for authentication endpoints."""
|
"""Pydantic schemas for authentication endpoints."""
|
||||||
from pydantic import BaseModel, EmailStr
|
from pydantic import BaseModel, EmailStr
|
||||||
|
|
||||||
|
|
||||||
class RegisterRequest(BaseModel):
|
class RegisterRequest(BaseModel):
|
||||||
email: EmailStr
|
email: EmailStr
|
||||||
password: str
|
password: str
|
||||||
|
|
||||||
|
|
||||||
class LoginRequest(BaseModel):
|
class LoginRequest(BaseModel):
|
||||||
email: EmailStr
|
email: EmailStr
|
||||||
password: str
|
password: str
|
||||||
|
|
||||||
|
|
||||||
class TokenResponse(BaseModel):
|
class TokenResponse(BaseModel):
|
||||||
access_token: str
|
access_token: str
|
||||||
refresh_token: str
|
refresh_token: str
|
||||||
token_type: str = "bearer"
|
token_type: str = "bearer"
|
||||||
|
|
||||||
|
|
||||||
class RefreshRequest(BaseModel):
|
class RefreshRequest(BaseModel):
|
||||||
refresh_token: str
|
refresh_token: str
|
||||||
+17
-17
@@ -1,17 +1,17 @@
|
|||||||
"""Pydantic schemas for user management endpoints."""
|
"""Pydantic schemas for user management endpoints."""
|
||||||
from pydantic import BaseModel, EmailStr
|
from pydantic import BaseModel, EmailStr
|
||||||
|
|
||||||
|
|
||||||
class UserResponse(BaseModel):
|
class UserResponse(BaseModel):
|
||||||
id: str
|
id: str
|
||||||
email: str
|
email: str
|
||||||
role: str
|
role: str
|
||||||
|
|
||||||
|
|
||||||
class UpdateUserRequest(BaseModel):
|
class UpdateUserRequest(BaseModel):
|
||||||
email: EmailStr | None = None
|
email: EmailStr | None = None
|
||||||
password: str | None = None
|
password: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class SetRoleRequest(BaseModel):
|
class SetRoleRequest(BaseModel):
|
||||||
role: str
|
role: str
|
||||||
+227
-227
@@ -1,227 +1,227 @@
|
|||||||
"""Admin router: operational endpoints for application management."""
|
"""Admin router: operational endpoints for application management."""
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends
|
from fastapi import APIRouter, Depends
|
||||||
|
|
||||||
from ..db import get_conn, get_write_lock
|
from ..db import get_conn, get_write_lock
|
||||||
from ..dependencies import require_admin
|
from ..dependencies import require_admin
|
||||||
from ..services import models as models_service
|
from ..services import models as models_service
|
||||||
from ..services.models import mark_timed_out_video_jobs
|
from ..services.models import mark_timed_out_video_jobs
|
||||||
|
|
||||||
router = APIRouter(prefix="/admin", tags=["admin"])
|
router = APIRouter(prefix="/admin", tags=["admin"])
|
||||||
|
|
||||||
|
|
||||||
@router.get("/stats")
|
@router.get("/stats")
|
||||||
async def get_stats(_: dict = Depends(require_admin)) -> dict:
|
async def get_stats(_: dict = Depends(require_admin)) -> dict:
|
||||||
"""Return aggregate statistics: user counts and token counts."""
|
"""Return aggregate statistics: user counts and token counts."""
|
||||||
conn = get_conn()
|
conn = get_conn()
|
||||||
sql_user_count = "SELECT COUNT(*) FROM users"
|
sql_user_count = "SELECT COUNT(*) FROM users"
|
||||||
sql_user_counts = "SELECT role, COUNT(*) FROM users GROUP BY role ORDER BY role"
|
sql_user_counts = "SELECT role, COUNT(*) FROM users GROUP BY role ORDER BY role"
|
||||||
sql_token_count = "SELECT COUNT(*) FROM refresh_tokens"
|
sql_token_count = "SELECT COUNT(*) FROM refresh_tokens"
|
||||||
sql_tokens_active = "SELECT COUNT(*) FROM refresh_tokens WHERE revoked = false AND expires_at > ?"
|
sql_tokens_active = "SELECT COUNT(*) FROM refresh_tokens WHERE revoked = false AND expires_at > ?"
|
||||||
now = datetime.now(timezone.utc)
|
now = datetime.now(timezone.utc)
|
||||||
|
|
||||||
total_users_row = conn.execute(sql_user_count).fetchone()
|
total_users_row = conn.execute(sql_user_count).fetchone()
|
||||||
total_users = total_users_row[0] if total_users_row else 0
|
total_users = total_users_row[0] if total_users_row else 0
|
||||||
|
|
||||||
users_by_role = conn.execute(sql_user_counts).fetchall()
|
users_by_role = conn.execute(sql_user_counts).fetchall()
|
||||||
|
|
||||||
total_tokens_row = conn.execute(sql_token_count).fetchone()
|
total_tokens_row = conn.execute(sql_token_count).fetchone()
|
||||||
total_tokens = total_tokens_row[0] if total_tokens_row else 0
|
total_tokens = total_tokens_row[0] if total_tokens_row else 0
|
||||||
|
|
||||||
active_tokens_row = conn.execute(sql_tokens_active, [now]).fetchone()
|
active_tokens_row = conn.execute(sql_tokens_active, [now]).fetchone()
|
||||||
active_tokens = active_tokens_row[0] if active_tokens_row else 0
|
active_tokens = active_tokens_row[0] if active_tokens_row else 0
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"users": {
|
"users": {
|
||||||
"total": total_users,
|
"total": total_users,
|
||||||
"by_role": {row[0]: row[1] for row in users_by_role},
|
"by_role": {row[0]: row[1] for row in users_by_role},
|
||||||
},
|
},
|
||||||
"refresh_tokens": {
|
"refresh_tokens": {
|
||||||
"total": total_tokens,
|
"total": total_tokens,
|
||||||
"active": active_tokens,
|
"active": active_tokens,
|
||||||
"revoked_or_expired": total_tokens - active_tokens,
|
"revoked_or_expired": total_tokens - active_tokens,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/health/db")
|
@router.get("/health/db")
|
||||||
async def db_health(_: dict = Depends(require_admin)) -> dict:
|
async def db_health(_: dict = Depends(require_admin)) -> dict:
|
||||||
"""Verify DuckDB is reachable."""
|
"""Verify DuckDB is reachable."""
|
||||||
conn = get_conn()
|
conn = get_conn()
|
||||||
result_row = conn.execute("SELECT 1").fetchone()
|
result_row = conn.execute("SELECT 1").fetchone()
|
||||||
result = result_row[0] if result_row else 0
|
result = result_row[0] if result_row else 0
|
||||||
return {"status": "ok" if result == 1 else "error"}
|
return {"status": "ok" if result == 1 else "error"}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/tokens/purge", status_code=200)
|
@router.post("/tokens/purge", status_code=200)
|
||||||
async def purge_tokens(_: dict = Depends(require_admin)) -> dict:
|
async def purge_tokens(_: dict = Depends(require_admin)) -> dict:
|
||||||
"""Delete all expired or revoked refresh tokens. Returns count removed."""
|
"""Delete all expired or revoked refresh tokens. Returns count removed."""
|
||||||
conn = get_conn()
|
conn = get_conn()
|
||||||
lock = get_write_lock()
|
lock = get_write_lock()
|
||||||
now = datetime.now(timezone.utc)
|
now = datetime.now(timezone.utc)
|
||||||
sql_count = "SELECT COUNT(*) FROM refresh_tokens"
|
sql_count = "SELECT COUNT(*) FROM refresh_tokens"
|
||||||
sql_delete = "DELETE FROM refresh_tokens WHERE revoked = true OR expires_at <= ?"
|
sql_delete = "DELETE FROM refresh_tokens WHERE revoked = true OR expires_at <= ?"
|
||||||
async with lock:
|
async with lock:
|
||||||
before_row = conn.execute(sql_count).fetchone()
|
before_row = conn.execute(sql_count).fetchone()
|
||||||
before = before_row[0] if before_row else 0
|
before = before_row[0] if before_row else 0
|
||||||
|
|
||||||
conn.execute(sql_delete, [now])
|
conn.execute(sql_delete, [now])
|
||||||
|
|
||||||
after_row = conn.execute(sql_count).fetchone()
|
after_row = conn.execute(sql_count).fetchone()
|
||||||
after = after_row[0] if after_row else 0
|
after = after_row[0] if after_row else 0
|
||||||
|
|
||||||
return {"deleted": before - after, "remaining": after}
|
return {"deleted": before - after, "remaining": after}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/models/status")
|
@router.get("/models/status")
|
||||||
async def get_model_status(_: dict = Depends(require_admin)) -> dict[str, Any]:
|
async def get_model_status(_: dict = Depends(require_admin)) -> dict[str, Any]:
|
||||||
"""Return model cache status: last update time and model count."""
|
"""Return model cache status: last update time and model count."""
|
||||||
conn = get_conn()
|
conn = get_conn()
|
||||||
return models_service.get_cache_status(conn)
|
return models_service.get_cache_status(conn)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/models")
|
@router.get("/models")
|
||||||
async def get_all_models(_: dict = Depends(require_admin)) -> list[dict[str, Any]]:
|
async def get_all_models(_: dict = Depends(require_admin)) -> list[dict[str, Any]]:
|
||||||
"""Return all cached models."""
|
"""Return all cached models."""
|
||||||
conn = get_conn()
|
conn = get_conn()
|
||||||
return models_service.get_cached_models(conn)
|
return models_service.get_cached_models(conn)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/models/refresh", status_code=200)
|
@router.post("/models/refresh", status_code=200)
|
||||||
async def refresh_models(
|
async def refresh_models(
|
||||||
_: dict = Depends(require_admin),
|
_: dict = Depends(require_admin),
|
||||||
) -> dict[str, str | int | None]:
|
) -> dict[str, str | int | None]:
|
||||||
"""Force a refresh of the model cache from OpenRouter."""
|
"""Force a refresh of the model cache from OpenRouter."""
|
||||||
conn = get_conn()
|
conn = get_conn()
|
||||||
lock = get_write_lock()
|
lock = get_write_lock()
|
||||||
async with lock:
|
async with lock:
|
||||||
count = await models_service.refresh_models_cache(conn)
|
count = await models_service.refresh_models_cache(conn)
|
||||||
status = models_service.get_cache_status(conn)
|
status = models_service.get_cache_status(conn)
|
||||||
return {
|
return {
|
||||||
"status": "ok",
|
"status": "ok",
|
||||||
"refreshed": count,
|
"refreshed": count,
|
||||||
"total_models": status.get("model_count"),
|
"total_models": status.get("model_count"),
|
||||||
"last_updated": status.get("last_updated"),
|
"last_updated": status.get("last_updated"),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/videos")
|
@router.get("/videos")
|
||||||
async def admin_list_video_jobs(_: dict = Depends(require_admin)) -> list[dict[str, Any]]:
|
async def admin_list_video_jobs(_: dict = Depends(require_admin)) -> list[dict[str, Any]]:
|
||||||
"""Return all video generation jobs across all users."""
|
"""Return all video generation jobs across all users."""
|
||||||
conn = get_conn()
|
conn = get_conn()
|
||||||
rows = conn.execute(
|
rows = conn.execute(
|
||||||
"""
|
"""
|
||||||
SELECT
|
SELECT
|
||||||
v.id, v.job_id, v.user_id, u.email, v.model_id, v.prompt,
|
v.id, v.job_id, v.user_id, u.email, v.model_id, v.prompt,
|
||||||
v.status, v.video_url, v.created_at, v.updated_at
|
v.status, v.video_url, v.created_at, v.updated_at
|
||||||
FROM generated_videos v
|
FROM generated_videos v
|
||||||
LEFT JOIN users u ON v.user_id = u.id
|
LEFT JOIN users u ON v.user_id = u.id
|
||||||
ORDER BY v.created_at DESC
|
ORDER BY v.created_at DESC
|
||||||
"""
|
"""
|
||||||
).fetchall()
|
).fetchall()
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
"id": str(row[0]),
|
"id": str(row[0]),
|
||||||
"job_id": row[1],
|
"job_id": row[1],
|
||||||
"user_id": str(row[2]),
|
"user_id": str(row[2]),
|
||||||
"user_email": row[3],
|
"user_email": row[3],
|
||||||
"model_id": row[4],
|
"model_id": row[4],
|
||||||
"prompt": row[5],
|
"prompt": row[5],
|
||||||
"status": row[6],
|
"status": row[6],
|
||||||
"video_url": row[7],
|
"video_url": row[7],
|
||||||
"created_at": row[8].isoformat() if row[8] else None,
|
"created_at": row[8].isoformat() if row[8] else None,
|
||||||
"updated_at": row[9].isoformat() if row[9] else None,
|
"updated_at": row[9].isoformat() if row[9] else None,
|
||||||
}
|
}
|
||||||
for row in rows
|
for row in rows
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@router.post("/videos/{job_id}/cancel", status_code=200)
|
@router.post("/videos/{job_id}/cancel", status_code=200)
|
||||||
async def admin_cancel_video_job(job_id: str, _: dict = Depends(require_admin)) -> dict[str, str]:
|
async def admin_cancel_video_job(job_id: str, _: dict = Depends(require_admin)) -> dict[str, str]:
|
||||||
"""Mark a video job as 'cancelled'. Does not stop the provider job."""
|
"""Mark a video job as 'cancelled'. Does not stop the provider job."""
|
||||||
conn = get_conn()
|
conn = get_conn()
|
||||||
lock = get_write_lock()
|
lock = get_write_lock()
|
||||||
now = datetime.now(timezone.utc)
|
now = datetime.now(timezone.utc)
|
||||||
async with lock:
|
async with lock:
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"UPDATE generated_videos SET status = 'cancelled', updated_at = ? WHERE id = ?", [
|
"UPDATE generated_videos SET status = 'cancelled', updated_at = ? WHERE id = ?", [
|
||||||
now, job_id]
|
now, job_id]
|
||||||
)
|
)
|
||||||
return {"status": "ok", "job_id": job_id}
|
return {"status": "ok", "job_id": job_id}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/videos/purge", status_code=200)
|
@router.post("/videos/purge", status_code=200)
|
||||||
async def admin_purge_video_jobs(_: dict = Depends(require_admin)) -> dict[str, Any]:
|
async def admin_purge_video_jobs(_: dict = Depends(require_admin)) -> dict[str, Any]:
|
||||||
"""Delete all completed, failed, or cancelled jobs older than 30 days."""
|
"""Delete all completed, failed, or cancelled jobs older than 30 days."""
|
||||||
conn = get_conn()
|
conn = get_conn()
|
||||||
lock = get_write_lock()
|
lock = get_write_lock()
|
||||||
thirty_days_ago = datetime.now(
|
thirty_days_ago = datetime.now(
|
||||||
timezone.utc) - timedelta(days=30)
|
timezone.utc) - timedelta(days=30)
|
||||||
|
|
||||||
sql_count = "SELECT COUNT(*) FROM generated_videos"
|
sql_count = "SELECT COUNT(*) FROM generated_videos"
|
||||||
sql_delete = """
|
sql_delete = """
|
||||||
DELETE FROM generated_videos
|
DELETE FROM generated_videos
|
||||||
WHERE status IN ('completed', 'failed', 'cancelled')
|
WHERE status IN ('completed', 'failed', 'cancelled')
|
||||||
AND updated_at < ?
|
AND updated_at < ?
|
||||||
"""
|
"""
|
||||||
|
|
||||||
async with lock:
|
async with lock:
|
||||||
before_row = conn.execute(sql_count).fetchone()
|
before_row = conn.execute(sql_count).fetchone()
|
||||||
before = before_row[0] if before_row else 0
|
before = before_row[0] if before_row else 0
|
||||||
|
|
||||||
conn.execute(sql_delete, [thirty_days_ago])
|
conn.execute(sql_delete, [thirty_days_ago])
|
||||||
|
|
||||||
after_row = conn.execute(sql_count).fetchone()
|
after_row = conn.execute(sql_count).fetchone()
|
||||||
after = after_row[0] if after_row else 0
|
after = after_row[0] if after_row else 0
|
||||||
|
|
||||||
return {"deleted": before - after, "remaining": after}
|
return {"deleted": before - after, "remaining": after}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/videos/timed-out", status_code=200)
|
@router.post("/videos/timed-out", status_code=200)
|
||||||
async def admin_mark_timed_out(_: dict = Depends(require_admin)) -> dict[str, int]:
|
async def admin_mark_timed_out(_: dict = Depends(require_admin)) -> dict[str, int]:
|
||||||
"""Mark video jobs that have been in 'queued' or 'processing' status for too long as 'failed'."""
|
"""Mark video jobs that have been in 'queued' or 'processing' status for too long as 'failed'."""
|
||||||
conn = get_conn()
|
conn = get_conn()
|
||||||
count = mark_timed_out_video_jobs(conn, timeout_minutes=120)
|
count = mark_timed_out_video_jobs(conn, timeout_minutes=120)
|
||||||
return {"timed_out": count}
|
return {"timed_out": count}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/videos/{job_id}/retry", status_code=200)
|
@router.post("/videos/{job_id}/retry", status_code=200)
|
||||||
async def admin_retry_video_job(job_id: str, _: dict = Depends(require_admin)) -> dict[str, str]:
|
async def admin_retry_video_job(job_id: str, _: dict = Depends(require_admin)) -> dict[str, str]:
|
||||||
"""Reset a failed or cancelled video job back to 'queued' for reprocessing."""
|
"""Reset a failed or cancelled video job back to 'queued' for reprocessing."""
|
||||||
conn = get_conn()
|
conn = get_conn()
|
||||||
lock = get_write_lock()
|
lock = get_write_lock()
|
||||||
now = datetime.now(timezone.utc)
|
now = datetime.now(timezone.utc)
|
||||||
async with lock:
|
async with lock:
|
||||||
row = conn.execute(
|
row = conn.execute(
|
||||||
"SELECT status FROM generated_videos WHERE id = ?", [job_id]
|
"SELECT status FROM generated_videos WHERE id = ?", [job_id]
|
||||||
).fetchone()
|
).fetchone()
|
||||||
if row is None:
|
if row is None:
|
||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
raise HTTPException(status_code=404, detail="Job not found")
|
raise HTTPException(status_code=404, detail="Job not found")
|
||||||
if row[0] not in ("failed", "cancelled"):
|
if row[0] not in ("failed", "cancelled"):
|
||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=400, detail=f"Cannot retry job with status '{row[0]}'")
|
status_code=400, detail=f"Cannot retry job with status '{row[0]}'")
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"UPDATE generated_videos SET status = 'queued', updated_at = ? WHERE id = ?",
|
"UPDATE generated_videos SET status = 'queued', updated_at = ? WHERE id = ?",
|
||||||
[now, job_id],
|
[now, job_id],
|
||||||
)
|
)
|
||||||
return {"status": "ok", "job_id": job_id}
|
return {"status": "ok", "job_id": job_id}
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/videos/{job_id}", status_code=200)
|
@router.delete("/videos/{job_id}", status_code=200)
|
||||||
async def admin_delete_video_job(job_id: str, _: dict = Depends(require_admin)) -> dict[str, str]:
|
async def admin_delete_video_job(job_id: str, _: dict = Depends(require_admin)) -> dict[str, str]:
|
||||||
"""Permanently delete a video job record."""
|
"""Permanently delete a video job record."""
|
||||||
conn = get_conn()
|
conn = get_conn()
|
||||||
lock = get_write_lock()
|
lock = get_write_lock()
|
||||||
async with lock:
|
async with lock:
|
||||||
row = conn.execute(
|
row = conn.execute(
|
||||||
"SELECT id FROM generated_videos WHERE id = ?", [job_id]
|
"SELECT id FROM generated_videos WHERE id = ?", [job_id]
|
||||||
).fetchone()
|
).fetchone()
|
||||||
if row is None:
|
if row is None:
|
||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
raise HTTPException(status_code=404, detail="Job not found")
|
raise HTTPException(status_code=404, detail="Job not found")
|
||||||
conn.execute("DELETE FROM generated_videos WHERE id = ?", [job_id])
|
conn.execute("DELETE FROM generated_videos WHERE id = ?", [job_id])
|
||||||
return {"status": "ok", "job_id": job_id}
|
return {"status": "ok", "job_id": job_id}
|
||||||
+63
-63
@@ -1,63 +1,63 @@
|
|||||||
"""AI router: model listing and chat completions via OpenRouter."""
|
"""AI router: model listing and chat completions via OpenRouter."""
|
||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
|
||||||
from ..dependencies import get_current_user
|
from ..dependencies import get_current_user
|
||||||
from ..models.ai import ChatRequest, ChatResponse, ModelInfo
|
from ..models.ai import ChatRequest, ChatResponse, ModelInfo
|
||||||
from ..services import openrouter
|
from ..services import openrouter
|
||||||
|
|
||||||
router = APIRouter(prefix="/ai", tags=["ai"])
|
router = APIRouter(prefix="/ai", tags=["ai"])
|
||||||
|
|
||||||
|
|
||||||
@router.get("/models", response_model=list[ModelInfo])
|
@router.get("/models", response_model=list[ModelInfo])
|
||||||
async def get_models(_: dict = Depends(get_current_user)) -> list[ModelInfo]:
|
async def get_models(_: dict = Depends(get_current_user)) -> list[ModelInfo]:
|
||||||
"""List available AI models from OpenRouter."""
|
"""List available AI models from OpenRouter."""
|
||||||
try:
|
try:
|
||||||
raw = await openrouter.list_models()
|
raw = await openrouter.list_models()
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||||
detail=f"OpenRouter error: {exc}",
|
detail=f"OpenRouter error: {exc}",
|
||||||
)
|
)
|
||||||
return [
|
return [
|
||||||
ModelInfo(
|
ModelInfo(
|
||||||
id=m.get("id", ""),
|
id=m.get("id", ""),
|
||||||
name=m.get("name", m.get("id", "")),
|
name=m.get("name", m.get("id", "")),
|
||||||
context_length=m.get("context_length"),
|
context_length=m.get("context_length"),
|
||||||
pricing=m.get("pricing"),
|
pricing=m.get("pricing"),
|
||||||
)
|
)
|
||||||
for m in raw
|
for m in raw
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@router.post("/chat", response_model=ChatResponse)
|
@router.post("/chat", response_model=ChatResponse)
|
||||||
async def chat(
|
async def chat(
|
||||||
body: ChatRequest,
|
body: ChatRequest,
|
||||||
_: dict = Depends(get_current_user),
|
_: dict = Depends(get_current_user),
|
||||||
) -> ChatResponse:
|
) -> ChatResponse:
|
||||||
"""Send a chat completion request through OpenRouter."""
|
"""Send a chat completion request through OpenRouter."""
|
||||||
try:
|
try:
|
||||||
result = await openrouter.chat_completion(
|
result = await openrouter.chat_completion(
|
||||||
model=body.model,
|
model=body.model,
|
||||||
messages=[m.model_dump() for m in body.messages],
|
messages=[m.model_dump() for m in body.messages],
|
||||||
temperature=body.temperature,
|
temperature=body.temperature,
|
||||||
max_tokens=body.max_tokens,
|
max_tokens=body.max_tokens,
|
||||||
)
|
)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||||
detail=f"OpenRouter error: {exc}",
|
detail=f"OpenRouter error: {exc}",
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
choice = result["choices"][0]
|
choice = result["choices"][0]
|
||||||
return ChatResponse(
|
return ChatResponse(
|
||||||
id=result["id"],
|
id=result["id"],
|
||||||
model=result.get("model", body.model),
|
model=result.get("model", body.model),
|
||||||
content=choice["message"]["content"],
|
content=choice["message"]["content"],
|
||||||
usage=result.get("usage"),
|
usage=result.get("usage"),
|
||||||
)
|
)
|
||||||
except (KeyError, IndexError) as exc:
|
except (KeyError, IndexError) as exc:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||||
detail=f"Unexpected response format from OpenRouter: {exc}",
|
detail=f"Unexpected response format from OpenRouter: {exc}",
|
||||||
)
|
)
|
||||||
+108
-108
@@ -1,108 +1,108 @@
|
|||||||
"""Auth router: register, login, refresh, logout."""
|
"""Auth router: register, login, refresh, logout."""
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException, status
|
from fastapi import APIRouter, HTTPException, status
|
||||||
from jose import JWTError
|
from jose import JWTError
|
||||||
|
|
||||||
from fastapi import Depends
|
from fastapi import Depends
|
||||||
|
|
||||||
from ..dependencies import get_current_user
|
from ..dependencies import get_current_user
|
||||||
from ..models.auth import LoginRequest, RefreshRequest, RegisterRequest, TokenResponse
|
from ..models.auth import LoginRequest, RefreshRequest, RegisterRequest, TokenResponse
|
||||||
from ..services.auth import (
|
from ..services.auth import (
|
||||||
authenticate_user,
|
authenticate_user,
|
||||||
create_access_token,
|
create_access_token,
|
||||||
create_refresh_token,
|
create_refresh_token,
|
||||||
decode_token,
|
decode_token,
|
||||||
register_user,
|
register_user,
|
||||||
revoke_refresh_token,
|
revoke_refresh_token,
|
||||||
store_refresh_token,
|
store_refresh_token,
|
||||||
validate_refresh_token_jti,
|
validate_refresh_token_jti,
|
||||||
)
|
)
|
||||||
|
|
||||||
router = APIRouter(prefix="/auth", tags=["auth"])
|
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||||
|
|
||||||
|
|
||||||
@router.post("/register", status_code=status.HTTP_201_CREATED)
|
@router.post("/register", status_code=status.HTTP_201_CREATED)
|
||||||
async def register(body: RegisterRequest) -> dict:
|
async def register(body: RegisterRequest) -> dict:
|
||||||
try:
|
try:
|
||||||
user = await register_user(body.email, body.password)
|
user = await register_user(body.email, body.password)
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_409_CONFLICT, detail=str(exc))
|
status_code=status.HTTP_409_CONFLICT, detail=str(exc))
|
||||||
return {"id": user["id"], "email": user["email"], "role": user["role"]}
|
return {"id": user["id"], "email": user["email"], "role": user["role"]}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/login", response_model=TokenResponse)
|
@router.post("/login", response_model=TokenResponse)
|
||||||
async def login(body: LoginRequest) -> TokenResponse:
|
async def login(body: LoginRequest) -> TokenResponse:
|
||||||
user = await authenticate_user(body.email, body.password)
|
user = await authenticate_user(body.email, body.password)
|
||||||
if user is None:
|
if user is None:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
detail="Invalid credentials.",
|
detail="Invalid credentials.",
|
||||||
headers={"WWW-Authenticate": "Bearer"},
|
headers={"WWW-Authenticate": "Bearer"},
|
||||||
)
|
)
|
||||||
jti = str(uuid.uuid4())
|
jti = str(uuid.uuid4())
|
||||||
await store_refresh_token(user["id"], jti)
|
await store_refresh_token(user["id"], jti)
|
||||||
return TokenResponse(
|
return TokenResponse(
|
||||||
access_token=create_access_token(
|
access_token=create_access_token(
|
||||||
user["id"], user["email"], user["role"]),
|
user["id"], user["email"], user["role"]),
|
||||||
refresh_token=create_refresh_token(user["id"], jti),
|
refresh_token=create_refresh_token(user["id"], jti),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/refresh", response_model=TokenResponse)
|
@router.post("/refresh", response_model=TokenResponse)
|
||||||
async def refresh(body: RefreshRequest) -> TokenResponse:
|
async def refresh(body: RefreshRequest) -> TokenResponse:
|
||||||
credentials_error = HTTPException(
|
credentials_error = HTTPException(
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
detail="Invalid or expired refresh token.",
|
detail="Invalid or expired refresh token.",
|
||||||
headers={"WWW-Authenticate": "Bearer"},
|
headers={"WWW-Authenticate": "Bearer"},
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
payload = decode_token(body.refresh_token)
|
payload = decode_token(body.refresh_token)
|
||||||
except JWTError:
|
except JWTError:
|
||||||
raise credentials_error
|
raise credentials_error
|
||||||
|
|
||||||
if payload.get("type") != "refresh":
|
if payload.get("type") != "refresh":
|
||||||
raise credentials_error
|
raise credentials_error
|
||||||
|
|
||||||
user_id: str = payload.get("sub", "")
|
user_id: str = payload.get("sub", "")
|
||||||
jti: str = payload.get("jti", "")
|
jti: str = payload.get("jti", "")
|
||||||
|
|
||||||
if not await validate_refresh_token_jti(jti, user_id):
|
if not await validate_refresh_token_jti(jti, user_id):
|
||||||
raise credentials_error
|
raise credentials_error
|
||||||
|
|
||||||
# Rotate: revoke old JTI, issue new pair
|
# Rotate: revoke old JTI, issue new pair
|
||||||
await revoke_refresh_token(jti)
|
await revoke_refresh_token(jti)
|
||||||
new_jti = str(uuid.uuid4())
|
new_jti = str(uuid.uuid4())
|
||||||
await store_refresh_token(user_id, new_jti)
|
await store_refresh_token(user_id, new_jti)
|
||||||
|
|
||||||
from ..db import get_conn
|
from ..db import get_conn
|
||||||
conn = get_conn()
|
conn = get_conn()
|
||||||
sql_fetch = "SELECT email, role FROM users WHERE id = ?"
|
sql_fetch = "SELECT email, role FROM users WHERE id = ?"
|
||||||
row = conn.execute(sql_fetch, [user_id]).fetchone()
|
row = conn.execute(sql_fetch, [user_id]).fetchone()
|
||||||
if row is None:
|
if row is None:
|
||||||
raise credentials_error
|
raise credentials_error
|
||||||
|
|
||||||
return TokenResponse(
|
return TokenResponse(
|
||||||
access_token=create_access_token(user_id, row[0], row[1]),
|
access_token=create_access_token(user_id, row[0], row[1]),
|
||||||
refresh_token=create_refresh_token(user_id, new_jti),
|
refresh_token=create_refresh_token(user_id, new_jti),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/logout", status_code=status.HTTP_204_NO_CONTENT)
|
@router.post("/logout", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
async def logout(body: RefreshRequest) -> None:
|
async def logout(body: RefreshRequest) -> None:
|
||||||
try:
|
try:
|
||||||
payload = decode_token(body.refresh_token)
|
payload = decode_token(body.refresh_token)
|
||||||
except JWTError:
|
except JWTError:
|
||||||
return # Already invalid — treat as success
|
return # Already invalid — treat as success
|
||||||
jti = payload.get("jti", "")
|
jti = payload.get("jti", "")
|
||||||
if jti:
|
if jti:
|
||||||
await revoke_refresh_token(jti)
|
await revoke_refresh_token(jti)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/validate")
|
@router.get("/validate")
|
||||||
async def validate(
|
async def validate(
|
||||||
current_user: dict = Depends(get_current_user),
|
current_user: dict = Depends(get_current_user),
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Return the token payload if the access token is valid."""
|
"""Return the token payload if the access token is valid."""
|
||||||
return current_user
|
return current_user
|
||||||
+403
-403
@@ -1,403 +1,403 @@
|
|||||||
"""Generate router: text, image, video, and image-to-video generation."""
|
"""Generate router: text, image, video, and image-to-video generation."""
|
||||||
import json
|
import json
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
|
||||||
from ..db import get_conn, get_write_lock
|
from ..db import get_conn, get_write_lock
|
||||||
from ..dependencies import get_current_user
|
from ..dependencies import get_current_user
|
||||||
from ..models.ai import (
|
from ..models.ai import (
|
||||||
ImageRequest,
|
ImageRequest,
|
||||||
ImageResponse,
|
ImageResponse,
|
||||||
ImageResult,
|
ImageResult,
|
||||||
TextRequest,
|
TextRequest,
|
||||||
TextResponse,
|
TextResponse,
|
||||||
VideoFromImageRequest,
|
VideoFromImageRequest,
|
||||||
VideoRequest,
|
VideoRequest,
|
||||||
VideoResponse,
|
VideoResponse,
|
||||||
)
|
)
|
||||||
from ..services import openrouter
|
from ..services import openrouter
|
||||||
from ..services.models import get_model_output_modalities
|
from ..services.models import get_model_output_modalities
|
||||||
|
|
||||||
router = APIRouter(prefix="/generate", tags=["generate"])
|
router = APIRouter(prefix="/generate", tags=["generate"])
|
||||||
|
|
||||||
|
|
||||||
@router.post("/text", response_model=TextResponse)
|
@router.post("/text", response_model=TextResponse)
|
||||||
async def generate_text(
|
async def generate_text(
|
||||||
body: TextRequest,
|
body: TextRequest,
|
||||||
_: dict = Depends(get_current_user),
|
_: dict = Depends(get_current_user),
|
||||||
) -> TextResponse:
|
) -> TextResponse:
|
||||||
"""Generate text from a prompt using a chat model."""
|
"""Generate text from a prompt using a chat model."""
|
||||||
if body.messages:
|
if body.messages:
|
||||||
messages = [{"role": m.role, "content": m.content}
|
messages = [{"role": m.role, "content": m.content}
|
||||||
for m in body.messages]
|
for m in body.messages]
|
||||||
if body.system_prompt and (not messages or messages[0]["role"] != "system"):
|
if body.system_prompt and (not messages or messages[0]["role"] != "system"):
|
||||||
messages.insert(
|
messages.insert(
|
||||||
0, {"role": "system", "content": body.system_prompt})
|
0, {"role": "system", "content": body.system_prompt})
|
||||||
else:
|
else:
|
||||||
messages = []
|
messages = []
|
||||||
if body.system_prompt:
|
if body.system_prompt:
|
||||||
messages.append({"role": "system", "content": body.system_prompt})
|
messages.append({"role": "system", "content": body.system_prompt})
|
||||||
messages.append({"role": "user", "content": body.prompt})
|
messages.append({"role": "user", "content": body.prompt})
|
||||||
|
|
||||||
try:
|
try:
|
||||||
result = await openrouter.chat_completion(
|
result = await openrouter.chat_completion(
|
||||||
model=body.model,
|
model=body.model,
|
||||||
messages=messages,
|
messages=messages,
|
||||||
temperature=body.temperature,
|
temperature=body.temperature,
|
||||||
max_tokens=body.max_tokens,
|
max_tokens=body.max_tokens,
|
||||||
)
|
)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_502_BAD_GATEWAY, detail=f"OpenRouter error: {exc}")
|
status_code=status.HTTP_502_BAD_GATEWAY, detail=f"OpenRouter error: {exc}")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
choice = result["choices"][0]
|
choice = result["choices"][0]
|
||||||
return TextResponse(
|
return TextResponse(
|
||||||
id=result["id"],
|
id=result["id"],
|
||||||
model=result.get("model", body.model),
|
model=result.get("model", body.model),
|
||||||
content=choice["message"]["content"],
|
content=choice["message"]["content"],
|
||||||
usage=result.get("usage"),
|
usage=result.get("usage"),
|
||||||
)
|
)
|
||||||
except (KeyError, IndexError) as exc:
|
except (KeyError, IndexError) as exc:
|
||||||
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY,
|
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY,
|
||||||
detail=f"Unexpected response format: {exc}")
|
detail=f"Unexpected response format: {exc}")
|
||||||
|
|
||||||
|
|
||||||
@router.post("/image", response_model=ImageResponse)
|
@router.post("/image", response_model=ImageResponse)
|
||||||
async def generate_image(
|
async def generate_image(
|
||||||
body: ImageRequest,
|
body: ImageRequest,
|
||||||
current_user: dict = Depends(get_current_user),
|
current_user: dict = Depends(get_current_user),
|
||||||
) -> ImageResponse:
|
) -> ImageResponse:
|
||||||
"""Generate images from a prompt using the chat completions endpoint.
|
"""Generate images from a prompt using the chat completions endpoint.
|
||||||
|
|
||||||
All OpenRouter image models use /chat/completions with a modalities param.
|
All OpenRouter image models use /chat/completions with a modalities param.
|
||||||
Models that output only images use ["image"]; those that also output text
|
Models that output only images use ["image"]; those that also output text
|
||||||
use ["image", "text"]. We look this up from the model cache; default to
|
use ["image", "text"]. We look this up from the model cache; default to
|
||||||
["image", "text"] when the model is not yet cached.
|
["image", "text"] when the model is not yet cached.
|
||||||
"""
|
"""
|
||||||
# Determine modalities from cache; default ["image", "text"] works for most models
|
# Determine modalities from cache; default ["image", "text"] works for most models
|
||||||
try:
|
try:
|
||||||
conn = get_conn()
|
conn = get_conn()
|
||||||
cached_modalities = get_model_output_modalities(conn, body.model)
|
cached_modalities = get_model_output_modalities(conn, body.model)
|
||||||
except Exception:
|
except Exception:
|
||||||
cached_modalities = []
|
cached_modalities = []
|
||||||
|
|
||||||
if cached_modalities:
|
if cached_modalities:
|
||||||
# If cache says model only outputs image (no text), use ["image"]
|
# If cache says model only outputs image (no text), use ["image"]
|
||||||
modalities = ["image"] if set(cached_modalities) == {
|
modalities = ["image"] if set(cached_modalities) == {
|
||||||
"image"} else ["image", "text"]
|
"image"} else ["image", "text"]
|
||||||
else:
|
else:
|
||||||
# Safe default: ["image", "text"]; works for Gemini, GPT-image etc.
|
# Safe default: ["image", "text"]; works for Gemini, GPT-image etc.
|
||||||
# For image-only models that fail with this, the error surfaces to the user.
|
# For image-only models that fail with this, the error surfaces to the user.
|
||||||
modalities = ["image", "text"]
|
modalities = ["image", "text"]
|
||||||
|
|
||||||
image_config: dict = {}
|
image_config: dict = {}
|
||||||
if body.aspect_ratio:
|
if body.aspect_ratio:
|
||||||
image_config["aspect_ratio"] = body.aspect_ratio
|
image_config["aspect_ratio"] = body.aspect_ratio
|
||||||
if body.image_size:
|
if body.image_size:
|
||||||
image_config["image_size"] = body.image_size
|
image_config["image_size"] = body.image_size
|
||||||
|
|
||||||
try:
|
try:
|
||||||
result = await openrouter.generate_image_chat(
|
result = await openrouter.generate_image_chat(
|
||||||
model=body.model,
|
model=body.model,
|
||||||
prompt=body.prompt,
|
prompt=body.prompt,
|
||||||
modalities=modalities,
|
modalities=modalities,
|
||||||
image_config=image_config if image_config else None,
|
image_config=image_config if image_config else None,
|
||||||
)
|
)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_502_BAD_GATEWAY, detail=f"OpenRouter error: {exc}")
|
status_code=status.HTTP_502_BAD_GATEWAY, detail=f"OpenRouter error: {exc}")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
message = result.get("choices", [{}])[0].get("message", {})
|
message = result.get("choices", [{}])[0].get("message", {})
|
||||||
images = []
|
images = []
|
||||||
for item in message.get("images", []):
|
for item in message.get("images", []):
|
||||||
img_url = item.get("image_url", {}).get("url")
|
img_url = item.get("image_url", {}).get("url")
|
||||||
images.append(ImageResult(
|
images.append(ImageResult(
|
||||||
url=img_url,
|
url=img_url,
|
||||||
b64_json=None,
|
b64_json=None,
|
||||||
revised_prompt=message.get("content") or None,
|
revised_prompt=message.get("content") or None,
|
||||||
))
|
))
|
||||||
if not images:
|
if not images:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||||
detail="No images returned by model. Verify the model supports image generation.",
|
detail="No images returned by model. Verify the model supports image generation.",
|
||||||
)
|
)
|
||||||
|
|
||||||
# Persist each image to DB
|
# Persist each image to DB
|
||||||
user_id = current_user.get("id") or current_user.get("sub")
|
user_id = current_user.get("id") or current_user.get("sub")
|
||||||
now = datetime.now(timezone.utc).replace(tzinfo=None)
|
now = datetime.now(timezone.utc).replace(tzinfo=None)
|
||||||
stored: list[ImageResult] = []
|
stored: list[ImageResult] = []
|
||||||
sql_insert = "INSERT INTO generated_images (user_id, model_id, prompt, image_data, created_at) VALUES (?, ?, ?, ?, ?) RETURNING id"
|
sql_insert = "INSERT INTO generated_images (user_id, model_id, prompt, image_data, created_at) VALUES (?, ?, ?, ?, ?) RETURNING id"
|
||||||
async with get_write_lock():
|
async with get_write_lock():
|
||||||
conn = get_conn()
|
conn = get_conn()
|
||||||
for img in images:
|
for img in images:
|
||||||
if img.url:
|
if img.url:
|
||||||
row = conn.execute(
|
row = conn.execute(
|
||||||
sql_insert, [user_id, body.model, body.prompt, img.url, now],).fetchone()
|
sql_insert, [user_id, body.model, body.prompt, img.url, now],).fetchone()
|
||||||
image_id = str(row[0]) if row else None
|
image_id = str(row[0]) if row else None
|
||||||
else:
|
else:
|
||||||
image_id = None
|
image_id = None
|
||||||
stored.append(ImageResult(
|
stored.append(ImageResult(
|
||||||
url=img.url,
|
url=img.url,
|
||||||
b64_json=img.b64_json,
|
b64_json=img.b64_json,
|
||||||
revised_prompt=img.revised_prompt,
|
revised_prompt=img.revised_prompt,
|
||||||
image_id=image_id,
|
image_id=image_id,
|
||||||
))
|
))
|
||||||
|
|
||||||
return ImageResponse(
|
return ImageResponse(
|
||||||
id=result.get("id", ""),
|
id=result.get("id", ""),
|
||||||
model=result.get("model", body.model),
|
model=result.get("model", body.model),
|
||||||
images=stored,
|
images=stored,
|
||||||
)
|
)
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
except (KeyError, TypeError) as exc:
|
except (KeyError, TypeError) as exc:
|
||||||
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY,
|
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY,
|
||||||
detail=f"Unexpected response format: {exc}")
|
detail=f"Unexpected response format: {exc}")
|
||||||
|
|
||||||
|
|
||||||
@router.get("/images")
|
@router.get("/images")
|
||||||
async def list_generated_images(
|
async def list_generated_images(
|
||||||
current_user: dict = Depends(get_current_user),
|
current_user: dict = Depends(get_current_user),
|
||||||
) -> list[dict]:
|
) -> list[dict]:
|
||||||
"""Return all generated images for the current user, newest first."""
|
"""Return all generated images for the current user, newest first."""
|
||||||
user_id = current_user.get("id") or current_user.get("sub")
|
user_id = current_user.get("id") or current_user.get("sub")
|
||||||
conn = get_conn()
|
conn = get_conn()
|
||||||
sql_fetch = "SELECT id, model_id, prompt, image_data, created_at FROM generated_images WHERE user_id = ? ORDER BY created_at DESC"
|
sql_fetch = "SELECT id, model_id, prompt, image_data, created_at FROM generated_images WHERE user_id = ? ORDER BY created_at DESC"
|
||||||
rows = conn.execute(sql_fetch, [user_id]).fetchall()
|
rows = conn.execute(sql_fetch, [user_id]).fetchall()
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
"id": str(r[0]),
|
"id": str(r[0]),
|
||||||
"model_id": r[1],
|
"model_id": r[1],
|
||||||
"prompt": r[2],
|
"prompt": r[2],
|
||||||
"image_data": r[3],
|
"image_data": r[3],
|
||||||
"created_at": r[4].isoformat() if r[4] else None,
|
"created_at": r[4].isoformat() if r[4] else None,
|
||||||
}
|
}
|
||||||
for r in rows
|
for r in rows
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@router.get("/images/{image_id}")
|
@router.get("/images/{image_id}")
|
||||||
async def get_generated_image(
|
async def get_generated_image(
|
||||||
image_id: str,
|
image_id: str,
|
||||||
current_user: dict = Depends(get_current_user),
|
current_user: dict = Depends(get_current_user),
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Return details for a single generated image."""
|
"""Return details for a single generated image."""
|
||||||
user_id = current_user.get("id") or current_user.get("sub")
|
user_id = current_user.get("id") or current_user.get("sub")
|
||||||
conn = get_conn()
|
conn = get_conn()
|
||||||
row = conn.execute(
|
row = conn.execute(
|
||||||
"""SELECT id, model_id, prompt, image_data, created_at
|
"""SELECT id, model_id, prompt, image_data, created_at
|
||||||
FROM generated_images
|
FROM generated_images
|
||||||
WHERE id = ? AND user_id = ?""",
|
WHERE id = ? AND user_id = ?""",
|
||||||
[image_id, user_id],
|
[image_id, user_id],
|
||||||
).fetchone()
|
).fetchone()
|
||||||
if not row:
|
if not row:
|
||||||
raise HTTPException(status_code=404, detail="Image not found")
|
raise HTTPException(status_code=404, detail="Image not found")
|
||||||
return {
|
return {
|
||||||
"id": str(row[0]),
|
"id": str(row[0]),
|
||||||
"model_id": row[1],
|
"model_id": row[1],
|
||||||
"prompt": row[2],
|
"prompt": row[2],
|
||||||
"image_data": row[3],
|
"image_data": row[3],
|
||||||
"created_at": row[4].isoformat() if row[4] else None,
|
"created_at": row[4].isoformat() if row[4] else None,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/video", response_model=VideoResponse)
|
@router.post("/video", response_model=VideoResponse)
|
||||||
async def generate_video(
|
async def generate_video(
|
||||||
body: VideoRequest,
|
body: VideoRequest,
|
||||||
current_user: dict = Depends(get_current_user),
|
current_user: dict = Depends(get_current_user),
|
||||||
) -> VideoResponse:
|
) -> VideoResponse:
|
||||||
"""Queue a text-to-video generation job for background processing."""
|
"""Queue a text-to-video generation job for background processing."""
|
||||||
user_id = current_user.get("id") or current_user.get("sub")
|
user_id = current_user.get("id") or current_user.get("sub")
|
||||||
now = datetime.now(timezone.utc).replace(tzinfo=None)
|
now = datetime.now(timezone.utc).replace(tzinfo=None)
|
||||||
request_params = json.dumps({
|
request_params = json.dumps({
|
||||||
"model": body.model,
|
"model": body.model,
|
||||||
"prompt": body.prompt,
|
"prompt": body.prompt,
|
||||||
"duration_seconds": body.duration_seconds,
|
"duration_seconds": body.duration_seconds,
|
||||||
"aspect_ratio": body.aspect_ratio,
|
"aspect_ratio": body.aspect_ratio,
|
||||||
"resolution": body.resolution,
|
"resolution": body.resolution,
|
||||||
})
|
})
|
||||||
db_id = None
|
db_id = None
|
||||||
async with get_write_lock():
|
async with get_write_lock():
|
||||||
conn = get_conn()
|
conn = get_conn()
|
||||||
row = conn.execute(
|
row = conn.execute(
|
||||||
"""INSERT INTO generated_videos
|
"""INSERT INTO generated_videos
|
||||||
(user_id, job_id, model_id, prompt, status, request_params, generation_type, created_at, updated_at)
|
(user_id, job_id, model_id, prompt, status, request_params, generation_type, created_at, updated_at)
|
||||||
VALUES (?, ?, ?, ?, 'queued', ?, 'text_to_video', ?, ?) RETURNING id""",
|
VALUES (?, ?, ?, ?, 'queued', ?, 'text_to_video', ?, ?) RETURNING id""",
|
||||||
[user_id, "", body.model, body.prompt, request_params, now, now],
|
[user_id, "", body.model, body.prompt, request_params, now, now],
|
||||||
).fetchone()
|
).fetchone()
|
||||||
if row:
|
if row:
|
||||||
db_id = str(row[0])
|
db_id = str(row[0])
|
||||||
return VideoResponse(
|
return VideoResponse(
|
||||||
id="",
|
id="",
|
||||||
db_id=db_id,
|
db_id=db_id,
|
||||||
model=body.model,
|
model=body.model,
|
||||||
status="queued",
|
status="queued",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/video/from-image", response_model=VideoResponse)
|
@router.post("/video/from-image", response_model=VideoResponse)
|
||||||
async def generate_video_from_image(
|
async def generate_video_from_image(
|
||||||
body: VideoFromImageRequest,
|
body: VideoFromImageRequest,
|
||||||
current_user: dict = Depends(get_current_user),
|
current_user: dict = Depends(get_current_user),
|
||||||
) -> VideoResponse:
|
) -> VideoResponse:
|
||||||
"""Queue an image-to-video generation job for background processing."""
|
"""Queue an image-to-video generation job for background processing."""
|
||||||
user_id = current_user.get("id") or current_user.get("sub")
|
user_id = current_user.get("id") or current_user.get("sub")
|
||||||
now = datetime.now(timezone.utc).replace(tzinfo=None)
|
now = datetime.now(timezone.utc).replace(tzinfo=None)
|
||||||
request_params = json.dumps({
|
request_params = json.dumps({
|
||||||
"model": body.model,
|
"model": body.model,
|
||||||
"image_url": body.image_url,
|
"image_url": body.image_url,
|
||||||
"prompt": body.prompt,
|
"prompt": body.prompt,
|
||||||
"duration_seconds": body.duration_seconds,
|
"duration_seconds": body.duration_seconds,
|
||||||
"aspect_ratio": body.aspect_ratio,
|
"aspect_ratio": body.aspect_ratio,
|
||||||
"resolution": body.resolution,
|
"resolution": body.resolution,
|
||||||
})
|
})
|
||||||
db_id = None
|
db_id = None
|
||||||
async with get_write_lock():
|
async with get_write_lock():
|
||||||
conn = get_conn()
|
conn = get_conn()
|
||||||
row = conn.execute(
|
row = conn.execute(
|
||||||
"""INSERT INTO generated_videos
|
"""INSERT INTO generated_videos
|
||||||
(user_id, job_id, model_id, prompt, status, request_params, generation_type, created_at, updated_at)
|
(user_id, job_id, model_id, prompt, status, request_params, generation_type, created_at, updated_at)
|
||||||
VALUES (?, ?, ?, ?, 'queued', ?, 'image_to_video', ?, ?) RETURNING id""",
|
VALUES (?, ?, ?, ?, 'queued', ?, 'image_to_video', ?, ?) RETURNING id""",
|
||||||
[user_id, "", body.model, body.prompt, request_params, now, now],
|
[user_id, "", body.model, body.prompt, request_params, now, now],
|
||||||
).fetchone()
|
).fetchone()
|
||||||
if row:
|
if row:
|
||||||
db_id = str(row[0])
|
db_id = str(row[0])
|
||||||
return VideoResponse(
|
return VideoResponse(
|
||||||
id="",
|
id="",
|
||||||
db_id=db_id,
|
db_id=db_id,
|
||||||
model=body.model,
|
model=body.model,
|
||||||
status="queued",
|
status="queued",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/video/status", response_model=VideoResponse)
|
@router.get("/video/status", response_model=VideoResponse)
|
||||||
async def poll_video_status(
|
async def poll_video_status(
|
||||||
polling_url: str,
|
polling_url: str,
|
||||||
current_user: dict = Depends(get_current_user),
|
current_user: dict = Depends(get_current_user),
|
||||||
) -> VideoResponse:
|
) -> VideoResponse:
|
||||||
"""Poll status of a video generation job; updates DB row when completed/failed."""
|
"""Poll status of a video generation job; updates DB row when completed/failed."""
|
||||||
try:
|
try:
|
||||||
result = await openrouter.poll_video_status(polling_url)
|
result = await openrouter.poll_video_status(polling_url)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_502_BAD_GATEWAY, detail=f"OpenRouter error: {exc}")
|
status_code=status.HTTP_502_BAD_GATEWAY, detail=f"OpenRouter error: {exc}")
|
||||||
|
|
||||||
job_status = result.get("status", "processing")
|
job_status = result.get("status", "processing")
|
||||||
urls = result.get("unsigned_urls") or result.get("video_urls")
|
urls = result.get("unsigned_urls") or result.get("video_urls")
|
||||||
video_url = (urls or [None])[0]
|
video_url = (urls or [None])[0]
|
||||||
|
|
||||||
# Update DB row for this job when terminal state reached
|
# Update DB row for this job when terminal state reached
|
||||||
if job_status in ("completed", "failed"):
|
if job_status in ("completed", "failed"):
|
||||||
now = datetime.now(timezone.utc).replace(tzinfo=None)
|
now = datetime.now(timezone.utc).replace(tzinfo=None)
|
||||||
async with get_write_lock():
|
async with get_write_lock():
|
||||||
conn = get_conn()
|
conn = get_conn()
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"""UPDATE generated_videos
|
"""UPDATE generated_videos
|
||||||
SET status = ?, video_url = ?, updated_at = ?
|
SET status = ?, video_url = ?, updated_at = ?
|
||||||
WHERE job_id = ?""",
|
WHERE job_id = ?""",
|
||||||
[job_status, video_url, now, result.get("id", "")],
|
[job_status, video_url, now, result.get("id", "")],
|
||||||
)
|
)
|
||||||
|
|
||||||
return VideoResponse(
|
return VideoResponse(
|
||||||
id=result.get("id", ""),
|
id=result.get("id", ""),
|
||||||
model=result.get("model", ""),
|
model=result.get("model", ""),
|
||||||
status=job_status,
|
status=job_status,
|
||||||
polling_url=result.get("polling_url"),
|
polling_url=result.get("polling_url"),
|
||||||
video_urls=urls,
|
video_urls=urls,
|
||||||
video_url=video_url,
|
video_url=video_url,
|
||||||
error=result.get("error"),
|
error=result.get("error"),
|
||||||
metadata=result.get("metadata"),
|
metadata=result.get("metadata"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/videos")
|
@router.get("/videos")
|
||||||
async def list_generated_videos(
|
async def list_generated_videos(
|
||||||
current_user: dict = Depends(get_current_user),
|
current_user: dict = Depends(get_current_user),
|
||||||
) -> list[dict]:
|
) -> list[dict]:
|
||||||
"""Return all generated video jobs for the current user, newest first."""
|
"""Return all generated video jobs for the current user, newest first."""
|
||||||
user_id = current_user.get("id") or current_user.get("sub")
|
user_id = current_user.get("id") or current_user.get("sub")
|
||||||
conn = get_conn()
|
conn = get_conn()
|
||||||
rows = conn.execute(
|
rows = conn.execute(
|
||||||
"""SELECT id, job_id, model_id, prompt, polling_url, status, video_url, error, created_at
|
"""SELECT id, job_id, model_id, prompt, polling_url, status, video_url, error, created_at
|
||||||
FROM generated_videos
|
FROM generated_videos
|
||||||
WHERE user_id = ?
|
WHERE user_id = ?
|
||||||
ORDER BY created_at DESC""",
|
ORDER BY created_at DESC""",
|
||||||
[user_id],
|
[user_id],
|
||||||
).fetchall()
|
).fetchall()
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
"id": str(r[0]),
|
"id": str(r[0]),
|
||||||
"job_id": r[1],
|
"job_id": r[1],
|
||||||
"model_id": r[2],
|
"model_id": r[2],
|
||||||
"prompt": r[3],
|
"prompt": r[3],
|
||||||
"polling_url": r[4],
|
"polling_url": r[4],
|
||||||
"status": r[5],
|
"status": r[5],
|
||||||
"video_url": r[6],
|
"video_url": r[6],
|
||||||
"error": r[7],
|
"error": r[7],
|
||||||
"created_at": r[8].isoformat() if r[8] else None,
|
"created_at": r[8].isoformat() if r[8] else None,
|
||||||
}
|
}
|
||||||
for r in rows
|
for r in rows
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@router.get("/videos/{video_id}")
|
@router.get("/videos/{video_id}")
|
||||||
async def get_generated_video(
|
async def get_generated_video(
|
||||||
video_id: str,
|
video_id: str,
|
||||||
current_user: dict = Depends(get_current_user),
|
current_user: dict = Depends(get_current_user),
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Return details for a single video generation job."""
|
"""Return details for a single video generation job."""
|
||||||
user_id = current_user.get("id") or current_user.get("sub")
|
user_id = current_user.get("id") or current_user.get("sub")
|
||||||
conn = get_conn()
|
conn = get_conn()
|
||||||
row = conn.execute(
|
row = conn.execute(
|
||||||
"""SELECT id, job_id, model_id, prompt, polling_url, status, video_url, error, created_at, updated_at
|
"""SELECT id, job_id, model_id, prompt, polling_url, status, video_url, error, created_at, updated_at
|
||||||
FROM generated_videos
|
FROM generated_videos
|
||||||
WHERE id = ? AND user_id = ?""",
|
WHERE id = ? AND user_id = ?""",
|
||||||
[video_id, user_id],
|
[video_id, user_id],
|
||||||
).fetchone()
|
).fetchone()
|
||||||
if not row:
|
if not row:
|
||||||
raise HTTPException(status_code=404, detail="Video job not found")
|
raise HTTPException(status_code=404, detail="Video job not found")
|
||||||
return {
|
return {
|
||||||
"id": str(row[0]),
|
"id": str(row[0]),
|
||||||
"job_id": row[1],
|
"job_id": row[1],
|
||||||
"model_id": row[2],
|
"model_id": row[2],
|
||||||
"prompt": row[3],
|
"prompt": row[3],
|
||||||
"polling_url": row[4],
|
"polling_url": row[4],
|
||||||
"status": row[5],
|
"status": row[5],
|
||||||
"video_url": row[6],
|
"video_url": row[6],
|
||||||
"error": row[7],
|
"error": row[7],
|
||||||
"created_at": row[8].isoformat() if row[8] else None,
|
"created_at": row[8].isoformat() if row[8] else None,
|
||||||
"updated_at": row[9].isoformat() if row[9] else None,
|
"updated_at": row[9].isoformat() if row[9] else None,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/videos/{video_id}/cancel", status_code=200)
|
@router.post("/videos/{video_id}/cancel", status_code=200)
|
||||||
async def cancel_video_job(
|
async def cancel_video_job(
|
||||||
video_id: str,
|
video_id: str,
|
||||||
current_user: dict = Depends(get_current_user),
|
current_user: dict = Depends(get_current_user),
|
||||||
) -> dict[str, str]:
|
) -> dict[str, str]:
|
||||||
"""Mark a video job as 'cancelled' if it belongs to the current user and is not terminal."""
|
"""Mark a video job as 'cancelled' if it belongs to the current user and is not terminal."""
|
||||||
user_id = current_user.get("id") or current_user.get("sub")
|
user_id = current_user.get("id") or current_user.get("sub")
|
||||||
conn = get_conn()
|
conn = get_conn()
|
||||||
row = conn.execute(
|
row = conn.execute(
|
||||||
"SELECT status FROM generated_videos WHERE id = ? AND user_id = ?",
|
"SELECT status FROM generated_videos WHERE id = ? AND user_id = ?",
|
||||||
[video_id, user_id],
|
[video_id, user_id],
|
||||||
).fetchone()
|
).fetchone()
|
||||||
if not row:
|
if not row:
|
||||||
raise HTTPException(status_code=404, detail="Video job not found")
|
raise HTTPException(status_code=404, detail="Video job not found")
|
||||||
job_status = row[0]
|
job_status = row[0]
|
||||||
if job_status in ("completed", "failed", "cancelled"):
|
if job_status in ("completed", "failed", "cancelled"):
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
detail=f"Cannot cancel job with status '{job_status}'",
|
detail=f"Cannot cancel job with status '{job_status}'",
|
||||||
)
|
)
|
||||||
now = datetime.now(timezone.utc).replace(tzinfo=None)
|
now = datetime.now(timezone.utc).replace(tzinfo=None)
|
||||||
async with get_write_lock():
|
async with get_write_lock():
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"UPDATE generated_videos SET status = 'cancelled', updated_at = ? WHERE id = ?",
|
"UPDATE generated_videos SET status = 'cancelled', updated_at = ? WHERE id = ?",
|
||||||
[now, video_id],
|
[now, video_id],
|
||||||
)
|
)
|
||||||
return {"status": "ok", "job_id": video_id}
|
return {"status": "ok", "job_id": video_id}
|
||||||
+150
-150
@@ -1,150 +1,150 @@
|
|||||||
"""Images router: upload reference images and list user's uploads."""
|
"""Images router: upload reference images and list user's uploads."""
|
||||||
import os
|
import os
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, UploadFile, status
|
from fastapi import APIRouter, Depends, HTTPException, UploadFile, status
|
||||||
from fastapi.responses import FileResponse
|
from fastapi.responses import FileResponse
|
||||||
|
|
||||||
from ..db import get_conn, get_write_lock
|
from ..db import get_conn, get_write_lock
|
||||||
from ..dependencies import get_current_user
|
from ..dependencies import get_current_user
|
||||||
|
|
||||||
router = APIRouter(prefix="/images", tags=["images"])
|
router = APIRouter(prefix="/images", tags=["images"])
|
||||||
|
|
||||||
UPLOAD_DIR = os.getenv("UPLOAD_DIR", "data/uploads")
|
UPLOAD_DIR = os.getenv("UPLOAD_DIR", "data/uploads")
|
||||||
MAX_SIZE_BYTES = 10 * 1024 * 1024 # 10 MB
|
MAX_SIZE_BYTES = 10 * 1024 * 1024 # 10 MB
|
||||||
ALLOWED_CONTENT_TYPES = {"image/jpeg", "image/png", "image/webp", "image/gif"}
|
ALLOWED_CONTENT_TYPES = {"image/jpeg", "image/png", "image/webp", "image/gif"}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/upload", status_code=status.HTTP_201_CREATED)
|
@router.post("/upload", status_code=status.HTTP_201_CREATED)
|
||||||
async def upload_image(
|
async def upload_image(
|
||||||
file: UploadFile,
|
file: UploadFile,
|
||||||
current_user: dict = Depends(get_current_user),
|
current_user: dict = Depends(get_current_user),
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Upload a reference image and store metadata in DuckDB."""
|
"""Upload a reference image and store metadata in DuckDB."""
|
||||||
if file.content_type not in ALLOWED_CONTENT_TYPES:
|
if file.content_type not in ALLOWED_CONTENT_TYPES:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_415_UNSUPPORTED_MEDIA_TYPE,
|
status_code=status.HTTP_415_UNSUPPORTED_MEDIA_TYPE,
|
||||||
detail=f"Unsupported content type '{file.content_type}'. Allowed: {sorted(ALLOWED_CONTENT_TYPES)}",
|
detail=f"Unsupported content type '{file.content_type}'. Allowed: {sorted(ALLOWED_CONTENT_TYPES)}",
|
||||||
)
|
)
|
||||||
|
|
||||||
data = await file.read()
|
data = await file.read()
|
||||||
if len(data) > MAX_SIZE_BYTES:
|
if len(data) > MAX_SIZE_BYTES:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
|
status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
|
||||||
detail=f"File exceeds maximum allowed size of {MAX_SIZE_BYTES // (1024*1024)} MB.",
|
detail=f"File exceeds maximum allowed size of {MAX_SIZE_BYTES // (1024*1024)} MB.",
|
||||||
)
|
)
|
||||||
|
|
||||||
user_id = current_user["id"]
|
user_id = current_user["id"]
|
||||||
image_id = str(uuid.uuid4())
|
image_id = str(uuid.uuid4())
|
||||||
ext = (file.filename or "").rsplit(
|
ext = (file.filename or "").rsplit(
|
||||||
".", 1)[-1].lower() if "." in (file.filename or "") else "bin"
|
".", 1)[-1].lower() if "." in (file.filename or "") else "bin"
|
||||||
safe_filename = f"{image_id}.{ext}"
|
safe_filename = f"{image_id}.{ext}"
|
||||||
user_dir = os.path.join(UPLOAD_DIR, user_id)
|
user_dir = os.path.join(UPLOAD_DIR, user_id)
|
||||||
os.makedirs(user_dir, exist_ok=True)
|
os.makedirs(user_dir, exist_ok=True)
|
||||||
file_path = os.path.join(user_dir, safe_filename)
|
file_path = os.path.join(user_dir, safe_filename)
|
||||||
|
|
||||||
with open(file_path, "wb") as f:
|
with open(file_path, "wb") as f:
|
||||||
f.write(data)
|
f.write(data)
|
||||||
|
|
||||||
async with get_write_lock():
|
async with get_write_lock():
|
||||||
conn = get_conn()
|
conn = get_conn()
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"""
|
"""
|
||||||
INSERT INTO uploaded_images (id, user_id, filename, content_type, file_path, size_bytes)
|
INSERT INTO uploaded_images (id, user_id, filename, content_type, file_path, size_bytes)
|
||||||
VALUES (?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?)
|
||||||
""",
|
""",
|
||||||
[image_id, user_id, file.filename or safe_filename,
|
[image_id, user_id, file.filename or safe_filename,
|
||||||
file.content_type, file_path, len(data)],
|
file.content_type, file_path, len(data)],
|
||||||
)
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"id": image_id,
|
"id": image_id,
|
||||||
"filename": file.filename or safe_filename,
|
"filename": file.filename or safe_filename,
|
||||||
"content_type": file.content_type,
|
"content_type": file.content_type,
|
||||||
"size_bytes": len(data),
|
"size_bytes": len(data),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/", status_code=status.HTTP_200_OK)
|
@router.get("/", status_code=status.HTTP_200_OK)
|
||||||
async def list_images(
|
async def list_images(
|
||||||
current_user: dict = Depends(get_current_user),
|
current_user: dict = Depends(get_current_user),
|
||||||
) -> list[dict]:
|
) -> list[dict]:
|
||||||
"""Return all uploaded images for the current user."""
|
"""Return all uploaded images for the current user."""
|
||||||
conn = get_conn()
|
conn = get_conn()
|
||||||
rows = conn.execute(
|
rows = conn.execute(
|
||||||
"""
|
"""
|
||||||
SELECT id, filename, content_type, size_bytes, created_at
|
SELECT id, filename, content_type, size_bytes, created_at
|
||||||
FROM uploaded_images
|
FROM uploaded_images
|
||||||
WHERE user_id = ?
|
WHERE user_id = ?
|
||||||
ORDER BY created_at DESC
|
ORDER BY created_at DESC
|
||||||
""",
|
""",
|
||||||
[current_user["id"]],
|
[current_user["id"]],
|
||||||
).fetchall()
|
).fetchall()
|
||||||
|
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
"id": str(row[0]),
|
"id": str(row[0]),
|
||||||
"filename": row[1],
|
"filename": row[1],
|
||||||
"content_type": row[2],
|
"content_type": row[2],
|
||||||
"size_bytes": row[3],
|
"size_bytes": row[3],
|
||||||
"created_at": row[4].isoformat() if row[4] else None,
|
"created_at": row[4].isoformat() if row[4] else None,
|
||||||
}
|
}
|
||||||
for row in rows
|
for row in rows
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{image_id}", status_code=status.HTTP_200_OK)
|
@router.get("/{image_id}", status_code=status.HTTP_200_OK)
|
||||||
async def get_image_details(
|
async def get_image_details(
|
||||||
image_id: str,
|
image_id: str,
|
||||||
current_user: dict = Depends(get_current_user),
|
current_user: dict = Depends(get_current_user),
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Return metadata for a single uploaded image."""
|
"""Return metadata for a single uploaded image."""
|
||||||
conn = get_conn()
|
conn = get_conn()
|
||||||
row = conn.execute(
|
row = conn.execute(
|
||||||
"""
|
"""
|
||||||
SELECT id, filename, content_type, size_bytes, created_at
|
SELECT id, filename, content_type, size_bytes, created_at
|
||||||
FROM uploaded_images
|
FROM uploaded_images
|
||||||
WHERE id = ? AND user_id = ?
|
WHERE id = ? AND user_id = ?
|
||||||
""",
|
""",
|
||||||
[image_id, current_user["id"]],
|
[image_id, current_user["id"]],
|
||||||
).fetchone()
|
).fetchone()
|
||||||
|
|
||||||
if not row:
|
if not row:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_404_NOT_FOUND, detail="Image not found"
|
status_code=status.HTTP_404_NOT_FOUND, detail="Image not found"
|
||||||
)
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"id": str(row[0]),
|
"id": str(row[0]),
|
||||||
"filename": row[1],
|
"filename": row[1],
|
||||||
"content_type": row[2],
|
"content_type": row[2],
|
||||||
"size_bytes": row[3],
|
"size_bytes": row[3],
|
||||||
"created_at": row[4].isoformat() if row[4] else None,
|
"created_at": row[4].isoformat() if row[4] else None,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{image_id}/file", status_code=status.HTTP_200_OK)
|
@router.get("/{image_id}/file", status_code=status.HTTP_200_OK)
|
||||||
async def serve_image(
|
async def serve_image(
|
||||||
image_id: str,
|
image_id: str,
|
||||||
current_user: dict = Depends(get_current_user),
|
current_user: dict = Depends(get_current_user),
|
||||||
) -> FileResponse:
|
) -> FileResponse:
|
||||||
"""Serve the raw image file. Only accessible by the owning user."""
|
"""Serve the raw image file. Only accessible by the owning user."""
|
||||||
conn = get_conn()
|
conn = get_conn()
|
||||||
row = conn.execute(
|
row = conn.execute(
|
||||||
"SELECT file_path, content_type, user_id FROM uploaded_images WHERE id = ?",
|
"SELECT file_path, content_type, user_id FROM uploaded_images WHERE id = ?",
|
||||||
[image_id],
|
[image_id],
|
||||||
).fetchone()
|
).fetchone()
|
||||||
|
|
||||||
if row is None:
|
if row is None:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_404_NOT_FOUND, detail="Image not found.")
|
status_code=status.HTTP_404_NOT_FOUND, detail="Image not found.")
|
||||||
if str(row[2]) != current_user["id"]:
|
if str(row[2]) != current_user["id"]:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_403_FORBIDDEN, detail="Access denied.")
|
status_code=status.HTTP_403_FORBIDDEN, detail="Access denied.")
|
||||||
|
|
||||||
file_path: str = row[0]
|
file_path: str = row[0]
|
||||||
if not os.path.isfile(file_path):
|
if not os.path.isfile(file_path):
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_404_NOT_FOUND, detail="Image file missing.")
|
status_code=status.HTTP_404_NOT_FOUND, detail="Image file missing.")
|
||||||
|
|
||||||
return FileResponse(file_path, media_type=row[1])
|
return FileResponse(file_path, media_type=row[1])
|
||||||
@@ -1,47 +1,47 @@
|
|||||||
"""Models router: list and refresh the OpenRouter model cache."""
|
"""Models router: list and refresh the OpenRouter model cache."""
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||||
|
|
||||||
from ..db import get_conn, get_write_lock
|
from ..db import get_conn, get_write_lock
|
||||||
from ..dependencies import get_current_user, require_admin
|
from ..dependencies import get_current_user, require_admin
|
||||||
from ..services import models as models_service
|
from ..services import models as models_service
|
||||||
|
|
||||||
router = APIRouter(prefix="/models", tags=["models"])
|
router = APIRouter(prefix="/models", tags=["models"])
|
||||||
|
|
||||||
|
|
||||||
@router.get("/")
|
@router.get("/")
|
||||||
async def list_models(
|
async def list_models(
|
||||||
modality: str | None = Query(
|
modality: str | None = Query(
|
||||||
None,
|
None,
|
||||||
description="Filter by output modality: text, image, video, audio",
|
description="Filter by output modality: text, image, video, audio",
|
||||||
),
|
),
|
||||||
_: dict = Depends(get_current_user),
|
_: dict = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
"""Return cached models. Auto-refreshes cache if stale (older than 24 h)."""
|
"""Return cached models. Auto-refreshes cache if stale (older than 24 h)."""
|
||||||
conn = get_conn()
|
conn = get_conn()
|
||||||
if models_service.is_cache_stale(conn):
|
if models_service.is_cache_stale(conn):
|
||||||
async with get_write_lock():
|
async with get_write_lock():
|
||||||
# Re-check inside lock to avoid redundant parallel refreshes
|
# Re-check inside lock to avoid redundant parallel refreshes
|
||||||
if models_service.is_cache_stale(conn):
|
if models_service.is_cache_stale(conn):
|
||||||
try:
|
try:
|
||||||
await models_service.refresh_models_cache(conn)
|
await models_service.refresh_models_cache(conn)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||||
detail=f"Failed to refresh model cache: {exc}",
|
detail=f"Failed to refresh model cache: {exc}",
|
||||||
)
|
)
|
||||||
return models_service.get_cached_models(conn, modality)
|
return models_service.get_cached_models(conn, modality)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/refresh", status_code=200)
|
@router.post("/refresh", status_code=200)
|
||||||
async def refresh_models(_: dict = Depends(require_admin)):
|
async def refresh_models(_: dict = Depends(require_admin)):
|
||||||
"""Force-refresh the model cache from OpenRouter. Admin only."""
|
"""Force-refresh the model cache from OpenRouter. Admin only."""
|
||||||
conn = get_conn()
|
conn = get_conn()
|
||||||
async with get_write_lock():
|
async with get_write_lock():
|
||||||
try:
|
try:
|
||||||
count = await models_service.refresh_models_cache(conn)
|
count = await models_service.refresh_models_cache(conn)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||||
detail=f"OpenRouter error: {exc}",
|
detail=f"OpenRouter error: {exc}",
|
||||||
)
|
)
|
||||||
return {"refreshed": count}
|
return {"refreshed": count}
|
||||||
@@ -1,86 +1,86 @@
|
|||||||
"""Users router: self-service profile and admin user management."""
|
"""Users router: self-service profile and admin user management."""
|
||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
|
||||||
from ..dependencies import get_current_user, require_admin
|
from ..dependencies import get_current_user, require_admin
|
||||||
from ..models.users import SetRoleRequest, UpdateUserRequest, UserResponse
|
from ..models.users import SetRoleRequest, UpdateUserRequest, UserResponse
|
||||||
from ..services.users import (
|
from ..services.users import (
|
||||||
delete_user,
|
delete_user,
|
||||||
get_user,
|
get_user,
|
||||||
list_users,
|
list_users,
|
||||||
set_user_role,
|
set_user_role,
|
||||||
update_user,
|
update_user,
|
||||||
)
|
)
|
||||||
|
|
||||||
router = APIRouter(prefix="/users", tags=["users"])
|
router = APIRouter(prefix="/users", tags=["users"])
|
||||||
|
|
||||||
ALLOWED_ROLES = {"user", "admin"}
|
ALLOWED_ROLES = {"user", "admin"}
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Self-service
|
# Self-service
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
@router.get("/me", response_model=UserResponse)
|
@router.get("/me", response_model=UserResponse)
|
||||||
async def get_me(current_user: dict = Depends(get_current_user)) -> UserResponse:
|
async def get_me(current_user: dict = Depends(get_current_user)) -> UserResponse:
|
||||||
user = await get_user(current_user["id"])
|
user = await get_user(current_user["id"])
|
||||||
if user is None:
|
if user is None:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_404_NOT_FOUND, detail="User not found.")
|
status_code=status.HTTP_404_NOT_FOUND, detail="User not found.")
|
||||||
return UserResponse(**user)
|
return UserResponse(**user)
|
||||||
|
|
||||||
|
|
||||||
@router.put("/me", response_model=UserResponse)
|
@router.put("/me", response_model=UserResponse)
|
||||||
async def update_me(
|
async def update_me(
|
||||||
body: UpdateUserRequest,
|
body: UpdateUserRequest,
|
||||||
current_user: dict = Depends(get_current_user),
|
current_user: dict = Depends(get_current_user),
|
||||||
) -> UserResponse:
|
) -> UserResponse:
|
||||||
try:
|
try:
|
||||||
user = await update_user(current_user["id"], email=body.email, password=body.password)
|
user = await update_user(current_user["id"], email=body.email, password=body.password)
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_409_CONFLICT, detail=str(exc))
|
status_code=status.HTTP_409_CONFLICT, detail=str(exc))
|
||||||
if user is None:
|
if user is None:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_404_NOT_FOUND, detail="User not found.")
|
status_code=status.HTTP_404_NOT_FOUND, detail="User not found.")
|
||||||
return UserResponse(**user)
|
return UserResponse(**user)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Admin
|
# Admin
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
@router.get("", response_model=list[UserResponse])
|
@router.get("", response_model=list[UserResponse])
|
||||||
async def get_all_users(_: dict = Depends(require_admin)) -> list[UserResponse]:
|
async def get_all_users(_: dict = Depends(require_admin)) -> list[UserResponse]:
|
||||||
users = await list_users()
|
users = await list_users()
|
||||||
return [UserResponse(**u) for u in users]
|
return [UserResponse(**u) for u in users]
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
|
@router.delete("/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
async def remove_user(
|
async def remove_user(
|
||||||
user_id: str,
|
user_id: str,
|
||||||
current_user: dict = Depends(require_admin),
|
current_user: dict = Depends(require_admin),
|
||||||
) -> None:
|
) -> None:
|
||||||
if user_id == current_user["id"]:
|
if user_id == current_user["id"]:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
detail="Cannot delete your own account.",
|
detail="Cannot delete your own account.",
|
||||||
)
|
)
|
||||||
await delete_user(user_id)
|
await delete_user(user_id)
|
||||||
|
|
||||||
|
|
||||||
@router.put("/{user_id}/role", response_model=UserResponse)
|
@router.put("/{user_id}/role", response_model=UserResponse)
|
||||||
async def change_role(
|
async def change_role(
|
||||||
user_id: str,
|
user_id: str,
|
||||||
body: SetRoleRequest,
|
body: SetRoleRequest,
|
||||||
_: dict = Depends(require_admin),
|
_: dict = Depends(require_admin),
|
||||||
) -> UserResponse:
|
) -> UserResponse:
|
||||||
if body.role not in ALLOWED_ROLES:
|
if body.role not in ALLOWED_ROLES:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||||
detail=f"Role must be one of: {', '.join(sorted(ALLOWED_ROLES))}.",
|
detail=f"Role must be one of: {', '.join(sorted(ALLOWED_ROLES))}.",
|
||||||
)
|
)
|
||||||
user = await set_user_role(user_id, body.role)
|
user = await set_user_role(user_id, body.role)
|
||||||
if user is None:
|
if user is None:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_404_NOT_FOUND, detail="User not found.")
|
status_code=status.HTTP_404_NOT_FOUND, detail="User not found.")
|
||||||
return UserResponse(**user)
|
return UserResponse(**user)
|
||||||
+127
-127
@@ -1,127 +1,127 @@
|
|||||||
"""Authentication service: password hashing, JWT creation/verification, token management."""
|
"""Authentication service: password hashing, JWT creation/verification, token management."""
|
||||||
import os
|
import os
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from jose import JWTError, jwt
|
from jose import JWTError, jwt
|
||||||
from passlib.context import CryptContext
|
from passlib.context import CryptContext
|
||||||
|
|
||||||
from ..db import get_conn, get_write_lock
|
from ..db import get_conn, get_write_lock
|
||||||
|
|
||||||
_pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
_pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||||
|
|
||||||
ACCESS_TOKEN_EXPIRE_MINUTES = 15
|
ACCESS_TOKEN_EXPIRE_MINUTES = 15
|
||||||
REFRESH_TOKEN_EXPIRE_DAYS = 7
|
REFRESH_TOKEN_EXPIRE_DAYS = 7
|
||||||
ALGORITHM = "HS256"
|
ALGORITHM = "HS256"
|
||||||
|
|
||||||
|
|
||||||
def _secret() -> str:
|
def _secret() -> str:
|
||||||
secret = os.getenv("JWT_SECRET")
|
secret = os.getenv("JWT_SECRET")
|
||||||
if not secret:
|
if not secret:
|
||||||
raise RuntimeError("JWT_SECRET environment variable is not set.")
|
raise RuntimeError("JWT_SECRET environment variable is not set.")
|
||||||
return secret
|
return secret
|
||||||
|
|
||||||
|
|
||||||
# --- Password ---
|
# --- Password ---
|
||||||
|
|
||||||
def hash_password(plain: str) -> str:
|
def hash_password(plain: str) -> str:
|
||||||
return _pwd_context.hash(plain)
|
return _pwd_context.hash(plain)
|
||||||
|
|
||||||
|
|
||||||
def verify_password(plain: str, hashed: str) -> bool:
|
def verify_password(plain: str, hashed: str) -> bool:
|
||||||
return _pwd_context.verify(plain, hashed)
|
return _pwd_context.verify(plain, hashed)
|
||||||
|
|
||||||
|
|
||||||
# --- Tokens ---
|
# --- Tokens ---
|
||||||
|
|
||||||
def create_access_token(user_id: str, email: str, role: str) -> str:
|
def create_access_token(user_id: str, email: str, role: str) -> str:
|
||||||
expire = datetime.now(timezone.utc) + \
|
expire = datetime.now(timezone.utc) + \
|
||||||
timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
|
timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
|
||||||
payload = {
|
payload = {
|
||||||
"sub": user_id,
|
"sub": user_id,
|
||||||
"email": email,
|
"email": email,
|
||||||
"role": role,
|
"role": role,
|
||||||
"exp": expire,
|
"exp": expire,
|
||||||
"type": "access",
|
"type": "access",
|
||||||
}
|
}
|
||||||
return jwt.encode(payload, _secret(), algorithm=ALGORITHM)
|
return jwt.encode(payload, _secret(), algorithm=ALGORITHM)
|
||||||
|
|
||||||
|
|
||||||
def create_refresh_token(user_id: str, jti: str) -> str:
|
def create_refresh_token(user_id: str, jti: str) -> str:
|
||||||
expire = datetime.now(timezone.utc) + \
|
expire = datetime.now(timezone.utc) + \
|
||||||
timedelta(days=REFRESH_TOKEN_EXPIRE_DAYS)
|
timedelta(days=REFRESH_TOKEN_EXPIRE_DAYS)
|
||||||
payload = {
|
payload = {
|
||||||
"sub": user_id,
|
"sub": user_id,
|
||||||
"jti": jti,
|
"jti": jti,
|
||||||
"exp": expire,
|
"exp": expire,
|
||||||
"type": "refresh",
|
"type": "refresh",
|
||||||
}
|
}
|
||||||
return jwt.encode(payload, _secret(), algorithm=ALGORITHM)
|
return jwt.encode(payload, _secret(), algorithm=ALGORITHM)
|
||||||
|
|
||||||
|
|
||||||
def decode_token(token: str) -> dict[str, Any]:
|
def decode_token(token: str) -> dict[str, Any]:
|
||||||
"""Decode and validate a JWT. Raises JWTError on failure."""
|
"""Decode and validate a JWT. Raises JWTError on failure."""
|
||||||
return jwt.decode(token, _secret(), algorithms=[ALGORITHM])
|
return jwt.decode(token, _secret(), algorithms=[ALGORITHM])
|
||||||
|
|
||||||
|
|
||||||
# --- Database operations ---
|
# --- Database operations ---
|
||||||
|
|
||||||
async def register_user(email: str, password: str) -> dict[str, Any]:
|
async def register_user(email: str, password: str) -> dict[str, Any]:
|
||||||
"""Insert a new user. Returns the created user row."""
|
"""Insert a new user. Returns the created user row."""
|
||||||
conn = get_conn()
|
conn = get_conn()
|
||||||
lock = get_write_lock()
|
lock = get_write_lock()
|
||||||
sql_check = "SELECT id FROM users WHERE email = ?"
|
sql_check = "SELECT id FROM users WHERE email = ?"
|
||||||
sql_insert = "INSERT INTO users (email, password_hash) VALUES (?, ?)"
|
sql_insert = "INSERT INTO users (email, password_hash) VALUES (?, ?)"
|
||||||
sql_fetch = "SELECT id, email, role FROM users WHERE email = ?"
|
sql_fetch = "SELECT id, email, role FROM users WHERE email = ?"
|
||||||
async with lock:
|
async with lock:
|
||||||
existing = conn.execute(sql_check, [email]).fetchone()
|
existing = conn.execute(sql_check, [email]).fetchone()
|
||||||
if existing:
|
if existing:
|
||||||
raise ValueError("Email already registered.")
|
raise ValueError("Email already registered.")
|
||||||
conn.execute(sql_insert, [email, hash_password(password)],)
|
conn.execute(sql_insert, [email, hash_password(password)],)
|
||||||
row = conn.execute(sql_fetch, [email]).fetchone()
|
row = conn.execute(sql_fetch, [email]).fetchone()
|
||||||
if row is None:
|
if row is None:
|
||||||
raise RuntimeError("Failed to fetch user after registration.")
|
raise RuntimeError("Failed to fetch user after registration.")
|
||||||
return {"id": str(row[0]), "email": row[1], "role": row[2]}
|
return {"id": str(row[0]), "email": row[1], "role": row[2]}
|
||||||
|
|
||||||
|
|
||||||
async def authenticate_user(email: str, password: str) -> dict[str, Any] | None:
|
async def authenticate_user(email: str, password: str) -> dict[str, Any] | None:
|
||||||
"""Return user dict if credentials are valid, else None."""
|
"""Return user dict if credentials are valid, else None."""
|
||||||
conn = get_conn()
|
conn = get_conn()
|
||||||
sql_fetch = "SELECT id, email, password_hash, role FROM users WHERE email = ?"
|
sql_fetch = "SELECT id, email, password_hash, role FROM users WHERE email = ?"
|
||||||
row = conn.execute(sql_fetch, [email]).fetchone()
|
row = conn.execute(sql_fetch, [email]).fetchone()
|
||||||
if row is None or not verify_password(password, row[2]):
|
if row is None or not verify_password(password, row[2]):
|
||||||
return None
|
return None
|
||||||
return {"id": str(row[0]), "email": row[1], "role": row[3]}
|
return {"id": str(row[0]), "email": row[1], "role": row[3]}
|
||||||
|
|
||||||
|
|
||||||
async def store_refresh_token(user_id: str, jti: str) -> None:
|
async def store_refresh_token(user_id: str, jti: str) -> None:
|
||||||
"""Persist a refresh token JTI in the database."""
|
"""Persist a refresh token JTI in the database."""
|
||||||
conn = get_conn()
|
conn = get_conn()
|
||||||
lock = get_write_lock()
|
lock = get_write_lock()
|
||||||
sql_insert = "INSERT INTO refresh_tokens (jti, user_id, expires_at) VALUES (?, ?, ?)"
|
sql_insert = "INSERT INTO refresh_tokens (jti, user_id, expires_at) VALUES (?, ?, ?)"
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
expires_at = datetime.now(timezone.utc) + \
|
expires_at = datetime.now(timezone.utc) + \
|
||||||
timedelta(days=REFRESH_TOKEN_EXPIRE_DAYS)
|
timedelta(days=REFRESH_TOKEN_EXPIRE_DAYS)
|
||||||
async with lock:
|
async with lock:
|
||||||
conn.execute(sql_insert, [jti, user_id, expires_at])
|
conn.execute(sql_insert, [jti, user_id, expires_at])
|
||||||
|
|
||||||
|
|
||||||
async def revoke_refresh_token(jti: str) -> None:
|
async def revoke_refresh_token(jti: str) -> None:
|
||||||
"""Mark a refresh token as revoked."""
|
"""Mark a refresh token as revoked."""
|
||||||
conn = get_conn()
|
conn = get_conn()
|
||||||
lock = get_write_lock()
|
lock = get_write_lock()
|
||||||
sql_update = "UPDATE refresh_tokens SET revoked = true WHERE jti = ?"
|
sql_update = "UPDATE refresh_tokens SET revoked = true WHERE jti = ?"
|
||||||
async with lock:
|
async with lock:
|
||||||
conn.execute(sql_update, [jti])
|
conn.execute(sql_update, [jti])
|
||||||
|
|
||||||
|
|
||||||
async def validate_refresh_token_jti(jti: str, user_id: str) -> bool:
|
async def validate_refresh_token_jti(jti: str, user_id: str) -> bool:
|
||||||
"""Return True if the JTI exists, is not revoked, and belongs to user_id."""
|
"""Return True if the JTI exists, is not revoked, and belongs to user_id."""
|
||||||
conn = get_conn()
|
conn = get_conn()
|
||||||
now = datetime.now(timezone.utc)
|
now = datetime.now(timezone.utc)
|
||||||
sql_select = """
|
sql_select = """
|
||||||
SELECT 1 FROM refresh_tokens
|
SELECT 1 FROM refresh_tokens
|
||||||
WHERE jti = ? AND user_id = ? AND revoked = false AND expires_at > ?
|
WHERE jti = ? AND user_id = ? AND revoked = false AND expires_at > ?
|
||||||
"""
|
"""
|
||||||
row = conn.execute(sql_select, [jti, user_id, now]).fetchone()
|
row = conn.execute(sql_select, [jti, user_id, now]).fetchone()
|
||||||
return row is not None
|
return row is not None
|
||||||
+246
-246
@@ -1,246 +1,246 @@
|
|||||||
"""Model cache service: fetch from OpenRouter, store in DuckDB."""
|
"""Model cache service: fetch from OpenRouter, store in DuckDB."""
|
||||||
import json
|
import json
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import duckdb
|
import duckdb
|
||||||
|
|
||||||
from . import openrouter
|
from . import openrouter
|
||||||
|
|
||||||
CACHE_TTL_HOURS = 24
|
CACHE_TTL_HOURS = 24
|
||||||
|
|
||||||
|
|
||||||
def _normalize_modality(raw: str) -> str:
|
def _normalize_modality(raw: str) -> str:
|
||||||
"""Normalize OpenRouter modality labels to canonical values."""
|
"""Normalize OpenRouter modality labels to canonical values."""
|
||||||
value = (raw or "").strip().lower()
|
value = (raw or "").strip().lower()
|
||||||
if value in {"text", "image", "video", "audio", "embeddings", "embedding"}:
|
if value in {"text", "image", "video", "audio", "embeddings", "embedding"}:
|
||||||
return "embeddings" if value == "embedding" else value
|
return "embeddings" if value == "embedding" else value
|
||||||
if "image" in value:
|
if "image" in value:
|
||||||
return "image"
|
return "image"
|
||||||
if "video" in value:
|
if "video" in value:
|
||||||
return "video"
|
return "video"
|
||||||
if "audio" in value:
|
if "audio" in value:
|
||||||
return "audio"
|
return "audio"
|
||||||
if "embed" in value:
|
if "embed" in value:
|
||||||
return "embeddings"
|
return "embeddings"
|
||||||
return "text"
|
return "text"
|
||||||
|
|
||||||
|
|
||||||
def _parse_modality(raw_modality: str) -> str:
|
def _parse_modality(raw_modality: str) -> str:
|
||||||
"""Extract output modality from OpenRouter architecture.modality string.
|
"""Extract output modality from OpenRouter architecture.modality string.
|
||||||
|
|
||||||
Examples: "text->text", "text+image->text", "text->image", "text->video"
|
Examples: "text->text", "text+image->text", "text->image", "text->video"
|
||||||
"""
|
"""
|
||||||
output = raw_modality.split(
|
output = raw_modality.split(
|
||||||
"->", 1)[-1] if "->" in raw_modality else raw_modality
|
"->", 1)[-1] if "->" in raw_modality else raw_modality
|
||||||
return _normalize_modality(output)
|
return _normalize_modality(output)
|
||||||
|
|
||||||
|
|
||||||
def _extract_output_modality(model: dict[str, Any]) -> str:
|
def _extract_output_modality(model: dict[str, Any]) -> str:
|
||||||
"""Extract output modality using OpenRouter schema, fallback to legacy field."""
|
"""Extract output modality using OpenRouter schema, fallback to legacy field."""
|
||||||
architecture = model.get("architecture") or {}
|
architecture = model.get("architecture") or {}
|
||||||
|
|
||||||
output_modalities = architecture.get(
|
output_modalities = architecture.get(
|
||||||
"output_modalities") or model.get("output_modalities")
|
"output_modalities") or model.get("output_modalities")
|
||||||
if isinstance(output_modalities, list) and output_modalities:
|
if isinstance(output_modalities, list) and output_modalities:
|
||||||
return _normalize_modality(str(output_modalities[0]))
|
return _normalize_modality(str(output_modalities[0]))
|
||||||
|
|
||||||
raw_modality = architecture.get(
|
raw_modality = architecture.get(
|
||||||
"modality") or model.get("modality") or "text->text"
|
"modality") or model.get("modality") or "text->text"
|
||||||
if isinstance(raw_modality, str):
|
if isinstance(raw_modality, str):
|
||||||
return _parse_modality(raw_modality)
|
return _parse_modality(raw_modality)
|
||||||
return "text"
|
return "text"
|
||||||
|
|
||||||
|
|
||||||
async def _fetch_models_for_cache() -> list[dict[str, Any]]:
|
async def _fetch_models_for_cache() -> list[dict[str, Any]]:
|
||||||
"""Fetch broad + modality-specific lists and merge unique models by id."""
|
"""Fetch broad + modality-specific lists and merge unique models by id."""
|
||||||
by_id: dict[str, dict[str, Any]] = {}
|
by_id: dict[str, dict[str, Any]] = {}
|
||||||
|
|
||||||
# Primary fetch: all modalities (per OpenRouter docs).
|
# Primary fetch: all modalities (per OpenRouter docs).
|
||||||
primary = await openrouter.list_models(output_modalities="all")
|
primary = await openrouter.list_models(output_modalities="all")
|
||||||
for model in primary:
|
for model in primary:
|
||||||
model_id = model.get("id")
|
model_id = model.get("id")
|
||||||
if model_id:
|
if model_id:
|
||||||
by_id[model_id] = model
|
by_id[model_id] = model
|
||||||
|
|
||||||
# Warmup fetches: some providers surface better results with explicit modality filter.
|
# Warmup fetches: some providers surface better results with explicit modality filter.
|
||||||
for modality in ("image", "video", "audio", "embeddings", "text"):
|
for modality in ("image", "video", "audio", "embeddings", "text"):
|
||||||
try:
|
try:
|
||||||
subset = await openrouter.list_models(output_modalities=modality)
|
subset = await openrouter.list_models(output_modalities=modality)
|
||||||
except Exception:
|
except Exception:
|
||||||
continue
|
continue
|
||||||
for model in subset:
|
for model in subset:
|
||||||
model_id = model.get("id")
|
model_id = model.get("id")
|
||||||
if model_id and model_id not in by_id:
|
if model_id and model_id not in by_id:
|
||||||
by_id[model_id] = model
|
by_id[model_id] = model
|
||||||
|
|
||||||
return list(by_id.values())
|
return list(by_id.values())
|
||||||
|
|
||||||
|
|
||||||
async def refresh_models_cache(conn: duckdb.DuckDBPyConnection) -> int:
|
async def refresh_models_cache(conn: duckdb.DuckDBPyConnection) -> int:
|
||||||
"""Fetch all models from OpenRouter and replace the cache. Returns count stored."""
|
"""Fetch all models from OpenRouter and replace the cache. Returns count stored."""
|
||||||
raw = await _fetch_models_for_cache()
|
raw = await _fetch_models_for_cache()
|
||||||
# Use naive UTC to avoid DuckDB TIMESTAMP tz-stripping inconsistencies
|
# Use naive UTC to avoid DuckDB TIMESTAMP tz-stripping inconsistencies
|
||||||
now = datetime.now(timezone.utc).replace(tzinfo=None)
|
now = datetime.now(timezone.utc).replace(tzinfo=None)
|
||||||
|
|
||||||
conn.execute("DELETE FROM models_cache")
|
conn.execute("DELETE FROM models_cache")
|
||||||
count = 0
|
count = 0
|
||||||
for m in raw:
|
for m in raw:
|
||||||
modality = _extract_output_modality(m)
|
modality = _extract_output_modality(m)
|
||||||
pricing = m.get("pricing")
|
pricing = m.get("pricing")
|
||||||
model_id = m.get("id", "")
|
model_id = m.get("id", "")
|
||||||
if not model_id:
|
if not model_id:
|
||||||
continue
|
continue
|
||||||
# Full output_modalities array from architecture (for proper modalities param in image gen)
|
# Full output_modalities array from architecture (for proper modalities param in image gen)
|
||||||
architecture = m.get("architecture") or {}
|
architecture = m.get("architecture") or {}
|
||||||
raw_output_modalities: list | None = (
|
raw_output_modalities: list | None = (
|
||||||
architecture.get("output_modalities") or m.get("output_modalities")
|
architecture.get("output_modalities") or m.get("output_modalities")
|
||||||
)
|
)
|
||||||
output_modalities_json: str | None = (
|
output_modalities_json: str | None = (
|
||||||
json.dumps([_normalize_modality(str(v))
|
json.dumps([_normalize_modality(str(v))
|
||||||
for v in raw_output_modalities])
|
for v in raw_output_modalities])
|
||||||
if isinstance(raw_output_modalities, list)
|
if isinstance(raw_output_modalities, list)
|
||||||
else None
|
else None
|
||||||
)
|
)
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"""
|
"""
|
||||||
INSERT INTO models_cache (model_id, name, modality, context_length, pricing, fetched_at, output_modalities)
|
INSERT INTO models_cache (model_id, name, modality, context_length, pricing, fetched_at, output_modalities)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||||
ON CONFLICT (model_id) DO UPDATE SET
|
ON CONFLICT (model_id) DO UPDATE SET
|
||||||
name = excluded.name,
|
name = excluded.name,
|
||||||
modality = excluded.modality,
|
modality = excluded.modality,
|
||||||
context_length = excluded.context_length,
|
context_length = excluded.context_length,
|
||||||
pricing = excluded.pricing,
|
pricing = excluded.pricing,
|
||||||
fetched_at = excluded.fetched_at,
|
fetched_at = excluded.fetched_at,
|
||||||
output_modalities = excluded.output_modalities
|
output_modalities = excluded.output_modalities
|
||||||
""",
|
""",
|
||||||
[
|
[
|
||||||
model_id,
|
model_id,
|
||||||
m.get("name", model_id),
|
m.get("name", model_id),
|
||||||
modality,
|
modality,
|
||||||
m.get("context_length"),
|
m.get("context_length"),
|
||||||
json.dumps(pricing) if pricing else None,
|
json.dumps(pricing) if pricing else None,
|
||||||
now,
|
now,
|
||||||
output_modalities_json,
|
output_modalities_json,
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
count += 1
|
count += 1
|
||||||
return count
|
return count
|
||||||
|
|
||||||
|
|
||||||
def is_cache_stale(conn: duckdb.DuckDBPyConnection) -> bool:
|
def is_cache_stale(conn: duckdb.DuckDBPyConnection) -> bool:
|
||||||
"""Return True if cache is empty or last fetched more than CACHE_TTL_HOURS ago."""
|
"""Return True if cache is empty or last fetched more than CACHE_TTL_HOURS ago."""
|
||||||
row = conn.execute("SELECT MAX(fetched_at) FROM models_cache").fetchone()
|
row = conn.execute("SELECT MAX(fetched_at) FROM models_cache").fetchone()
|
||||||
if not row or row[0] is None:
|
if not row or row[0] is None:
|
||||||
return True
|
return True
|
||||||
last_fetched = row[0]
|
last_fetched = row[0]
|
||||||
# DuckDB TIMESTAMP is always naive; compare against naive UTC
|
# DuckDB TIMESTAMP is always naive; compare against naive UTC
|
||||||
if last_fetched.tzinfo is not None:
|
if last_fetched.tzinfo is not None:
|
||||||
last_fetched = last_fetched.replace(tzinfo=None)
|
last_fetched = last_fetched.replace(tzinfo=None)
|
||||||
now_naive = datetime.now(timezone.utc).replace(tzinfo=None)
|
now_naive = datetime.now(timezone.utc).replace(tzinfo=None)
|
||||||
return now_naive - last_fetched > timedelta(hours=CACHE_TTL_HOURS)
|
return now_naive - last_fetched > timedelta(hours=CACHE_TTL_HOURS)
|
||||||
|
|
||||||
|
|
||||||
def get_cached_models(
|
def get_cached_models(
|
||||||
conn: duckdb.DuckDBPyConnection,
|
conn: duckdb.DuckDBPyConnection,
|
||||||
modality: str | None = None,
|
modality: str | None = None,
|
||||||
) -> list[dict[str, Any]]:
|
) -> list[dict[str, Any]]:
|
||||||
"""Return cached models, optionally filtered by modality, ordered by name."""
|
"""Return cached models, optionally filtered by modality, ordered by name."""
|
||||||
if modality:
|
if modality:
|
||||||
rows = conn.execute(
|
rows = conn.execute(
|
||||||
"""
|
"""
|
||||||
SELECT model_id, name, modality, context_length, pricing
|
SELECT model_id, name, modality, context_length, pricing
|
||||||
FROM models_cache
|
FROM models_cache
|
||||||
WHERE modality = ?
|
WHERE modality = ?
|
||||||
ORDER BY name
|
ORDER BY name
|
||||||
""",
|
""",
|
||||||
[modality],
|
[modality],
|
||||||
).fetchall()
|
).fetchall()
|
||||||
else:
|
else:
|
||||||
rows = conn.execute(
|
rows = conn.execute(
|
||||||
"""
|
"""
|
||||||
SELECT model_id, name, modality, context_length, pricing
|
SELECT model_id, name, modality, context_length, pricing
|
||||||
FROM models_cache
|
FROM models_cache
|
||||||
ORDER BY name
|
ORDER BY name
|
||||||
"""
|
"""
|
||||||
).fetchall()
|
).fetchall()
|
||||||
|
|
||||||
result = []
|
result = []
|
||||||
for row in rows:
|
for row in rows:
|
||||||
pricing = None
|
pricing = None
|
||||||
if row[4]:
|
if row[4]:
|
||||||
try:
|
try:
|
||||||
pricing = json.loads(row[4])
|
pricing = json.loads(row[4])
|
||||||
except (json.JSONDecodeError, TypeError):
|
except (json.JSONDecodeError, TypeError):
|
||||||
pricing = None
|
pricing = None
|
||||||
result.append({
|
result.append({
|
||||||
"id": row[0],
|
"id": row[0],
|
||||||
"name": row[1],
|
"name": row[1],
|
||||||
"modality": row[2],
|
"modality": row[2],
|
||||||
"context_length": row[3],
|
"context_length": row[3],
|
||||||
"pricing": pricing,
|
"pricing": pricing,
|
||||||
})
|
})
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
def get_model_output_modalities(
|
def get_model_output_modalities(
|
||||||
conn: duckdb.DuckDBPyConnection,
|
conn: duckdb.DuckDBPyConnection,
|
||||||
model_id: str,
|
model_id: str,
|
||||||
) -> list[str]:
|
) -> list[str]:
|
||||||
"""Return output_modalities list for a model; empty list if not found."""
|
"""Return output_modalities list for a model; empty list if not found."""
|
||||||
row = conn.execute(
|
row = conn.execute(
|
||||||
"SELECT output_modalities FROM models_cache WHERE model_id = ?",
|
"SELECT output_modalities FROM models_cache WHERE model_id = ?",
|
||||||
[model_id],
|
[model_id],
|
||||||
).fetchone()
|
).fetchone()
|
||||||
if not row or not row[0]:
|
if not row or not row[0]:
|
||||||
return []
|
return []
|
||||||
try:
|
try:
|
||||||
return json.loads(row[0])
|
return json.loads(row[0])
|
||||||
except (json.JSONDecodeError, TypeError):
|
except (json.JSONDecodeError, TypeError):
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
|
||||||
def get_cache_status(conn: duckdb.DuckDBPyConnection) -> dict[str, Any]:
|
def get_cache_status(conn: duckdb.DuckDBPyConnection) -> dict[str, Any]:
|
||||||
"""Return cache last update time and model count."""
|
"""Return cache last update time and model count."""
|
||||||
row = conn.execute(
|
row = conn.execute(
|
||||||
"SELECT MAX(fetched_at), COUNT(*) FROM models_cache"
|
"SELECT MAX(fetched_at), COUNT(*) FROM models_cache"
|
||||||
).fetchone()
|
).fetchone()
|
||||||
last_updated, model_count = (row[0], row[1]) if row else (None, 0)
|
last_updated, model_count = (row[0], row[1]) if row else (None, 0)
|
||||||
return {"last_updated": last_updated, "model_count": model_count}
|
return {"last_updated": last_updated, "model_count": model_count}
|
||||||
|
|
||||||
|
|
||||||
def mark_timed_out_video_jobs(conn: duckdb.DuckDBPyConnection, timeout_minutes: int = 120) -> int:
|
def mark_timed_out_video_jobs(conn: duckdb.DuckDBPyConnection, timeout_minutes: int = 120) -> int:
|
||||||
"""Mark video jobs that have been in 'queued' or 'processing' status for too long as 'failed'.
|
"""Mark video jobs that have been in 'queued' or 'processing' status for too long as 'failed'.
|
||||||
|
|
||||||
Returns the number of jobs marked as timed out.
|
Returns the number of jobs marked as timed out.
|
||||||
"""
|
"""
|
||||||
timeout_threshold = datetime.now(
|
timeout_threshold = datetime.now(
|
||||||
timezone.utc) - timedelta(minutes=timeout_minutes)
|
timezone.utc) - timedelta(minutes=timeout_minutes)
|
||||||
|
|
||||||
# Find timed out jobs
|
# Find timed out jobs
|
||||||
timed_out_rows = conn.execute(
|
timed_out_rows = conn.execute(
|
||||||
"""
|
"""
|
||||||
SELECT id FROM generated_videos
|
SELECT id FROM generated_videos
|
||||||
WHERE status IN ('queued', 'processing')
|
WHERE status IN ('queued', 'processing')
|
||||||
AND updated_at < ?
|
AND updated_at < ?
|
||||||
""",
|
""",
|
||||||
[timeout_threshold]
|
[timeout_threshold]
|
||||||
).fetchall()
|
).fetchall()
|
||||||
|
|
||||||
if not timed_out_rows:
|
if not timed_out_rows:
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
job_ids = [row[0] for row in timed_out_rows]
|
job_ids = [row[0] for row in timed_out_rows]
|
||||||
placeholders = ",".join(["?"] * len(job_ids))
|
placeholders = ",".join(["?"] * len(job_ids))
|
||||||
|
|
||||||
# Update them to failed
|
# Update them to failed
|
||||||
conn.execute(
|
conn.execute(
|
||||||
f"""
|
f"""
|
||||||
UPDATE generated_videos
|
UPDATE generated_videos
|
||||||
SET status = 'failed', updated_at = ?
|
SET status = 'failed', updated_at = ?
|
||||||
WHERE id IN ({placeholders})
|
WHERE id IN ({placeholders})
|
||||||
""",
|
""",
|
||||||
[datetime.now(timezone.utc)] + job_ids
|
[datetime.now(timezone.utc)] + job_ids
|
||||||
)
|
)
|
||||||
|
|
||||||
return len(job_ids)
|
return len(job_ids)
|
||||||
+213
-213
@@ -1,213 +1,213 @@
|
|||||||
"""OpenRouter API client (OpenAI-compatible interface)."""
|
"""OpenRouter API client (OpenAI-compatible interface)."""
|
||||||
import os
|
import os
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1"
|
OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1"
|
||||||
|
|
||||||
|
|
||||||
def _api_key() -> str:
|
def _api_key() -> str:
|
||||||
key = os.getenv("OPENROUTER_API_KEY")
|
key = os.getenv("OPENROUTER_API_KEY")
|
||||||
if not key:
|
if not key:
|
||||||
raise RuntimeError(
|
raise RuntimeError(
|
||||||
"OPENROUTER_API_KEY environment variable is not set.")
|
"OPENROUTER_API_KEY environment variable is not set.")
|
||||||
return key
|
return key
|
||||||
|
|
||||||
|
|
||||||
def _headers() -> dict[str, str]:
|
def _headers() -> dict[str, str]:
|
||||||
return {
|
return {
|
||||||
"Authorization": f"Bearer {_api_key()}",
|
"Authorization": f"Bearer {_api_key()}",
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
"HTTP-Referer": os.getenv("APP_URL", "https://ai.allucanget.biz"),
|
"HTTP-Referer": os.getenv("APP_URL", "https://ai.allucanget.biz"),
|
||||||
"X-Title": os.getenv("APP_NAME", "All You Can GET AI"),
|
"X-Title": os.getenv("APP_NAME", "All You Can GET AI"),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
async def list_models(
|
async def list_models(
|
||||||
output_modalities: str = "all",
|
output_modalities: str = "all",
|
||||||
category: str | None = None,
|
category: str | None = None,
|
||||||
supported_parameters: str | None = None,
|
supported_parameters: str | None = None,
|
||||||
) -> list[dict[str, Any]]:
|
) -> list[dict[str, Any]]:
|
||||||
"""Return available models from OpenRouter.
|
"""Return available models from OpenRouter.
|
||||||
|
|
||||||
Docs: GET /models supports query filters like output_modalities.
|
Docs: GET /models supports query filters like output_modalities.
|
||||||
"""
|
"""
|
||||||
base_url = os.getenv("OPENROUTER_BASE_URL", OPENROUTER_BASE_URL)
|
base_url = os.getenv("OPENROUTER_BASE_URL", OPENROUTER_BASE_URL)
|
||||||
params: dict[str, str] = {"output_modalities": output_modalities}
|
params: dict[str, str] = {"output_modalities": output_modalities}
|
||||||
if category:
|
if category:
|
||||||
params["category"] = category
|
params["category"] = category
|
||||||
if supported_parameters:
|
if supported_parameters:
|
||||||
params["supported_parameters"] = supported_parameters
|
params["supported_parameters"] = supported_parameters
|
||||||
|
|
||||||
async with httpx.AsyncClient(timeout=15) as client:
|
async with httpx.AsyncClient(timeout=15) as client:
|
||||||
resp = client.build_request(
|
resp = client.build_request(
|
||||||
"GET", f"{base_url}/models", headers=_headers(), params=params)
|
"GET", f"{base_url}/models", headers=_headers(), params=params)
|
||||||
response = await client.send(resp)
|
response = await client.send(resp)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
return response.json().get("data", [])
|
return response.json().get("data", [])
|
||||||
|
|
||||||
|
|
||||||
async def chat_completion(
|
async def chat_completion(
|
||||||
model: str,
|
model: str,
|
||||||
messages: list[dict[str, str]],
|
messages: list[dict[str, str]],
|
||||||
temperature: float = 0.7,
|
temperature: float = 0.7,
|
||||||
max_tokens: int = 1024,
|
max_tokens: int = 1024,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Send a chat completion request to OpenRouter."""
|
"""Send a chat completion request to OpenRouter."""
|
||||||
base_url = os.getenv("OPENROUTER_BASE_URL", OPENROUTER_BASE_URL)
|
base_url = os.getenv("OPENROUTER_BASE_URL", OPENROUTER_BASE_URL)
|
||||||
payload = {
|
payload = {
|
||||||
"model": model,
|
"model": model,
|
||||||
"messages": messages,
|
"messages": messages,
|
||||||
"temperature": temperature,
|
"temperature": temperature,
|
||||||
"max_tokens": max_tokens,
|
"max_tokens": max_tokens,
|
||||||
}
|
}
|
||||||
async with httpx.AsyncClient(timeout=60) as client:
|
async with httpx.AsyncClient(timeout=60) as client:
|
||||||
resp = client.build_request(
|
resp = client.build_request(
|
||||||
"POST", f"{base_url}/chat/completions", headers=_headers(), json=payload
|
"POST", f"{base_url}/chat/completions", headers=_headers(), json=payload
|
||||||
)
|
)
|
||||||
response = await client.send(resp)
|
response = await client.send(resp)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
return response.json()
|
return response.json()
|
||||||
|
|
||||||
|
|
||||||
async def generate_image(
|
async def generate_image(
|
||||||
model: str,
|
model: str,
|
||||||
prompt: str,
|
prompt: str,
|
||||||
n: int = 1,
|
n: int = 1,
|
||||||
size: str = "1024x1024",
|
size: str = "1024x1024",
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Request image generation via OpenRouter /images/generations."""
|
"""Request image generation via OpenRouter /images/generations."""
|
||||||
base_url = os.getenv("OPENROUTER_BASE_URL", OPENROUTER_BASE_URL)
|
base_url = os.getenv("OPENROUTER_BASE_URL", OPENROUTER_BASE_URL)
|
||||||
payload = {"model": model, "prompt": prompt, "n": n, "size": size}
|
payload = {"model": model, "prompt": prompt, "n": n, "size": size}
|
||||||
async with httpx.AsyncClient(timeout=120) as client:
|
async with httpx.AsyncClient(timeout=120) as client:
|
||||||
resp = client.build_request(
|
resp = client.build_request(
|
||||||
"POST", f"{base_url}/images/generations", headers=_headers(), json=payload
|
"POST", f"{base_url}/images/generations", headers=_headers(), json=payload
|
||||||
)
|
)
|
||||||
response = await client.send(resp)
|
response = await client.send(resp)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
return response.json()
|
return response.json()
|
||||||
|
|
||||||
|
|
||||||
async def generate_video(
|
async def generate_video(
|
||||||
model: str,
|
model: str,
|
||||||
prompt: str,
|
prompt: str,
|
||||||
duration_seconds: int | None = None,
|
duration_seconds: int | None = None,
|
||||||
aspect_ratio: str = "16:9",
|
aspect_ratio: str = "16:9",
|
||||||
resolution: str | None = None,
|
resolution: str | None = None,
|
||||||
generate_audio: bool | None = None,
|
generate_audio: bool | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Request text-to-video generation via OpenRouter POST /videos."""
|
"""Request text-to-video generation via OpenRouter POST /videos."""
|
||||||
base_url = os.getenv("OPENROUTER_BASE_URL", OPENROUTER_BASE_URL)
|
base_url = os.getenv("OPENROUTER_BASE_URL", OPENROUTER_BASE_URL)
|
||||||
payload: dict[str, Any] = {
|
payload: dict[str, Any] = {
|
||||||
"model": model,
|
"model": model,
|
||||||
"prompt": prompt,
|
"prompt": prompt,
|
||||||
"aspect_ratio": aspect_ratio,
|
"aspect_ratio": aspect_ratio,
|
||||||
}
|
}
|
||||||
if duration_seconds is not None:
|
if duration_seconds is not None:
|
||||||
# API uses 'duration' not 'duration_seconds'
|
# API uses 'duration' not 'duration_seconds'
|
||||||
payload["duration"] = duration_seconds
|
payload["duration"] = duration_seconds
|
||||||
if resolution is not None:
|
if resolution is not None:
|
||||||
payload["resolution"] = resolution
|
payload["resolution"] = resolution
|
||||||
if generate_audio is not None:
|
if generate_audio is not None:
|
||||||
payload["generate_audio"] = generate_audio
|
payload["generate_audio"] = generate_audio
|
||||||
async with httpx.AsyncClient(timeout=120) as client:
|
async with httpx.AsyncClient(timeout=120) as client:
|
||||||
resp = client.build_request(
|
resp = client.build_request(
|
||||||
"POST", f"{base_url}/videos", headers=_headers(), json=payload
|
"POST", f"{base_url}/videos", headers=_headers(), json=payload
|
||||||
)
|
)
|
||||||
response = await client.send(resp)
|
response = await client.send(resp)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
return response.json()
|
return response.json()
|
||||||
|
|
||||||
|
|
||||||
async def generate_video_from_image(
|
async def generate_video_from_image(
|
||||||
model: str,
|
model: str,
|
||||||
image_url: str,
|
image_url: str,
|
||||||
prompt: str,
|
prompt: str,
|
||||||
duration_seconds: int | None = None,
|
duration_seconds: int | None = None,
|
||||||
aspect_ratio: str = "16:9",
|
aspect_ratio: str = "16:9",
|
||||||
resolution: str | None = None,
|
resolution: str | None = None,
|
||||||
generate_audio: bool | None = None,
|
generate_audio: bool | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Request image-to-video generation via OpenRouter POST /videos.
|
"""Request image-to-video generation via OpenRouter POST /videos.
|
||||||
|
|
||||||
Uses frame_images array with first_frame as per OpenRouter API spec.
|
Uses frame_images array with first_frame as per OpenRouter API spec.
|
||||||
"""
|
"""
|
||||||
base_url = os.getenv("OPENROUTER_BASE_URL", OPENROUTER_BASE_URL)
|
base_url = os.getenv("OPENROUTER_BASE_URL", OPENROUTER_BASE_URL)
|
||||||
payload: dict[str, Any] = {
|
payload: dict[str, Any] = {
|
||||||
"model": model,
|
"model": model,
|
||||||
"prompt": prompt,
|
"prompt": prompt,
|
||||||
"aspect_ratio": aspect_ratio,
|
"aspect_ratio": aspect_ratio,
|
||||||
"frame_images": [
|
"frame_images": [
|
||||||
{
|
{
|
||||||
"type": "image_url",
|
"type": "image_url",
|
||||||
"image_url": {"url": image_url},
|
"image_url": {"url": image_url},
|
||||||
"frame_type": "first_frame",
|
"frame_type": "first_frame",
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
if duration_seconds is not None:
|
if duration_seconds is not None:
|
||||||
payload["duration"] = duration_seconds
|
payload["duration"] = duration_seconds
|
||||||
if resolution is not None:
|
if resolution is not None:
|
||||||
payload["resolution"] = resolution
|
payload["resolution"] = resolution
|
||||||
if generate_audio is not None:
|
if generate_audio is not None:
|
||||||
payload["generate_audio"] = generate_audio
|
payload["generate_audio"] = generate_audio
|
||||||
async with httpx.AsyncClient(timeout=120) as client:
|
async with httpx.AsyncClient(timeout=120) as client:
|
||||||
resp = client.build_request(
|
resp = client.build_request(
|
||||||
"POST", f"{base_url}/videos", headers=_headers(), json=payload
|
"POST", f"{base_url}/videos", headers=_headers(), json=payload
|
||||||
)
|
)
|
||||||
response = await client.send(resp)
|
response = await client.send(resp)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
return response.json()
|
return response.json()
|
||||||
|
|
||||||
|
|
||||||
async def poll_video_status(polling_url: str) -> dict[str, Any]:
|
async def poll_video_status(polling_url: str) -> dict[str, Any]:
|
||||||
"""Check the status of a video generation job via its polling_url."""
|
"""Check the status of a video generation job via its polling_url."""
|
||||||
async with httpx.AsyncClient(timeout=15) as client:
|
async with httpx.AsyncClient(timeout=15) as client:
|
||||||
resp = client.build_request("GET", polling_url, headers=_headers())
|
resp = client.build_request("GET", polling_url, headers=_headers())
|
||||||
response = await client.send(resp)
|
response = await client.send(resp)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
return response.json()
|
return response.json()
|
||||||
|
|
||||||
|
|
||||||
async def list_video_models() -> list[dict[str, Any]]:
|
async def list_video_models() -> list[dict[str, Any]]:
|
||||||
"""Return video generation models from the dedicated /videos/models endpoint."""
|
"""Return video generation models from the dedicated /videos/models endpoint."""
|
||||||
base_url = os.getenv("OPENROUTER_BASE_URL", OPENROUTER_BASE_URL)
|
base_url = os.getenv("OPENROUTER_BASE_URL", OPENROUTER_BASE_URL)
|
||||||
async with httpx.AsyncClient(timeout=15) as client:
|
async with httpx.AsyncClient(timeout=15) as client:
|
||||||
resp = client.build_request(
|
resp = client.build_request(
|
||||||
"GET", f"{base_url}/videos/models", headers=_headers()
|
"GET", f"{base_url}/videos/models", headers=_headers()
|
||||||
)
|
)
|
||||||
response = await client.send(resp)
|
response = await client.send(resp)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
return response.json().get("data", [])
|
return response.json().get("data", [])
|
||||||
|
|
||||||
|
|
||||||
async def generate_image_chat(
|
async def generate_image_chat(
|
||||||
model: str,
|
model: str,
|
||||||
prompt: str,
|
prompt: str,
|
||||||
modalities: list[str] | None = None,
|
modalities: list[str] | None = None,
|
||||||
image_config: dict[str, Any] | None = None,
|
image_config: dict[str, Any] | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Request image generation via Chat Completions with modalities.
|
"""Request image generation via Chat Completions with modalities.
|
||||||
|
|
||||||
Used by models like FLUX.2 Klein 4B and GPT-5 Image Mini that output
|
Used by models like FLUX.2 Klein 4B and GPT-5 Image Mini that output
|
||||||
images through the chat completions endpoint rather than /images/generations.
|
images through the chat completions endpoint rather than /images/generations.
|
||||||
"""
|
"""
|
||||||
base_url = os.getenv("OPENROUTER_BASE_URL", OPENROUTER_BASE_URL)
|
base_url = os.getenv("OPENROUTER_BASE_URL", OPENROUTER_BASE_URL)
|
||||||
if modalities is None:
|
if modalities is None:
|
||||||
# Image-only models (FLUX) vs multimodal (GPT-5 Image Mini)
|
# Image-only models (FLUX) vs multimodal (GPT-5 Image Mini)
|
||||||
modalities = ["image"]
|
modalities = ["image"]
|
||||||
payload: dict[str, Any] = {
|
payload: dict[str, Any] = {
|
||||||
"model": model,
|
"model": model,
|
||||||
"messages": [{"role": "user", "content": prompt}],
|
"messages": [{"role": "user", "content": prompt}],
|
||||||
"modalities": modalities,
|
"modalities": modalities,
|
||||||
}
|
}
|
||||||
if image_config:
|
if image_config:
|
||||||
payload["image_config"] = image_config
|
payload["image_config"] = image_config
|
||||||
async with httpx.AsyncClient(timeout=120) as client:
|
async with httpx.AsyncClient(timeout=120) as client:
|
||||||
resp = client.build_request(
|
resp = client.build_request(
|
||||||
"POST", f"{base_url}/chat/completions", headers=_headers(), json=payload
|
"POST", f"{base_url}/chat/completions", headers=_headers(), json=payload
|
||||||
)
|
)
|
||||||
response = await client.send(resp)
|
response = await client.send(resp)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
return response.json()
|
return response.json()
|
||||||
@@ -1,86 +1,86 @@
|
|||||||
"""User management service: CRUD helpers against DuckDB."""
|
"""User management service: CRUD helpers against DuckDB."""
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from ..db import get_conn, get_write_lock
|
from ..db import get_conn, get_write_lock
|
||||||
from .auth import hash_password
|
from .auth import hash_password
|
||||||
|
|
||||||
|
|
||||||
async def get_user(user_id: str) -> dict[str, Any] | None:
|
async def get_user(user_id: str) -> dict[str, Any] | None:
|
||||||
conn = get_conn()
|
conn = get_conn()
|
||||||
row = conn.execute(
|
row = conn.execute(
|
||||||
"SELECT id, email, role FROM users WHERE id = ?", [user_id]
|
"SELECT id, email, role FROM users WHERE id = ?", [user_id]
|
||||||
).fetchone()
|
).fetchone()
|
||||||
if row is None:
|
if row is None:
|
||||||
return None
|
return None
|
||||||
return {"id": str(row[0]), "email": row[1], "role": row[2]}
|
return {"id": str(row[0]), "email": row[1], "role": row[2]}
|
||||||
|
|
||||||
|
|
||||||
async def list_users() -> list[dict[str, Any]]:
|
async def list_users() -> list[dict[str, Any]]:
|
||||||
conn = get_conn()
|
conn = get_conn()
|
||||||
rows = conn.execute(
|
rows = conn.execute(
|
||||||
"SELECT id, email, role FROM users ORDER BY email").fetchall()
|
"SELECT id, email, role FROM users ORDER BY email").fetchall()
|
||||||
return [{"id": str(r[0]), "email": r[1], "role": r[2]} for r in rows]
|
return [{"id": str(r[0]), "email": r[1], "role": r[2]} for r in rows]
|
||||||
|
|
||||||
|
|
||||||
async def update_user(
|
async def update_user(
|
||||||
user_id: str,
|
user_id: str,
|
||||||
email: str | None = None,
|
email: str | None = None,
|
||||||
password: str | None = None,
|
password: str | None = None,
|
||||||
) -> dict[str, Any] | None:
|
) -> dict[str, Any] | None:
|
||||||
"""Update email and/or password. Returns updated user or None if not found."""
|
"""Update email and/or password. Returns updated user or None if not found."""
|
||||||
conn = get_conn()
|
conn = get_conn()
|
||||||
lock = get_write_lock()
|
lock = get_write_lock()
|
||||||
|
|
||||||
if email is None and password is None:
|
if email is None and password is None:
|
||||||
return await get_user(user_id)
|
return await get_user(user_id)
|
||||||
|
|
||||||
async with lock:
|
async with lock:
|
||||||
if email is not None:
|
if email is not None:
|
||||||
existing = conn.execute(
|
existing = conn.execute(
|
||||||
"SELECT id FROM users WHERE email = ? AND id != ?", [
|
"SELECT id FROM users WHERE email = ? AND id != ?", [
|
||||||
email, user_id]
|
email, user_id]
|
||||||
).fetchone()
|
).fetchone()
|
||||||
if existing:
|
if existing:
|
||||||
raise ValueError("Email already in use.")
|
raise ValueError("Email already in use.")
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"UPDATE users SET email = ?, updated_at = now() WHERE id = ?",
|
"UPDATE users SET email = ?, updated_at = now() WHERE id = ?",
|
||||||
[email, user_id],
|
[email, user_id],
|
||||||
)
|
)
|
||||||
if password is not None:
|
if password is not None:
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"UPDATE users SET password_hash = ?, updated_at = now() WHERE id = ?",
|
"UPDATE users SET password_hash = ?, updated_at = now() WHERE id = ?",
|
||||||
[hash_password(password), user_id],
|
[hash_password(password), user_id],
|
||||||
)
|
)
|
||||||
row = conn.execute(
|
row = conn.execute(
|
||||||
"SELECT id, email, role FROM users WHERE id = ?", [user_id]
|
"SELECT id, email, role FROM users WHERE id = ?", [user_id]
|
||||||
).fetchone()
|
).fetchone()
|
||||||
|
|
||||||
if row is None:
|
if row is None:
|
||||||
return None
|
return None
|
||||||
return {"id": str(row[0]), "email": row[1], "role": row[2]}
|
return {"id": str(row[0]), "email": row[1], "role": row[2]}
|
||||||
|
|
||||||
|
|
||||||
async def set_user_role(user_id: str, role: str) -> dict[str, Any] | None:
|
async def set_user_role(user_id: str, role: str) -> dict[str, Any] | None:
|
||||||
conn = get_conn()
|
conn = get_conn()
|
||||||
lock = get_write_lock()
|
lock = get_write_lock()
|
||||||
async with lock:
|
async with lock:
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"UPDATE users SET role = ?, updated_at = now() WHERE id = ?",
|
"UPDATE users SET role = ?, updated_at = now() WHERE id = ?",
|
||||||
[role, user_id],
|
[role, user_id],
|
||||||
)
|
)
|
||||||
row = conn.execute(
|
row = conn.execute(
|
||||||
"SELECT id, email, role FROM users WHERE id = ?", [user_id]
|
"SELECT id, email, role FROM users WHERE id = ?", [user_id]
|
||||||
).fetchone()
|
).fetchone()
|
||||||
if row is None:
|
if row is None:
|
||||||
return None
|
return None
|
||||||
return {"id": str(row[0]), "email": row[1], "role": row[2]}
|
return {"id": str(row[0]), "email": row[1], "role": row[2]}
|
||||||
|
|
||||||
|
|
||||||
async def delete_user(user_id: str) -> bool:
|
async def delete_user(user_id: str) -> bool:
|
||||||
"""Delete user and their refresh tokens. Returns True if a row was removed."""
|
"""Delete user and their refresh tokens. Returns True if a row was removed."""
|
||||||
conn = get_conn()
|
conn = get_conn()
|
||||||
lock = get_write_lock()
|
lock = get_write_lock()
|
||||||
async with lock:
|
async with lock:
|
||||||
conn.execute("DELETE FROM refresh_tokens WHERE user_id = ?", [user_id])
|
conn.execute("DELETE FROM refresh_tokens WHERE user_id = ?", [user_id])
|
||||||
conn.execute("DELETE FROM users WHERE id = ?", [user_id])
|
conn.execute("DELETE FROM users WHERE id = ?", [user_id])
|
||||||
return True
|
return True
|
||||||
@@ -1,159 +1,159 @@
|
|||||||
"""Background worker: processes queued/processing video generation jobs."""
|
"""Background worker: processes queued/processing video generation jobs."""
|
||||||
import asyncio
|
import asyncio
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
import duckdb
|
import duckdb
|
||||||
|
|
||||||
from . import openrouter
|
from . import openrouter
|
||||||
from .models import mark_timed_out_video_jobs
|
from .models import mark_timed_out_video_jobs
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# Interval between worker ticks (seconds)
|
# Interval between worker ticks (seconds)
|
||||||
WORKER_INTERVAL = 15
|
WORKER_INTERVAL = 15
|
||||||
# Jobs to process per tick (prevents unbounded bursts)
|
# Jobs to process per tick (prevents unbounded bursts)
|
||||||
BATCH_SIZE = 5
|
BATCH_SIZE = 5
|
||||||
|
|
||||||
|
|
||||||
async def process_queued_jobs(conn: duckdb.DuckDBPyConnection, lock: asyncio.Lock) -> int:
|
async def process_queued_jobs(conn: duckdb.DuckDBPyConnection, lock: asyncio.Lock) -> int:
|
||||||
"""Submit queued jobs to OpenRouter and transition them to 'processing'."""
|
"""Submit queued jobs to OpenRouter and transition them to 'processing'."""
|
||||||
rows = conn.execute(
|
rows = conn.execute(
|
||||||
"""SELECT id, generation_type, request_params
|
"""SELECT id, generation_type, request_params
|
||||||
FROM generated_videos
|
FROM generated_videos
|
||||||
WHERE status = 'queued' AND request_params IS NOT NULL
|
WHERE status = 'queued' AND request_params IS NOT NULL
|
||||||
ORDER BY created_at ASC
|
ORDER BY created_at ASC
|
||||||
LIMIT ?""",
|
LIMIT ?""",
|
||||||
[BATCH_SIZE],
|
[BATCH_SIZE],
|
||||||
).fetchall()
|
).fetchall()
|
||||||
|
|
||||||
processed = 0
|
processed = 0
|
||||||
for row in rows:
|
for row in rows:
|
||||||
db_id, generation_type, raw_params = str(row[0]), row[1], row[2]
|
db_id, generation_type, raw_params = str(row[0]), row[1], row[2]
|
||||||
try:
|
try:
|
||||||
params = json.loads(raw_params)
|
params = json.loads(raw_params)
|
||||||
except (json.JSONDecodeError, TypeError):
|
except (json.JSONDecodeError, TypeError):
|
||||||
logger.error("Bad request_params for video job %s", db_id)
|
logger.error("Bad request_params for video job %s", db_id)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if generation_type == "image_to_video":
|
if generation_type == "image_to_video":
|
||||||
result = await openrouter.generate_video_from_image(
|
result = await openrouter.generate_video_from_image(
|
||||||
model=params["model"],
|
model=params["model"],
|
||||||
image_url=params.get("image_url", ""),
|
image_url=params.get("image_url", ""),
|
||||||
prompt=params.get("prompt", ""),
|
prompt=params.get("prompt", ""),
|
||||||
duration_seconds=params.get("duration_seconds"),
|
duration_seconds=params.get("duration_seconds"),
|
||||||
aspect_ratio=params.get("aspect_ratio", "16:9"),
|
aspect_ratio=params.get("aspect_ratio", "16:9"),
|
||||||
resolution=params.get("resolution"),
|
resolution=params.get("resolution"),
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
result = await openrouter.generate_video(
|
result = await openrouter.generate_video(
|
||||||
model=params["model"],
|
model=params["model"],
|
||||||
prompt=params.get("prompt", ""),
|
prompt=params.get("prompt", ""),
|
||||||
duration_seconds=params.get("duration_seconds"),
|
duration_seconds=params.get("duration_seconds"),
|
||||||
aspect_ratio=params.get("aspect_ratio", "16:9"),
|
aspect_ratio=params.get("aspect_ratio", "16:9"),
|
||||||
resolution=params.get("resolution"),
|
resolution=params.get("resolution"),
|
||||||
)
|
)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.warning("OpenRouter call failed for job %s: %s", db_id, exc)
|
logger.warning("OpenRouter call failed for job %s: %s", db_id, exc)
|
||||||
now = datetime.now(timezone.utc).replace(tzinfo=None)
|
now = datetime.now(timezone.utc).replace(tzinfo=None)
|
||||||
async with lock:
|
async with lock:
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"UPDATE generated_videos SET status = 'failed', error = ?, updated_at = ? WHERE id = ?",
|
"UPDATE generated_videos SET status = 'failed', error = ?, updated_at = ? WHERE id = ?",
|
||||||
[str(exc), now, db_id],
|
[str(exc), now, db_id],
|
||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
job_id = result.get("id", "")
|
job_id = result.get("id", "")
|
||||||
polling_url = result.get("polling_url")
|
polling_url = result.get("polling_url")
|
||||||
new_status = result.get("status", "processing")
|
new_status = result.get("status", "processing")
|
||||||
# Normalise terminal statuses returned immediately (rare but possible)
|
# Normalise terminal statuses returned immediately (rare but possible)
|
||||||
if new_status not in ("queued", "processing", "completed", "failed", "cancelled"):
|
if new_status not in ("queued", "processing", "completed", "failed", "cancelled"):
|
||||||
new_status = "processing"
|
new_status = "processing"
|
||||||
|
|
||||||
urls = result.get("unsigned_urls") or result.get("video_urls")
|
urls = result.get("unsigned_urls") or result.get("video_urls")
|
||||||
video_url = (urls or [None])[0]
|
video_url = (urls or [None])[0]
|
||||||
now = datetime.now(timezone.utc).replace(tzinfo=None)
|
now = datetime.now(timezone.utc).replace(tzinfo=None)
|
||||||
|
|
||||||
async with lock:
|
async with lock:
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"""UPDATE generated_videos
|
"""UPDATE generated_videos
|
||||||
SET job_id = ?, polling_url = ?, status = ?, video_url = ?, updated_at = ?
|
SET job_id = ?, polling_url = ?, status = ?, video_url = ?, updated_at = ?
|
||||||
WHERE id = ?""",
|
WHERE id = ?""",
|
||||||
[job_id, polling_url, new_status, video_url, now, db_id],
|
[job_id, polling_url, new_status, video_url, now, db_id],
|
||||||
)
|
)
|
||||||
processed += 1
|
processed += 1
|
||||||
logger.info("Video job %s → %s (provider id: %s)",
|
logger.info("Video job %s → %s (provider id: %s)",
|
||||||
db_id, new_status, job_id)
|
db_id, new_status, job_id)
|
||||||
|
|
||||||
return processed
|
return processed
|
||||||
|
|
||||||
|
|
||||||
async def process_processing_jobs(conn: duckdb.DuckDBPyConnection, lock: asyncio.Lock) -> int:
|
async def process_processing_jobs(conn: duckdb.DuckDBPyConnection, lock: asyncio.Lock) -> int:
|
||||||
"""Poll in-progress jobs and update to 'completed' or 'failed'."""
|
"""Poll in-progress jobs and update to 'completed' or 'failed'."""
|
||||||
rows = conn.execute(
|
rows = conn.execute(
|
||||||
"""SELECT id, polling_url
|
"""SELECT id, polling_url
|
||||||
FROM generated_videos
|
FROM generated_videos
|
||||||
WHERE status = 'processing' AND polling_url IS NOT NULL
|
WHERE status = 'processing' AND polling_url IS NOT NULL
|
||||||
ORDER BY updated_at ASC
|
ORDER BY updated_at ASC
|
||||||
LIMIT ?""",
|
LIMIT ?""",
|
||||||
[BATCH_SIZE],
|
[BATCH_SIZE],
|
||||||
).fetchall()
|
).fetchall()
|
||||||
|
|
||||||
updated = 0
|
updated = 0
|
||||||
for row in rows:
|
for row in rows:
|
||||||
db_id, polling_url = str(row[0]), row[1]
|
db_id, polling_url = str(row[0]), row[1]
|
||||||
try:
|
try:
|
||||||
result = await openrouter.poll_video_status(polling_url)
|
result = await openrouter.poll_video_status(polling_url)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.warning("Polling failed for job %s: %s", db_id, exc)
|
logger.warning("Polling failed for job %s: %s", db_id, exc)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
job_status = result.get("status", "processing")
|
job_status = result.get("status", "processing")
|
||||||
if job_status not in ("completed", "failed"):
|
if job_status not in ("completed", "failed"):
|
||||||
continue # still in-progress — check again next tick
|
continue # still in-progress — check again next tick
|
||||||
|
|
||||||
urls = result.get("unsigned_urls") or result.get("video_urls")
|
urls = result.get("unsigned_urls") or result.get("video_urls")
|
||||||
video_url = (urls or [None])[0]
|
video_url = (urls or [None])[0]
|
||||||
error_msg = result.get("error")
|
error_msg = result.get("error")
|
||||||
now = datetime.now(timezone.utc).replace(tzinfo=None)
|
now = datetime.now(timezone.utc).replace(tzinfo=None)
|
||||||
|
|
||||||
async with lock:
|
async with lock:
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"""UPDATE generated_videos
|
"""UPDATE generated_videos
|
||||||
SET status = ?, video_url = ?, error = ?, updated_at = ?
|
SET status = ?, video_url = ?, error = ?, updated_at = ?
|
||||||
WHERE id = ?""",
|
WHERE id = ?""",
|
||||||
[job_status, video_url, error_msg, now, db_id],
|
[job_status, video_url, error_msg, now, db_id],
|
||||||
)
|
)
|
||||||
updated += 1
|
updated += 1
|
||||||
logger.info("Video job %s → %s", db_id, job_status)
|
logger.info("Video job %s → %s", db_id, job_status)
|
||||||
|
|
||||||
return updated
|
return updated
|
||||||
|
|
||||||
|
|
||||||
async def worker_tick(conn: duckdb.DuckDBPyConnection, lock: asyncio.Lock) -> None:
|
async def worker_tick(conn: duckdb.DuckDBPyConnection, lock: asyncio.Lock) -> None:
|
||||||
"""Single worker tick: submit queued, poll processing, expire timed-out."""
|
"""Single worker tick: submit queued, poll processing, expire timed-out."""
|
||||||
queued = await process_queued_jobs(conn, lock)
|
queued = await process_queued_jobs(conn, lock)
|
||||||
polled = await process_processing_jobs(conn, lock)
|
polled = await process_processing_jobs(conn, lock)
|
||||||
async with lock:
|
async with lock:
|
||||||
timed_out = mark_timed_out_video_jobs(conn, timeout_minutes=120)
|
timed_out = mark_timed_out_video_jobs(conn, timeout_minutes=120)
|
||||||
if queued or polled or timed_out:
|
if queued or polled or timed_out:
|
||||||
logger.info(
|
logger.info(
|
||||||
"Worker tick: submitted=%d polled=%d timed_out=%d",
|
"Worker tick: submitted=%d polled=%d timed_out=%d",
|
||||||
queued, polled, timed_out,
|
queued, polled, timed_out,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
async def run_worker(conn: duckdb.DuckDBPyConnection, lock: asyncio.Lock) -> None:
|
async def run_worker(conn: duckdb.DuckDBPyConnection, lock: asyncio.Lock) -> None:
|
||||||
"""Infinite loop: run a worker tick every WORKER_INTERVAL seconds."""
|
"""Infinite loop: run a worker tick every WORKER_INTERVAL seconds."""
|
||||||
logger.info("Video worker started (interval=%ds)", WORKER_INTERVAL)
|
logger.info("Video worker started (interval=%ds)", WORKER_INTERVAL)
|
||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
await worker_tick(conn, lock)
|
await worker_tick(conn, lock)
|
||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
logger.info("Video worker stopped.")
|
logger.info("Video worker stopped.")
|
||||||
return
|
return
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.exception("Unexpected error in video worker: %s", exc)
|
logger.exception("Unexpected error in video worker: %s", exc)
|
||||||
await asyncio.sleep(WORKER_INTERVAL)
|
await asyncio.sleep(WORKER_INTERVAL)
|
||||||
@@ -1,13 +1,13 @@
|
|||||||
# Dev-only dependencies for local development and testing
|
# Dev-only dependencies for local development and testing
|
||||||
# Production dependencies are in requirements.txt
|
# Production dependencies are in requirements.txt
|
||||||
|
|
||||||
pytest
|
pytest
|
||||||
pytest-asyncio
|
pytest-asyncio
|
||||||
Flask
|
Flask
|
||||||
gunicorn
|
gunicorn
|
||||||
Pygments
|
Pygments
|
||||||
tomli
|
tomli
|
||||||
exceptiongroup
|
exceptiongroup
|
||||||
iniconfig
|
iniconfig
|
||||||
pluggy
|
pluggy
|
||||||
|
|
||||||
+20
-20
@@ -1,21 +1,21 @@
|
|||||||
anyio
|
anyio
|
||||||
bcrypt==4.0.1
|
bcrypt==4.0.1
|
||||||
blinker
|
blinker
|
||||||
certifi
|
certifi
|
||||||
cryptography
|
cryptography
|
||||||
dnspython
|
dnspython
|
||||||
duckdb
|
duckdb
|
||||||
ecdsa
|
ecdsa
|
||||||
email-validator
|
email-validator
|
||||||
fastapi
|
fastapi
|
||||||
httpcore
|
httpcore
|
||||||
httpx
|
httpx
|
||||||
Jinja2
|
Jinja2
|
||||||
MarkupSafe
|
MarkupSafe
|
||||||
packaging
|
packaging
|
||||||
passlib==1.7.4
|
passlib==1.7.4
|
||||||
pydantic
|
pydantic
|
||||||
python-dotenv
|
python-dotenv
|
||||||
python-jose
|
python-jose
|
||||||
python-multipart
|
python-multipart
|
||||||
uvicorn
|
uvicorn
|
||||||
+119
-119
@@ -1,119 +1,119 @@
|
|||||||
"""Integration tests for admin endpoints."""
|
"""Integration tests for admin endpoints."""
|
||||||
import os
|
import os
|
||||||
import pytest
|
import pytest
|
||||||
import pytest_asyncio
|
import pytest_asyncio
|
||||||
from httpx import AsyncClient, ASGITransport
|
from httpx import AsyncClient, ASGITransport
|
||||||
|
|
||||||
from app.main import app
|
from app.main import app
|
||||||
from app import db as db_module
|
from app import db as db_module
|
||||||
|
|
||||||
os.environ.setdefault("JWT_SECRET", "test-secret-key-for-testing-only")
|
os.environ.setdefault("JWT_SECRET", "test-secret-key-for-testing-only")
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
@pytest.fixture(autouse=True)
|
||||||
def fresh_db():
|
def fresh_db():
|
||||||
db_module._conn = None
|
db_module._conn = None
|
||||||
db_module.init_db(":memory:")
|
db_module.init_db(":memory:")
|
||||||
yield
|
yield
|
||||||
db_module.close_db()
|
db_module.close_db()
|
||||||
db_module._conn = None
|
db_module._conn = None
|
||||||
|
|
||||||
|
|
||||||
@pytest_asyncio.fixture
|
@pytest_asyncio.fixture
|
||||||
async def client(fresh_db):
|
async def client(fresh_db):
|
||||||
transport = ASGITransport(app=app)
|
transport = ASGITransport(app=app)
|
||||||
async with AsyncClient(transport=transport, base_url="http://test") as ac:
|
async with AsyncClient(transport=transport, base_url="http://test") as ac:
|
||||||
yield ac
|
yield ac
|
||||||
|
|
||||||
|
|
||||||
async def _register_login(client, email="user@example.com", password="secret123"):
|
async def _register_login(client, email="user@example.com", password="secret123"):
|
||||||
await client.post("/auth/register", json={"email": email, "password": password})
|
await client.post("/auth/register", json={"email": email, "password": password})
|
||||||
resp = await client.post("/auth/login", json={"email": email, "password": password})
|
resp = await client.post("/auth/login", json={"email": email, "password": password})
|
||||||
return resp.json()
|
return resp.json()
|
||||||
|
|
||||||
|
|
||||||
async def _admin_token(client, email="admin@example.com", password="secret123"):
|
async def _admin_token(client, email="admin@example.com", password="secret123"):
|
||||||
tokens = await _register_login(client, email, password)
|
tokens = await _register_login(client, email, password)
|
||||||
me = await client.get("/users/me", headers={"Authorization": f"Bearer {tokens['access_token']}"})
|
me = await client.get("/users/me", headers={"Authorization": f"Bearer {tokens['access_token']}"})
|
||||||
db_module.get_conn().execute(
|
db_module.get_conn().execute(
|
||||||
"UPDATE users SET role = 'admin' WHERE id = ?", [me.json()["id"]])
|
"UPDATE users SET role = 'admin' WHERE id = ?", [me.json()["id"]])
|
||||||
login = await client.post("/auth/login", json={"email": email, "password": password})
|
login = await client.post("/auth/login", json={"email": email, "password": password})
|
||||||
return login.json()["access_token"]
|
return login.json()["access_token"]
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# GET /admin/stats
|
# GET /admin/stats
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
async def test_stats_as_admin(client):
|
async def test_stats_as_admin(client):
|
||||||
await _register_login(client, "user1@example.com")
|
await _register_login(client, "user1@example.com")
|
||||||
await _register_login(client, "user2@example.com")
|
await _register_login(client, "user2@example.com")
|
||||||
token = await _admin_token(client)
|
token = await _admin_token(client)
|
||||||
|
|
||||||
resp = await client.get("/admin/stats", headers={"Authorization": f"Bearer {token}"})
|
resp = await client.get("/admin/stats", headers={"Authorization": f"Bearer {token}"})
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
data = resp.json()
|
data = resp.json()
|
||||||
# 2 users + 1 admin + 1 seeded admin (ai@allucanget.biz)
|
# 2 users + 1 admin + 1 seeded admin (ai@allucanget.biz)
|
||||||
assert data["users"]["total"] == 4
|
assert data["users"]["total"] == 4
|
||||||
assert "by_role" in data["users"]
|
assert "by_role" in data["users"]
|
||||||
assert "refresh_tokens" in data
|
assert "refresh_tokens" in data
|
||||||
|
|
||||||
|
|
||||||
async def test_stats_as_regular_user(client):
|
async def test_stats_as_regular_user(client):
|
||||||
tokens = await _register_login(client)
|
tokens = await _register_login(client)
|
||||||
resp = await client.get("/admin/stats", headers={"Authorization": f"Bearer {tokens['access_token']}"})
|
resp = await client.get("/admin/stats", headers={"Authorization": f"Bearer {tokens['access_token']}"})
|
||||||
assert resp.status_code == 403
|
assert resp.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
async def test_stats_unauthenticated(client):
|
async def test_stats_unauthenticated(client):
|
||||||
resp = await client.get("/admin/stats")
|
resp = await client.get("/admin/stats")
|
||||||
assert resp.status_code == 401
|
assert resp.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# GET /admin/health/db
|
# GET /admin/health/db
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
async def test_db_health_as_admin(client):
|
async def test_db_health_as_admin(client):
|
||||||
token = await _admin_token(client)
|
token = await _admin_token(client)
|
||||||
resp = await client.get("/admin/health/db", headers={"Authorization": f"Bearer {token}"})
|
resp = await client.get("/admin/health/db", headers={"Authorization": f"Bearer {token}"})
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
assert resp.json()["status"] == "ok"
|
assert resp.json()["status"] == "ok"
|
||||||
|
|
||||||
|
|
||||||
async def test_db_health_as_regular_user(client):
|
async def test_db_health_as_regular_user(client):
|
||||||
tokens = await _register_login(client)
|
tokens = await _register_login(client)
|
||||||
resp = await client.get("/admin/health/db", headers={"Authorization": f"Bearer {tokens['access_token']}"})
|
resp = await client.get("/admin/health/db", headers={"Authorization": f"Bearer {tokens['access_token']}"})
|
||||||
assert resp.status_code == 403
|
assert resp.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# POST /admin/tokens/purge
|
# POST /admin/tokens/purge
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
async def test_purge_removes_revoked_tokens(client):
|
async def test_purge_removes_revoked_tokens(client):
|
||||||
tokens = await _register_login(client, "user@example.com")
|
tokens = await _register_login(client, "user@example.com")
|
||||||
refresh_token = tokens["refresh_token"]
|
refresh_token = tokens["refresh_token"]
|
||||||
|
|
||||||
# Logout to revoke the token
|
# Logout to revoke the token
|
||||||
await client.post("/auth/logout", json={"refresh_token": refresh_token})
|
await client.post("/auth/logout", json={"refresh_token": refresh_token})
|
||||||
|
|
||||||
token = await _admin_token(client)
|
token = await _admin_token(client)
|
||||||
resp = await client.post("/admin/tokens/purge", headers={"Authorization": f"Bearer {token}"})
|
resp = await client.post("/admin/tokens/purge", headers={"Authorization": f"Bearer {token}"})
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
data = resp.json()
|
data = resp.json()
|
||||||
assert data["deleted"] >= 1
|
assert data["deleted"] >= 1
|
||||||
|
|
||||||
|
|
||||||
async def test_purge_nothing_to_remove(client):
|
async def test_purge_nothing_to_remove(client):
|
||||||
token = await _admin_token(client)
|
token = await _admin_token(client)
|
||||||
resp = await client.post("/admin/tokens/purge", headers={"Authorization": f"Bearer {token}"})
|
resp = await client.post("/admin/tokens/purge", headers={"Authorization": f"Bearer {token}"})
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
# Admin login issued one active token — nothing to purge
|
# Admin login issued one active token — nothing to purge
|
||||||
assert resp.json()["deleted"] == 0
|
assert resp.json()["deleted"] == 0
|
||||||
|
|
||||||
|
|
||||||
async def test_purge_as_regular_user(client):
|
async def test_purge_as_regular_user(client):
|
||||||
tokens = await _register_login(client)
|
tokens = await _register_login(client)
|
||||||
resp = await client.post("/admin/tokens/purge", headers={"Authorization": f"Bearer {tokens['access_token']}"})
|
resp = await client.post("/admin/tokens/purge", headers={"Authorization": f"Bearer {tokens['access_token']}"})
|
||||||
assert resp.status_code == 403
|
assert resp.status_code == 403
|
||||||
+172
-172
@@ -1,172 +1,172 @@
|
|||||||
"""Tests for AI endpoints — OpenRouter HTTP calls are fully mocked."""
|
"""Tests for AI endpoints — OpenRouter HTTP calls are fully mocked."""
|
||||||
import os
|
import os
|
||||||
import pytest
|
import pytest
|
||||||
import pytest_asyncio
|
import pytest_asyncio
|
||||||
from unittest.mock import AsyncMock, patch
|
from unittest.mock import AsyncMock, patch
|
||||||
from httpx import AsyncClient, ASGITransport
|
from httpx import AsyncClient, ASGITransport
|
||||||
|
|
||||||
from app.main import app
|
from app.main import app
|
||||||
from app import db as db_module
|
from app import db as db_module
|
||||||
|
|
||||||
os.environ.setdefault("JWT_SECRET", "test-secret-key-for-testing-only")
|
os.environ.setdefault("JWT_SECRET", "test-secret-key-for-testing-only")
|
||||||
os.environ.setdefault("OPENROUTER_API_KEY", "test-key")
|
os.environ.setdefault("OPENROUTER_API_KEY", "test-key")
|
||||||
|
|
||||||
FAKE_MODELS = [
|
FAKE_MODELS = [
|
||||||
{"id": "openai/gpt-4o", "name": "GPT-4o", "context_length": 128000, "pricing": {"prompt": "0.000005"}},
|
{"id": "openai/gpt-4o", "name": "GPT-4o", "context_length": 128000, "pricing": {"prompt": "0.000005"}},
|
||||||
{"id": "anthropic/claude-3-haiku", "name": "Claude 3 Haiku", "context_length": 200000, "pricing": {}},
|
{"id": "anthropic/claude-3-haiku", "name": "Claude 3 Haiku", "context_length": 200000, "pricing": {}},
|
||||||
]
|
]
|
||||||
|
|
||||||
FAKE_CHAT_RESPONSE = {
|
FAKE_CHAT_RESPONSE = {
|
||||||
"id": "gen-abc123",
|
"id": "gen-abc123",
|
||||||
"model": "openai/gpt-4o",
|
"model": "openai/gpt-4o",
|
||||||
"choices": [{"message": {"role": "assistant", "content": "Hello! How can I help?"}}],
|
"choices": [{"message": {"role": "assistant", "content": "Hello! How can I help?"}}],
|
||||||
"usage": {"prompt_tokens": 10, "completion_tokens": 8, "total_tokens": 18},
|
"usage": {"prompt_tokens": 10, "completion_tokens": 8, "total_tokens": 18},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
@pytest.fixture(autouse=True)
|
||||||
def fresh_db():
|
def fresh_db():
|
||||||
db_module._conn = None
|
db_module._conn = None
|
||||||
db_module.init_db(":memory:")
|
db_module.init_db(":memory:")
|
||||||
yield
|
yield
|
||||||
db_module.close_db()
|
db_module.close_db()
|
||||||
db_module._conn = None
|
db_module._conn = None
|
||||||
|
|
||||||
|
|
||||||
@pytest_asyncio.fixture
|
@pytest_asyncio.fixture
|
||||||
async def client(fresh_db):
|
async def client(fresh_db):
|
||||||
transport = ASGITransport(app=app)
|
transport = ASGITransport(app=app)
|
||||||
async with AsyncClient(transport=transport, base_url="http://test") as ac:
|
async with AsyncClient(transport=transport, base_url="http://test") as ac:
|
||||||
yield ac
|
yield ac
|
||||||
|
|
||||||
|
|
||||||
async def _user_token(client):
|
async def _user_token(client):
|
||||||
await client.post("/auth/register", json={"email": "user@example.com", "password": "secret123"})
|
await client.post("/auth/register", json={"email": "user@example.com", "password": "secret123"})
|
||||||
resp = await client.post("/auth/login", json={"email": "user@example.com", "password": "secret123"})
|
resp = await client.post("/auth/login", json={"email": "user@example.com", "password": "secret123"})
|
||||||
return resp.json()["access_token"]
|
return resp.json()["access_token"]
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# GET /ai/models
|
# GET /ai/models
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
async def test_list_models(client):
|
async def test_list_models(client):
|
||||||
token = await _user_token(client)
|
token = await _user_token(client)
|
||||||
with patch(
|
with patch(
|
||||||
"app.routers.ai.openrouter.list_models",
|
"app.routers.ai.openrouter.list_models",
|
||||||
new_callable=AsyncMock,
|
new_callable=AsyncMock,
|
||||||
return_value=FAKE_MODELS,
|
return_value=FAKE_MODELS,
|
||||||
):
|
):
|
||||||
resp = await client.get("/ai/models", headers={"Authorization": f"Bearer {token}"})
|
resp = await client.get("/ai/models", headers={"Authorization": f"Bearer {token}"})
|
||||||
|
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
data = resp.json()
|
data = resp.json()
|
||||||
assert len(data) == 2
|
assert len(data) == 2
|
||||||
assert data[0]["id"] == "openai/gpt-4o"
|
assert data[0]["id"] == "openai/gpt-4o"
|
||||||
assert data[1]["name"] == "Claude 3 Haiku"
|
assert data[1]["name"] == "Claude 3 Haiku"
|
||||||
|
|
||||||
|
|
||||||
async def test_list_models_unauthenticated(client):
|
async def test_list_models_unauthenticated(client):
|
||||||
resp = await client.get("/ai/models")
|
resp = await client.get("/ai/models")
|
||||||
assert resp.status_code == 401
|
assert resp.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
async def test_list_models_upstream_error(client):
|
async def test_list_models_upstream_error(client):
|
||||||
token = await _user_token(client)
|
token = await _user_token(client)
|
||||||
with patch(
|
with patch(
|
||||||
"app.routers.ai.openrouter.list_models",
|
"app.routers.ai.openrouter.list_models",
|
||||||
new_callable=AsyncMock,
|
new_callable=AsyncMock,
|
||||||
side_effect=Exception("Connection refused"),
|
side_effect=Exception("Connection refused"),
|
||||||
):
|
):
|
||||||
resp = await client.get("/ai/models", headers={"Authorization": f"Bearer {token}"})
|
resp = await client.get("/ai/models", headers={"Authorization": f"Bearer {token}"})
|
||||||
|
|
||||||
assert resp.status_code == 502
|
assert resp.status_code == 502
|
||||||
assert "OpenRouter error" in resp.json()["detail"]
|
assert "OpenRouter error" in resp.json()["detail"]
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# POST /ai/chat
|
# POST /ai/chat
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
async def test_chat_success(client):
|
async def test_chat_success(client):
|
||||||
token = await _user_token(client)
|
token = await _user_token(client)
|
||||||
with patch(
|
with patch(
|
||||||
"app.routers.ai.openrouter.chat_completion",
|
"app.routers.ai.openrouter.chat_completion",
|
||||||
new_callable=AsyncMock,
|
new_callable=AsyncMock,
|
||||||
return_value=FAKE_CHAT_RESPONSE,
|
return_value=FAKE_CHAT_RESPONSE,
|
||||||
):
|
):
|
||||||
resp = await client.post(
|
resp = await client.post(
|
||||||
"/ai/chat",
|
"/ai/chat",
|
||||||
json={
|
json={
|
||||||
"model": "openai/gpt-4o",
|
"model": "openai/gpt-4o",
|
||||||
"messages": [{"role": "user", "content": "Hello"}],
|
"messages": [{"role": "user", "content": "Hello"}],
|
||||||
},
|
},
|
||||||
headers={"Authorization": f"Bearer {token}"},
|
headers={"Authorization": f"Bearer {token}"},
|
||||||
)
|
)
|
||||||
|
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
data = resp.json()
|
data = resp.json()
|
||||||
assert data["id"] == "gen-abc123"
|
assert data["id"] == "gen-abc123"
|
||||||
assert data["model"] == "openai/gpt-4o"
|
assert data["model"] == "openai/gpt-4o"
|
||||||
assert data["content"] == "Hello! How can I help?"
|
assert data["content"] == "Hello! How can I help?"
|
||||||
assert data["usage"]["total_tokens"] == 18
|
assert data["usage"]["total_tokens"] == 18
|
||||||
|
|
||||||
|
|
||||||
async def test_chat_passes_parameters(client):
|
async def test_chat_passes_parameters(client):
|
||||||
token = await _user_token(client)
|
token = await _user_token(client)
|
||||||
mock = AsyncMock(return_value=FAKE_CHAT_RESPONSE)
|
mock = AsyncMock(return_value=FAKE_CHAT_RESPONSE)
|
||||||
with patch("app.routers.ai.openrouter.chat_completion", new_callable=AsyncMock, return_value=FAKE_CHAT_RESPONSE) as mock:
|
with patch("app.routers.ai.openrouter.chat_completion", new_callable=AsyncMock, return_value=FAKE_CHAT_RESPONSE) as mock:
|
||||||
await client.post(
|
await client.post(
|
||||||
"/ai/chat",
|
"/ai/chat",
|
||||||
json={
|
json={
|
||||||
"model": "anthropic/claude-3-haiku",
|
"model": "anthropic/claude-3-haiku",
|
||||||
"messages": [{"role": "user", "content": "Hi"}],
|
"messages": [{"role": "user", "content": "Hi"}],
|
||||||
"temperature": 0.3,
|
"temperature": 0.3,
|
||||||
"max_tokens": 512,
|
"max_tokens": 512,
|
||||||
},
|
},
|
||||||
headers={"Authorization": f"Bearer {token}"},
|
headers={"Authorization": f"Bearer {token}"},
|
||||||
)
|
)
|
||||||
mock.assert_called_once_with(
|
mock.assert_called_once_with(
|
||||||
model="anthropic/claude-3-haiku",
|
model="anthropic/claude-3-haiku",
|
||||||
messages=[{"role": "user", "content": "Hi"}],
|
messages=[{"role": "user", "content": "Hi"}],
|
||||||
temperature=0.3,
|
temperature=0.3,
|
||||||
max_tokens=512,
|
max_tokens=512,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
async def test_chat_unauthenticated(client):
|
async def test_chat_unauthenticated(client):
|
||||||
resp = await client.post(
|
resp = await client.post(
|
||||||
"/ai/chat",
|
"/ai/chat",
|
||||||
json={"model": "openai/gpt-4o", "messages": [{"role": "user", "content": "Hi"}]},
|
json={"model": "openai/gpt-4o", "messages": [{"role": "user", "content": "Hi"}]},
|
||||||
)
|
)
|
||||||
assert resp.status_code == 401
|
assert resp.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
async def test_chat_upstream_error(client):
|
async def test_chat_upstream_error(client):
|
||||||
token = await _user_token(client)
|
token = await _user_token(client)
|
||||||
with patch(
|
with patch(
|
||||||
"app.routers.ai.openrouter.chat_completion",
|
"app.routers.ai.openrouter.chat_completion",
|
||||||
new_callable=AsyncMock,
|
new_callable=AsyncMock,
|
||||||
side_effect=Exception("timeout"),
|
side_effect=Exception("timeout"),
|
||||||
):
|
):
|
||||||
resp = await client.post(
|
resp = await client.post(
|
||||||
"/ai/chat",
|
"/ai/chat",
|
||||||
json={"model": "openai/gpt-4o", "messages": [{"role": "user", "content": "Hi"}]},
|
json={"model": "openai/gpt-4o", "messages": [{"role": "user", "content": "Hi"}]},
|
||||||
headers={"Authorization": f"Bearer {token}"},
|
headers={"Authorization": f"Bearer {token}"},
|
||||||
)
|
)
|
||||||
assert resp.status_code == 502
|
assert resp.status_code == 502
|
||||||
|
|
||||||
|
|
||||||
async def test_chat_malformed_upstream_response(client):
|
async def test_chat_malformed_upstream_response(client):
|
||||||
token = await _user_token(client)
|
token = await _user_token(client)
|
||||||
with patch(
|
with patch(
|
||||||
"app.routers.ai.openrouter.chat_completion",
|
"app.routers.ai.openrouter.chat_completion",
|
||||||
new_callable=AsyncMock,
|
new_callable=AsyncMock,
|
||||||
return_value={"id": "x", "choices": []}, # empty choices
|
return_value={"id": "x", "choices": []}, # empty choices
|
||||||
):
|
):
|
||||||
resp = await client.post(
|
resp = await client.post(
|
||||||
"/ai/chat",
|
"/ai/chat",
|
||||||
json={"model": "openai/gpt-4o", "messages": [{"role": "user", "content": "Hi"}]},
|
json={"model": "openai/gpt-4o", "messages": [{"role": "user", "content": "Hi"}]},
|
||||||
headers={"Authorization": f"Bearer {token}"},
|
headers={"Authorization": f"Bearer {token}"},
|
||||||
)
|
)
|
||||||
assert resp.status_code == 502
|
assert resp.status_code == 502
|
||||||
+103
-103
@@ -1,103 +1,103 @@
|
|||||||
"""Integration tests for auth endpoints using in-memory DuckDB."""
|
"""Integration tests for auth endpoints using in-memory DuckDB."""
|
||||||
from app.main import app
|
from app.main import app
|
||||||
from app import db as db_module
|
from app import db as db_module
|
||||||
from httpx import AsyncClient, ASGITransport
|
from httpx import AsyncClient, ASGITransport
|
||||||
import os
|
import os
|
||||||
import pytest
|
import pytest
|
||||||
import pytest_asyncio
|
import pytest_asyncio
|
||||||
|
|
||||||
os.environ.setdefault("JWT_SECRET", "test-secret-key-for-testing-only")
|
os.environ.setdefault("JWT_SECRET", "test-secret-key-for-testing-only")
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
@pytest.fixture(autouse=True)
|
||||||
def fresh_db():
|
def fresh_db():
|
||||||
"""Reset the DB singleton to a fresh in-memory DB for each test."""
|
"""Reset the DB singleton to a fresh in-memory DB for each test."""
|
||||||
db_module._conn = None
|
db_module._conn = None
|
||||||
db_module.init_db(":memory:")
|
db_module.init_db(":memory:")
|
||||||
yield
|
yield
|
||||||
db_module.close_db()
|
db_module.close_db()
|
||||||
db_module._conn = None
|
db_module._conn = None
|
||||||
|
|
||||||
|
|
||||||
@pytest_asyncio.fixture
|
@pytest_asyncio.fixture
|
||||||
async def client(fresh_db):
|
async def client(fresh_db):
|
||||||
"""HTTP client wired to the app; explicitly depends on fresh_db to guarantee ordering."""
|
"""HTTP client wired to the app; explicitly depends on fresh_db to guarantee ordering."""
|
||||||
transport = ASGITransport(app=app)
|
transport = ASGITransport(app=app)
|
||||||
async with AsyncClient(transport=transport, base_url="http://test") as ac:
|
async with AsyncClient(transport=transport, base_url="http://test") as ac:
|
||||||
yield ac
|
yield ac
|
||||||
|
|
||||||
|
|
||||||
async def test_register_success(client):
|
async def test_register_success(client):
|
||||||
resp = await client.post("/auth/register", json={"email": "user@example.com", "password": "secret123"})
|
resp = await client.post("/auth/register", json={"email": "user@example.com", "password": "secret123"})
|
||||||
assert resp.status_code == 201
|
assert resp.status_code == 201
|
||||||
data = resp.json()
|
data = resp.json()
|
||||||
assert data["email"] == "user@example.com"
|
assert data["email"] == "user@example.com"
|
||||||
assert data["role"] == "user"
|
assert data["role"] == "user"
|
||||||
assert "id" in data
|
assert "id" in data
|
||||||
|
|
||||||
|
|
||||||
async def test_register_duplicate_email(client):
|
async def test_register_duplicate_email(client):
|
||||||
payload = {"email": "dup@example.com", "password": "secret123"}
|
payload = {"email": "dup@example.com", "password": "secret123"}
|
||||||
await client.post("/auth/register", json=payload)
|
await client.post("/auth/register", json=payload)
|
||||||
resp = await client.post("/auth/register", json=payload)
|
resp = await client.post("/auth/register", json=payload)
|
||||||
assert resp.status_code == 409
|
assert resp.status_code == 409
|
||||||
|
|
||||||
|
|
||||||
async def test_login_success(client):
|
async def test_login_success(client):
|
||||||
await client.post("/auth/register", json={"email": "user@example.com", "password": "secret123"})
|
await client.post("/auth/register", json={"email": "user@example.com", "password": "secret123"})
|
||||||
resp = await client.post("/auth/login", json={"email": "user@example.com", "password": "secret123"})
|
resp = await client.post("/auth/login", json={"email": "user@example.com", "password": "secret123"})
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
data = resp.json()
|
data = resp.json()
|
||||||
assert "access_token" in data
|
assert "access_token" in data
|
||||||
assert "refresh_token" in data
|
assert "refresh_token" in data
|
||||||
assert data["token_type"] == "bearer"
|
assert data["token_type"] == "bearer"
|
||||||
|
|
||||||
|
|
||||||
async def test_login_wrong_password(client):
|
async def test_login_wrong_password(client):
|
||||||
await client.post("/auth/register", json={"email": "user@example.com", "password": "secret123"})
|
await client.post("/auth/register", json={"email": "user@example.com", "password": "secret123"})
|
||||||
resp = await client.post("/auth/login", json={"email": "user@example.com", "password": "wrong"})
|
resp = await client.post("/auth/login", json={"email": "user@example.com", "password": "wrong"})
|
||||||
assert resp.status_code == 401
|
assert resp.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
async def test_login_unknown_user(client):
|
async def test_login_unknown_user(client):
|
||||||
resp = await client.post("/auth/login", json={"email": "nobody@example.com", "password": "x"})
|
resp = await client.post("/auth/login", json={"email": "nobody@example.com", "password": "x"})
|
||||||
assert resp.status_code == 401
|
assert resp.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
async def test_refresh_success(client):
|
async def test_refresh_success(client):
|
||||||
await client.post("/auth/register", json={"email": "user@example.com", "password": "secret123"})
|
await client.post("/auth/register", json={"email": "user@example.com", "password": "secret123"})
|
||||||
login = await client.post("/auth/login", json={"email": "user@example.com", "password": "secret123"})
|
login = await client.post("/auth/login", json={"email": "user@example.com", "password": "secret123"})
|
||||||
refresh_token = login.json()["refresh_token"]
|
refresh_token = login.json()["refresh_token"]
|
||||||
|
|
||||||
resp = await client.post("/auth/refresh", json={"refresh_token": refresh_token})
|
resp = await client.post("/auth/refresh", json={"refresh_token": refresh_token})
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
data = resp.json()
|
data = resp.json()
|
||||||
assert "access_token" in data
|
assert "access_token" in data
|
||||||
assert "refresh_token" in data
|
assert "refresh_token" in data
|
||||||
# New refresh token must differ (rotation)
|
# New refresh token must differ (rotation)
|
||||||
assert data["refresh_token"] != refresh_token
|
assert data["refresh_token"] != refresh_token
|
||||||
|
|
||||||
|
|
||||||
async def test_refresh_revoked_token(client):
|
async def test_refresh_revoked_token(client):
|
||||||
await client.post("/auth/register", json={"email": "user@example.com", "password": "secret123"})
|
await client.post("/auth/register", json={"email": "user@example.com", "password": "secret123"})
|
||||||
login = await client.post("/auth/login", json={"email": "user@example.com", "password": "secret123"})
|
login = await client.post("/auth/login", json={"email": "user@example.com", "password": "secret123"})
|
||||||
refresh_token = login.json()["refresh_token"]
|
refresh_token = login.json()["refresh_token"]
|
||||||
|
|
||||||
# Use once (rotates)
|
# Use once (rotates)
|
||||||
await client.post("/auth/refresh", json={"refresh_token": refresh_token})
|
await client.post("/auth/refresh", json={"refresh_token": refresh_token})
|
||||||
# Try to reuse old token — must fail
|
# Try to reuse old token — must fail
|
||||||
resp = await client.post("/auth/refresh", json={"refresh_token": refresh_token})
|
resp = await client.post("/auth/refresh", json={"refresh_token": refresh_token})
|
||||||
assert resp.status_code == 401
|
assert resp.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
async def test_logout_success(client):
|
async def test_logout_success(client):
|
||||||
await client.post("/auth/register", json={"email": "user@example.com", "password": "secret123"})
|
await client.post("/auth/register", json={"email": "user@example.com", "password": "secret123"})
|
||||||
login = await client.post("/auth/login", json={"email": "user@example.com", "password": "secret123"})
|
login = await client.post("/auth/login", json={"email": "user@example.com", "password": "secret123"})
|
||||||
refresh_token = login.json()["refresh_token"]
|
refresh_token = login.json()["refresh_token"]
|
||||||
|
|
||||||
resp = await client.post("/auth/logout", json={"refresh_token": refresh_token})
|
resp = await client.post("/auth/logout", json={"refresh_token": refresh_token})
|
||||||
assert resp.status_code == 204
|
assert resp.status_code == 204
|
||||||
|
|
||||||
# Refresh after logout must fail
|
# Refresh after logout must fail
|
||||||
resp2 = await client.post("/auth/refresh", json={"refresh_token": refresh_token})
|
resp2 = await client.post("/auth/refresh", json={"refresh_token": refresh_token})
|
||||||
assert resp2.status_code == 401
|
assert resp2.status_code == 401
|
||||||
+230
-230
@@ -1,230 +1,230 @@
|
|||||||
"""Tests for DuckDB initialization and schema."""
|
"""Tests for DuckDB initialization and schema."""
|
||||||
import asyncio
|
import asyncio
|
||||||
import pytest
|
import pytest
|
||||||
import duckdb
|
import duckdb
|
||||||
|
|
||||||
from app import db as db_module
|
from app import db as db_module
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
@pytest.fixture(autouse=True)
|
||||||
def fresh_db():
|
def fresh_db():
|
||||||
"""Use an in-memory DB for each test and reset global state."""
|
"""Use an in-memory DB for each test and reset global state."""
|
||||||
db_module._conn = None
|
db_module._conn = None
|
||||||
yield
|
yield
|
||||||
db_module.close_db()
|
db_module.close_db()
|
||||||
db_module._conn = None
|
db_module._conn = None
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Lifecycle
|
# Lifecycle
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
def test_init_creates_users_table():
|
def test_init_creates_users_table():
|
||||||
conn = db_module.init_db(":memory:")
|
conn = db_module.init_db(":memory:")
|
||||||
result = conn.execute(
|
result = conn.execute(
|
||||||
"SELECT table_name FROM information_schema.tables WHERE table_name = 'users'"
|
"SELECT table_name FROM information_schema.tables WHERE table_name = 'users'"
|
||||||
).fetchone()
|
).fetchone()
|
||||||
assert result is not None
|
assert result is not None
|
||||||
|
|
||||||
|
|
||||||
def test_init_creates_refresh_tokens_table():
|
def test_init_creates_refresh_tokens_table():
|
||||||
conn = db_module.init_db(":memory:")
|
conn = db_module.init_db(":memory:")
|
||||||
result = conn.execute(
|
result = conn.execute(
|
||||||
"SELECT table_name FROM information_schema.tables WHERE table_name = 'refresh_tokens'"
|
"SELECT table_name FROM information_schema.tables WHERE table_name = 'refresh_tokens'"
|
||||||
).fetchone()
|
).fetchone()
|
||||||
assert result is not None
|
assert result is not None
|
||||||
|
|
||||||
|
|
||||||
def test_init_is_idempotent():
|
def test_init_is_idempotent():
|
||||||
conn1 = db_module.init_db(":memory:")
|
conn1 = db_module.init_db(":memory:")
|
||||||
conn2 = db_module.init_db(":memory:")
|
conn2 = db_module.init_db(":memory:")
|
||||||
assert conn1 is conn2
|
assert conn1 is conn2
|
||||||
|
|
||||||
|
|
||||||
def test_get_conn_raises_before_init():
|
def test_get_conn_raises_before_init():
|
||||||
with pytest.raises(RuntimeError, match="not initialised"):
|
with pytest.raises(RuntimeError, match="not initialised"):
|
||||||
db_module.get_conn()
|
db_module.get_conn()
|
||||||
|
|
||||||
|
|
||||||
def test_get_conn_returns_connection_after_init():
|
def test_get_conn_returns_connection_after_init():
|
||||||
db_module.init_db(":memory:")
|
db_module.init_db(":memory:")
|
||||||
conn = db_module.get_conn()
|
conn = db_module.get_conn()
|
||||||
assert conn is not None
|
assert conn is not None
|
||||||
|
|
||||||
|
|
||||||
def test_close_db_resets_connection():
|
def test_close_db_resets_connection():
|
||||||
db_module.init_db(":memory:")
|
db_module.init_db(":memory:")
|
||||||
db_module.close_db()
|
db_module.close_db()
|
||||||
with pytest.raises(RuntimeError):
|
with pytest.raises(RuntimeError):
|
||||||
db_module.get_conn()
|
db_module.get_conn()
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Schema: users table columns and defaults
|
# Schema: users table columns and defaults
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
def test_users_columns():
|
def test_users_columns():
|
||||||
conn = db_module.init_db(":memory:")
|
conn = db_module.init_db(":memory:")
|
||||||
cols = {
|
cols = {
|
||||||
row[0]
|
row[0]
|
||||||
for row in conn.execute(
|
for row in conn.execute(
|
||||||
"SELECT column_name FROM information_schema.columns WHERE table_name = 'users'"
|
"SELECT column_name FROM information_schema.columns WHERE table_name = 'users'"
|
||||||
).fetchall()
|
).fetchall()
|
||||||
}
|
}
|
||||||
assert {"id", "email", "password_hash",
|
assert {"id", "email", "password_hash",
|
||||||
"role", "created_at", "updated_at"} <= cols
|
"role", "created_at", "updated_at"} <= cols
|
||||||
|
|
||||||
|
|
||||||
def test_users_default_role_is_user():
|
def test_users_default_role_is_user():
|
||||||
conn = db_module.init_db(":memory:")
|
conn = db_module.init_db(":memory:")
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT INTO users (email, password_hash) VALUES ('a@example.com', 'hash')"
|
"INSERT INTO users (email, password_hash) VALUES ('a@example.com', 'hash')"
|
||||||
)
|
)
|
||||||
row = conn.execute(
|
row = conn.execute(
|
||||||
"SELECT role FROM users WHERE email = 'a@example.com'").fetchone()
|
"SELECT role FROM users WHERE email = 'a@example.com'").fetchone()
|
||||||
assert row[0] == "user"
|
assert row[0] == "user"
|
||||||
|
|
||||||
|
|
||||||
def test_users_id_auto_generated():
|
def test_users_id_auto_generated():
|
||||||
conn = db_module.init_db(":memory:")
|
conn = db_module.init_db(":memory:")
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT INTO users (email, password_hash) VALUES ('b@example.com', 'hash')"
|
"INSERT INTO users (email, password_hash) VALUES ('b@example.com', 'hash')"
|
||||||
)
|
)
|
||||||
row = conn.execute(
|
row = conn.execute(
|
||||||
"SELECT id FROM users WHERE email = 'b@example.com'").fetchone()
|
"SELECT id FROM users WHERE email = 'b@example.com'").fetchone()
|
||||||
assert row[0] is not None
|
assert row[0] is not None
|
||||||
|
|
||||||
|
|
||||||
def test_users_email_unique_constraint():
|
def test_users_email_unique_constraint():
|
||||||
conn = db_module.init_db(":memory:")
|
conn = db_module.init_db(":memory:")
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT INTO users (email, password_hash) VALUES ('c@example.com', 'h')")
|
"INSERT INTO users (email, password_hash) VALUES ('c@example.com', 'h')")
|
||||||
with pytest.raises(Exception):
|
with pytest.raises(Exception):
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT INTO users (email, password_hash) VALUES ('c@example.com', 'h2')")
|
"INSERT INTO users (email, password_hash) VALUES ('c@example.com', 'h2')")
|
||||||
|
|
||||||
|
|
||||||
def test_users_timestamps_auto_set():
|
def test_users_timestamps_auto_set():
|
||||||
conn = db_module.init_db(":memory:")
|
conn = db_module.init_db(":memory:")
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT INTO users (email, password_hash) VALUES ('d@example.com', 'hash')"
|
"INSERT INTO users (email, password_hash) VALUES ('d@example.com', 'hash')"
|
||||||
)
|
)
|
||||||
row = conn.execute(
|
row = conn.execute(
|
||||||
"SELECT created_at, updated_at FROM users WHERE email = 'd@example.com'"
|
"SELECT created_at, updated_at FROM users WHERE email = 'd@example.com'"
|
||||||
).fetchone()
|
).fetchone()
|
||||||
assert row[0] is not None
|
assert row[0] is not None
|
||||||
assert row[1] is not None
|
assert row[1] is not None
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Schema: refresh_tokens table columns and defaults
|
# Schema: refresh_tokens table columns and defaults
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
def test_refresh_tokens_columns():
|
def test_refresh_tokens_columns():
|
||||||
conn = db_module.init_db(":memory:")
|
conn = db_module.init_db(":memory:")
|
||||||
cols = {
|
cols = {
|
||||||
row[0]
|
row[0]
|
||||||
for row in conn.execute(
|
for row in conn.execute(
|
||||||
"SELECT column_name FROM information_schema.columns WHERE table_name = 'refresh_tokens'"
|
"SELECT column_name FROM information_schema.columns WHERE table_name = 'refresh_tokens'"
|
||||||
).fetchall()
|
).fetchall()
|
||||||
}
|
}
|
||||||
assert {"jti", "user_id", "issued_at", "expires_at", "revoked"} <= cols
|
assert {"jti", "user_id", "issued_at", "expires_at", "revoked"} <= cols
|
||||||
|
|
||||||
|
|
||||||
def test_refresh_tokens_default_revoked_false():
|
def test_refresh_tokens_default_revoked_false():
|
||||||
conn = db_module.init_db(":memory:")
|
conn = db_module.init_db(":memory:")
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT INTO users (email, password_hash) VALUES ('e@example.com', 'h')")
|
"INSERT INTO users (email, password_hash) VALUES ('e@example.com', 'h')")
|
||||||
user_id = conn.execute(
|
user_id = conn.execute(
|
||||||
"SELECT id FROM users WHERE email = 'e@example.com'").fetchone()[0]
|
"SELECT id FROM users WHERE email = 'e@example.com'").fetchone()[0]
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT INTO refresh_tokens (user_id, expires_at) VALUES (?, now() + INTERVAL 7 DAY)",
|
"INSERT INTO refresh_tokens (user_id, expires_at) VALUES (?, now() + INTERVAL 7 DAY)",
|
||||||
[user_id],
|
[user_id],
|
||||||
)
|
)
|
||||||
row = conn.execute("SELECT revoked FROM refresh_tokens WHERE user_id = ?", [
|
row = conn.execute("SELECT revoked FROM refresh_tokens WHERE user_id = ?", [
|
||||||
user_id]).fetchone()
|
user_id]).fetchone()
|
||||||
assert row[0] is False
|
assert row[0] is False
|
||||||
|
|
||||||
|
|
||||||
def test_refresh_tokens_jti_auto_generated():
|
def test_refresh_tokens_jti_auto_generated():
|
||||||
conn = db_module.init_db(":memory:")
|
conn = db_module.init_db(":memory:")
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT INTO users (email, password_hash) VALUES ('f@example.com', 'h')")
|
"INSERT INTO users (email, password_hash) VALUES ('f@example.com', 'h')")
|
||||||
user_id = conn.execute(
|
user_id = conn.execute(
|
||||||
"SELECT id FROM users WHERE email = 'f@example.com'").fetchone()[0]
|
"SELECT id FROM users WHERE email = 'f@example.com'").fetchone()[0]
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT INTO refresh_tokens (user_id, expires_at) VALUES (?, now() + INTERVAL 7 DAY)",
|
"INSERT INTO refresh_tokens (user_id, expires_at) VALUES (?, now() + INTERVAL 7 DAY)",
|
||||||
[user_id],
|
[user_id],
|
||||||
)
|
)
|
||||||
row = conn.execute("SELECT jti FROM refresh_tokens WHERE user_id = ?", [
|
row = conn.execute("SELECT jti FROM refresh_tokens WHERE user_id = ?", [
|
||||||
user_id]).fetchone()
|
user_id]).fetchone()
|
||||||
assert row[0] is not None
|
assert row[0] is not None
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Write lock
|
# Write lock
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
def test_get_write_lock_returns_asyncio_lock():
|
def test_get_write_lock_returns_asyncio_lock():
|
||||||
lock = db_module.get_write_lock()
|
lock = db_module.get_write_lock()
|
||||||
assert isinstance(lock, asyncio.Lock)
|
assert isinstance(lock, asyncio.Lock)
|
||||||
|
|
||||||
|
|
||||||
def test_get_write_lock_returns_same_instance():
|
def test_get_write_lock_returns_same_instance():
|
||||||
lock1 = db_module.get_write_lock()
|
lock1 = db_module.get_write_lock()
|
||||||
lock2 = db_module.get_write_lock()
|
lock2 = db_module.get_write_lock()
|
||||||
assert lock1 is lock2
|
assert lock1 is lock2
|
||||||
|
|
||||||
|
|
||||||
async def test_write_lock_serialises_concurrent_writes():
|
async def test_write_lock_serialises_concurrent_writes():
|
||||||
"""Two coroutines acquiring the lock must not overlap."""
|
"""Two coroutines acquiring the lock must not overlap."""
|
||||||
conn = db_module.init_db(":memory:")
|
conn = db_module.init_db(":memory:")
|
||||||
lock = db_module.get_write_lock()
|
lock = db_module.get_write_lock()
|
||||||
order = []
|
order = []
|
||||||
|
|
||||||
async def writer(label: str):
|
async def writer(label: str):
|
||||||
async with lock:
|
async with lock:
|
||||||
order.append(f"{label}-start")
|
order.append(f"{label}-start")
|
||||||
await asyncio.sleep(0) # yield to event loop
|
await asyncio.sleep(0) # yield to event loop
|
||||||
order.append(f"{label}-end")
|
order.append(f"{label}-end")
|
||||||
|
|
||||||
await asyncio.gather(writer("A"), writer("B"))
|
await asyncio.gather(writer("A"), writer("B"))
|
||||||
# Each writer's start and end must be adjacent (no interleaving)
|
# Each writer's start and end must be adjacent (no interleaving)
|
||||||
assert order.index("A-start") + 1 == order.index("A-end") or \
|
assert order.index("A-start") + 1 == order.index("A-end") or \
|
||||||
order.index("B-start") + 1 == order.index("B-end")
|
order.index("B-start") + 1 == order.index("B-end")
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Admin seed user
|
# Admin seed user
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
def test_seed_admin_user_created_on_init():
|
def test_seed_admin_user_created_on_init():
|
||||||
import os
|
import os
|
||||||
conn = db_module.init_db(":memory:")
|
conn = db_module.init_db(":memory:")
|
||||||
row = conn.execute(
|
row = conn.execute(
|
||||||
"SELECT email, role FROM users WHERE email = 'ai@allucanget.biz'"
|
"SELECT email, role FROM users WHERE email = 'ai@allucanget.biz'"
|
||||||
).fetchone()
|
).fetchone()
|
||||||
assert row is not None
|
assert row is not None
|
||||||
assert row[0] == "ai@allucanget.biz"
|
assert row[0] == "ai@allucanget.biz"
|
||||||
assert row[1] == "admin"
|
assert row[1] == "admin"
|
||||||
|
|
||||||
|
|
||||||
def test_seed_admin_is_idempotent():
|
def test_seed_admin_is_idempotent():
|
||||||
conn = db_module.init_db(":memory:")
|
conn = db_module.init_db(":memory:")
|
||||||
# Simulate re-running seed (second init_db call reuses connection, so call _seed_admin directly)
|
# Simulate re-running seed (second init_db call reuses connection, so call _seed_admin directly)
|
||||||
db_module._seed_admin(conn)
|
db_module._seed_admin(conn)
|
||||||
count = conn.execute(
|
count = conn.execute(
|
||||||
"SELECT COUNT(*) FROM users WHERE email = 'ai@allucanget.biz'"
|
"SELECT COUNT(*) FROM users WHERE email = 'ai@allucanget.biz'"
|
||||||
).fetchone()[0]
|
).fetchone()[0]
|
||||||
assert count == 1
|
assert count == 1
|
||||||
|
|
||||||
|
|
||||||
def test_seed_admin_email_env_override(monkeypatch):
|
def test_seed_admin_email_env_override(monkeypatch):
|
||||||
monkeypatch.setenv("ADMIN_EMAIL", "custom@example.com")
|
monkeypatch.setenv("ADMIN_EMAIL", "custom@example.com")
|
||||||
monkeypatch.setenv("ADMIN_PASSWORD", "custompass")
|
monkeypatch.setenv("ADMIN_PASSWORD", "custompass")
|
||||||
conn = db_module.init_db(":memory:")
|
conn = db_module.init_db(":memory:")
|
||||||
row = conn.execute(
|
row = conn.execute(
|
||||||
"SELECT email, role FROM users WHERE email = 'custom@example.com'"
|
"SELECT email, role FROM users WHERE email = 'custom@example.com'"
|
||||||
).fetchone()
|
).fetchone()
|
||||||
assert row is not None
|
assert row is not None
|
||||||
assert row[1] == "admin"
|
assert row[1] == "admin"
|
||||||
+668
-591
File diff suppressed because it is too large.
Load diff
+184
-184
@@ -1,184 +1,184 @@
|
|||||||
"""Tests for image upload and retrieval endpoints."""
|
"""Tests for image upload and retrieval endpoints."""
|
||||||
import io
|
import io
|
||||||
import os
|
import os
|
||||||
import pytest
|
import pytest
|
||||||
import pytest_asyncio
|
import pytest_asyncio
|
||||||
from httpx import AsyncClient, ASGITransport
|
from httpx import AsyncClient, ASGITransport
|
||||||
|
|
||||||
from app.main import app
|
from app.main import app
|
||||||
from app import db as db_module
|
from app import db as db_module
|
||||||
|
|
||||||
os.environ.setdefault("JWT_SECRET", "test-secret-key-for-testing-only")
|
os.environ.setdefault("JWT_SECRET", "test-secret-key-for-testing-only")
|
||||||
# Use a temp dir so file I/O works without polluting project data/
|
# Use a temp dir so file I/O works without polluting project data/
|
||||||
os.environ.setdefault("UPLOAD_DIR", "/tmp/test_uploads")
|
os.environ.setdefault("UPLOAD_DIR", "/tmp/test_uploads")
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
@pytest.fixture(autouse=True)
|
||||||
def fresh_db():
|
def fresh_db():
|
||||||
db_module._conn = None
|
db_module._conn = None
|
||||||
db_module.init_db(":memory:")
|
db_module.init_db(":memory:")
|
||||||
yield
|
yield
|
||||||
db_module.close_db()
|
db_module.close_db()
|
||||||
db_module._conn = None
|
db_module._conn = None
|
||||||
|
|
||||||
|
|
||||||
@pytest_asyncio.fixture
|
@pytest_asyncio.fixture
|
||||||
async def client(fresh_db):
|
async def client(fresh_db):
|
||||||
transport = ASGITransport(app=app)
|
transport = ASGITransport(app=app)
|
||||||
async with AsyncClient(transport=transport, base_url="http://test") as ac:
|
async with AsyncClient(transport=transport, base_url="http://test") as ac:
|
||||||
yield ac
|
yield ac
|
||||||
|
|
||||||
|
|
||||||
async def _user_token(client) -> str:
|
async def _user_token(client) -> str:
|
||||||
await client.post("/auth/register", json={"email": "user@example.com", "password": "secret123"})
|
await client.post("/auth/register", json={"email": "user@example.com", "password": "secret123"})
|
||||||
resp = await client.post("/auth/login", json={"email": "user@example.com", "password": "secret123"})
|
resp = await client.post("/auth/login", json={"email": "user@example.com", "password": "secret123"})
|
||||||
return resp.json()["access_token"]
|
return resp.json()["access_token"]
|
||||||
|
|
||||||
|
|
||||||
async def _other_token(client) -> str:
|
async def _other_token(client) -> str:
|
||||||
await client.post("/auth/register", json={"email": "other@example.com", "password": "secret123"})
|
await client.post("/auth/register", json={"email": "other@example.com", "password": "secret123"})
|
||||||
resp = await client.post("/auth/login", json={"email": "other@example.com", "password": "secret123"})
|
resp = await client.post("/auth/login", json={"email": "other@example.com", "password": "secret123"})
|
||||||
return resp.json()["access_token"]
|
return resp.json()["access_token"]
|
||||||
|
|
||||||
|
|
||||||
def _png_bytes() -> bytes:
|
def _png_bytes() -> bytes:
|
||||||
"""Minimal valid 1x1 PNG."""
|
"""Minimal valid 1x1 PNG."""
|
||||||
return (
|
return (
|
||||||
b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01"
|
b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01"
|
||||||
b"\x08\x02\x00\x00\x00\x90wS\xde\x00\x00\x00\x0cIDATx\x9cc\xf8\x0f\x00"
|
b"\x08\x02\x00\x00\x00\x90wS\xde\x00\x00\x00\x0cIDATx\x9cc\xf8\x0f\x00"
|
||||||
b"\x00\x01\x01\x00\x05\x18\xd4n\x00\x00\x00\x00IEND\xaeB`\x82"
|
b"\x00\x01\x01\x00\x05\x18\xd4n\x00\x00\x00\x00IEND\xaeB`\x82"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# POST /images/upload
|
# POST /images/upload
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
async def test_upload_image_success(client):
|
async def test_upload_image_success(client):
|
||||||
token = await _user_token(client)
|
token = await _user_token(client)
|
||||||
resp = await client.post(
|
resp = await client.post(
|
||||||
"/images/upload",
|
"/images/upload",
|
||||||
files={"file": ("test.png", io.BytesIO(_png_bytes()), "image/png")},
|
files={"file": ("test.png", io.BytesIO(_png_bytes()), "image/png")},
|
||||||
headers={"Authorization": f"Bearer {token}"},
|
headers={"Authorization": f"Bearer {token}"},
|
||||||
)
|
)
|
||||||
assert resp.status_code == 201
|
assert resp.status_code == 201
|
||||||
data = resp.json()
|
data = resp.json()
|
||||||
assert data["filename"] == "test.png"
|
assert data["filename"] == "test.png"
|
||||||
assert data["content_type"] == "image/png"
|
assert data["content_type"] == "image/png"
|
||||||
assert "id" in data
|
assert "id" in data
|
||||||
assert data["size_bytes"] > 0
|
assert data["size_bytes"] > 0
|
||||||
|
|
||||||
|
|
||||||
async def test_upload_image_unauthenticated(client):
|
async def test_upload_image_unauthenticated(client):
|
||||||
resp = await client.post(
|
resp = await client.post(
|
||||||
"/images/upload",
|
"/images/upload",
|
||||||
files={"file": ("test.png", io.BytesIO(_png_bytes()), "image/png")},
|
files={"file": ("test.png", io.BytesIO(_png_bytes()), "image/png")},
|
||||||
)
|
)
|
||||||
assert resp.status_code == 401
|
assert resp.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
async def test_upload_image_unsupported_type(client):
|
async def test_upload_image_unsupported_type(client):
|
||||||
token = await _user_token(client)
|
token = await _user_token(client)
|
||||||
resp = await client.post(
|
resp = await client.post(
|
||||||
"/images/upload",
|
"/images/upload",
|
||||||
files={"file": ("doc.pdf", io.BytesIO(
|
files={"file": ("doc.pdf", io.BytesIO(
|
||||||
b"%PDF-fake"), "application/pdf")},
|
b"%PDF-fake"), "application/pdf")},
|
||||||
headers={"Authorization": f"Bearer {token}"},
|
headers={"Authorization": f"Bearer {token}"},
|
||||||
)
|
)
|
||||||
assert resp.status_code == 415
|
assert resp.status_code == 415
|
||||||
|
|
||||||
|
|
||||||
async def test_upload_image_too_large(client, monkeypatch):
|
async def test_upload_image_too_large(client, monkeypatch):
|
||||||
import app.routers.images as images_mod
|
import app.routers.images as images_mod
|
||||||
monkeypatch.setattr(images_mod, "MAX_SIZE_BYTES", 5)
|
monkeypatch.setattr(images_mod, "MAX_SIZE_BYTES", 5)
|
||||||
token = await _user_token(client)
|
token = await _user_token(client)
|
||||||
resp = await client.post(
|
resp = await client.post(
|
||||||
"/images/upload",
|
"/images/upload",
|
||||||
files={"file": ("big.png", io.BytesIO(b"x" * 10), "image/png")},
|
files={"file": ("big.png", io.BytesIO(b"x" * 10), "image/png")},
|
||||||
headers={"Authorization": f"Bearer {token}"},
|
headers={"Authorization": f"Bearer {token}"},
|
||||||
)
|
)
|
||||||
assert resp.status_code == 413
|
assert resp.status_code == 413
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# GET /images/
|
# GET /images/
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
async def test_list_images_empty(client):
|
async def test_list_images_empty(client):
|
||||||
token = await _user_token(client)
|
token = await _user_token(client)
|
||||||
resp = await client.get("/images/", headers={"Authorization": f"Bearer {token}"})
|
resp = await client.get("/images/", headers={"Authorization": f"Bearer {token}"})
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
assert resp.json() == []
|
assert resp.json() == []
|
||||||
|
|
||||||
|
|
||||||
async def test_list_images_returns_own_only(client):
|
async def test_list_images_returns_own_only(client):
|
||||||
token = await _user_token(client)
|
token = await _user_token(client)
|
||||||
other = await _other_token(client)
|
other = await _other_token(client)
|
||||||
|
|
||||||
# Upload one image as user, one as other
|
# Upload one image as user, one as other
|
||||||
for tok, name in [(token, "mine.png"), (other, "theirs.png")]:
|
for tok, name in [(token, "mine.png"), (other, "theirs.png")]:
|
||||||
await client.post(
|
await client.post(
|
||||||
"/images/upload",
|
"/images/upload",
|
||||||
files={"file": (name, io.BytesIO(_png_bytes()), "image/png")},
|
files={"file": (name, io.BytesIO(_png_bytes()), "image/png")},
|
||||||
headers={"Authorization": f"Bearer {tok}"},
|
headers={"Authorization": f"Bearer {tok}"},
|
||||||
)
|
)
|
||||||
|
|
||||||
resp = await client.get("/images/", headers={"Authorization": f"Bearer {token}"})
|
resp = await client.get("/images/", headers={"Authorization": f"Bearer {token}"})
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
items = resp.json()
|
items = resp.json()
|
||||||
assert len(items) == 1
|
assert len(items) == 1
|
||||||
assert items[0]["filename"] == "mine.png"
|
assert items[0]["filename"] == "mine.png"
|
||||||
|
|
||||||
|
|
||||||
async def test_list_images_unauthenticated(client):
|
async def test_list_images_unauthenticated(client):
|
||||||
resp = await client.get("/images/")
|
resp = await client.get("/images/")
|
||||||
assert resp.status_code == 401
|
assert resp.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# GET /images/{id}/file
|
# GET /images/{id}/file
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
async def test_serve_image_success(client):
|
async def test_serve_image_success(client):
|
||||||
token = await _user_token(client)
|
token = await _user_token(client)
|
||||||
up = await client.post(
|
up = await client.post(
|
||||||
"/images/upload",
|
"/images/upload",
|
||||||
files={"file": ("pixel.png", io.BytesIO(_png_bytes()), "image/png")},
|
files={"file": ("pixel.png", io.BytesIO(_png_bytes()), "image/png")},
|
||||||
headers={"Authorization": f"Bearer {token}"},
|
headers={"Authorization": f"Bearer {token}"},
|
||||||
)
|
)
|
||||||
image_id = up.json()["id"]
|
image_id = up.json()["id"]
|
||||||
|
|
||||||
resp = await client.get(
|
resp = await client.get(
|
||||||
f"/images/{image_id}/file",
|
f"/images/{image_id}/file",
|
||||||
headers={"Authorization": f"Bearer {token}"},
|
headers={"Authorization": f"Bearer {token}"},
|
||||||
)
|
)
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
assert resp.headers["content-type"].startswith("image/png")
|
assert resp.headers["content-type"].startswith("image/png")
|
||||||
assert resp.content == _png_bytes()
|
assert resp.content == _png_bytes()
|
||||||
|
|
||||||
|
|
||||||
async def test_serve_image_wrong_user(client):
|
async def test_serve_image_wrong_user(client):
|
||||||
token = await _user_token(client)
|
token = await _user_token(client)
|
||||||
other = await _other_token(client)
|
other = await _other_token(client)
|
||||||
|
|
||||||
up = await client.post(
|
up = await client.post(
|
||||||
"/images/upload",
|
"/images/upload",
|
||||||
files={"file": ("secret.png", io.BytesIO(_png_bytes()), "image/png")},
|
files={"file": ("secret.png", io.BytesIO(_png_bytes()), "image/png")},
|
||||||
headers={"Authorization": f"Bearer {token}"},
|
headers={"Authorization": f"Bearer {token}"},
|
||||||
)
|
)
|
||||||
image_id = up.json()["id"]
|
image_id = up.json()["id"]
|
||||||
|
|
||||||
resp = await client.get(
|
resp = await client.get(
|
||||||
f"/images/{image_id}/file",
|
f"/images/{image_id}/file",
|
||||||
headers={"Authorization": f"Bearer {other}"},
|
headers={"Authorization": f"Bearer {other}"},
|
||||||
)
|
)
|
||||||
assert resp.status_code == 403
|
assert resp.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
async def test_serve_image_not_found(client):
|
async def test_serve_image_not_found(client):
|
||||||
token = await _user_token(client)
|
token = await _user_token(client)
|
||||||
resp = await client.get(
|
resp = await client.get(
|
||||||
"/images/00000000-0000-0000-0000-000000000000/file",
|
"/images/00000000-0000-0000-0000-000000000000/file",
|
||||||
headers={"Authorization": f"Bearer {token}"},
|
headers={"Authorization": f"Bearer {token}"},
|
||||||
)
|
)
|
||||||
assert resp.status_code == 404
|
assert resp.status_code == 404
|
||||||
+366
-366
@@ -1,366 +1,366 @@
|
|||||||
"""Tests for model cache service and router."""
|
"""Tests for model cache service and router."""
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from unittest.mock import AsyncMock, patch
|
from unittest.mock import AsyncMock, patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
import pytest_asyncio
|
import pytest_asyncio
|
||||||
from httpx import ASGITransport, AsyncClient
|
from httpx import ASGITransport, AsyncClient
|
||||||
|
|
||||||
from app import db as db_module
|
from app import db as db_module
|
||||||
from app.main import app
|
from app.main import app
|
||||||
from app.services.models import (
|
from app.services.models import (
|
||||||
_extract_output_modality,
|
_extract_output_modality,
|
||||||
_normalize_modality,
|
_normalize_modality,
|
||||||
_parse_modality,
|
_parse_modality,
|
||||||
get_cached_models,
|
get_cached_models,
|
||||||
get_model_output_modalities,
|
get_model_output_modalities,
|
||||||
is_cache_stale,
|
is_cache_stale,
|
||||||
refresh_models_cache,
|
refresh_models_cache,
|
||||||
)
|
)
|
||||||
|
|
||||||
os.environ.setdefault("JWT_SECRET", "test-secret-key-for-testing-only")
|
os.environ.setdefault("JWT_SECRET", "test-secret-key-for-testing-only")
|
||||||
os.environ.setdefault("OPENROUTER_API_KEY", "test-key")
|
os.environ.setdefault("OPENROUTER_API_KEY", "test-key")
|
||||||
|
|
||||||
FAKE_MODELS_RAW = [
|
FAKE_MODELS_RAW = [
|
||||||
{
|
{
|
||||||
"id": "openai/gpt-4o",
|
"id": "openai/gpt-4o",
|
||||||
"name": "GPT-4o",
|
"name": "GPT-4o",
|
||||||
"context_length": 128000,
|
"context_length": 128000,
|
||||||
"pricing": {"prompt": "0.000005"},
|
"pricing": {"prompt": "0.000005"},
|
||||||
"architecture": {"modality": "text->text", "output_modalities": ["text"]},
|
"architecture": {"modality": "text->text", "output_modalities": ["text"]},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "anthropic/claude-3-haiku",
|
"id": "anthropic/claude-3-haiku",
|
||||||
"name": "Claude 3 Haiku",
|
"name": "Claude 3 Haiku",
|
||||||
"context_length": 200000,
|
"context_length": 200000,
|
||||||
"pricing": {},
|
"pricing": {},
|
||||||
"architecture": {"modality": "text+image->text", "output_modalities": ["text"]},
|
"architecture": {"modality": "text+image->text", "output_modalities": ["text"]},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "openai/dall-e-3",
|
"id": "openai/dall-e-3",
|
||||||
"name": "DALL-E 3",
|
"name": "DALL-E 3",
|
||||||
"context_length": None,
|
"context_length": None,
|
||||||
"pricing": {"image": "0.04"},
|
"pricing": {"image": "0.04"},
|
||||||
"architecture": {"modality": "text->image", "output_modalities": ["image"]},
|
"architecture": {"modality": "text->image", "output_modalities": ["image"]},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "openai/sora-2",
|
"id": "openai/sora-2",
|
||||||
"name": "Sora 2",
|
"name": "Sora 2",
|
||||||
"context_length": None,
|
"context_length": None,
|
||||||
"pricing": {"video": "0.10"},
|
"pricing": {"video": "0.10"},
|
||||||
"architecture": {"modality": "text->video", "output_modalities": ["video"]},
|
"architecture": {"modality": "text->video", "output_modalities": ["video"]},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "google/gemini-2.5-flash-image",
|
"id": "google/gemini-2.5-flash-image",
|
||||||
"name": "Gemini 2.5 Flash Image",
|
"name": "Gemini 2.5 Flash Image",
|
||||||
"context_length": None,
|
"context_length": None,
|
||||||
"pricing": {},
|
"pricing": {},
|
||||||
"architecture": {"output_modalities": ["image", "text"]},
|
"architecture": {"output_modalities": ["image", "text"]},
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
@pytest.fixture(autouse=True)
|
||||||
def fresh_db():
|
def fresh_db():
|
||||||
db_module._conn = None
|
db_module._conn = None
|
||||||
db_module.init_db(":memory:")
|
db_module.init_db(":memory:")
|
||||||
yield
|
yield
|
||||||
db_module.close_db()
|
db_module.close_db()
|
||||||
db_module._conn = None
|
db_module._conn = None
|
||||||
|
|
||||||
|
|
||||||
@pytest_asyncio.fixture
|
@pytest_asyncio.fixture
|
||||||
async def client(fresh_db):
|
async def client(fresh_db):
|
||||||
transport = ASGITransport(app=app)
|
transport = ASGITransport(app=app)
|
||||||
async with AsyncClient(transport=transport, base_url="http://test") as ac:
|
async with AsyncClient(transport=transport, base_url="http://test") as ac:
|
||||||
yield ac
|
yield ac
|
||||||
|
|
||||||
|
|
||||||
async def _register_login(client, email, password, is_admin=False):
|
async def _register_login(client, email, password, is_admin=False):
|
||||||
"""Register + login; optionally promote to admin directly in DB."""
|
"""Register + login; optionally promote to admin directly in DB."""
|
||||||
await client.post("/auth/register", json={"email": email, "password": password})
|
await client.post("/auth/register", json={"email": email, "password": password})
|
||||||
if is_admin:
|
if is_admin:
|
||||||
db_module.get_conn().execute(
|
db_module.get_conn().execute(
|
||||||
"UPDATE users SET role = 'admin' WHERE email = ?", [email]
|
"UPDATE users SET role = 'admin' WHERE email = ?", [email]
|
||||||
)
|
)
|
||||||
resp = await client.post("/auth/login", json={"email": email, "password": password})
|
resp = await client.post("/auth/login", json={"email": email, "password": password})
|
||||||
return resp.json()["access_token"]
|
return resp.json()["access_token"]
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Unit tests: _parse_modality
|
# Unit tests: _parse_modality
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
def test_parse_modality_text():
|
def test_parse_modality_text():
|
||||||
assert _parse_modality("text->text") == "text"
|
assert _parse_modality("text->text") == "text"
|
||||||
|
|
||||||
|
|
||||||
def test_parse_modality_multimodal_input_text_output():
|
def test_parse_modality_multimodal_input_text_output():
|
||||||
assert _parse_modality("text+image->text") == "text"
|
assert _parse_modality("text+image->text") == "text"
|
||||||
|
|
||||||
|
|
||||||
def test_parse_modality_image():
|
def test_parse_modality_image():
|
||||||
assert _parse_modality("text->image") == "image"
|
assert _parse_modality("text->image") == "image"
|
||||||
|
|
||||||
|
|
||||||
def test_parse_modality_video():
|
def test_parse_modality_video():
|
||||||
assert _parse_modality("text->video") == "video"
|
assert _parse_modality("text->video") == "video"
|
||||||
|
|
||||||
|
|
||||||
def test_parse_modality_audio():
|
def test_parse_modality_audio():
|
||||||
assert _parse_modality("text->audio") == "audio"
|
assert _parse_modality("text->audio") == "audio"
|
||||||
|
|
||||||
|
|
||||||
def test_parse_modality_no_arrow_fallback():
|
def test_parse_modality_no_arrow_fallback():
|
||||||
assert _parse_modality("text") == "text"
|
assert _parse_modality("text") == "text"
|
||||||
|
|
||||||
|
|
||||||
def test_normalize_embedding_alias():
|
def test_normalize_embedding_alias():
|
||||||
assert _normalize_modality("embedding") == "embeddings"
|
assert _normalize_modality("embedding") == "embeddings"
|
||||||
|
|
||||||
|
|
||||||
def test_extract_output_modality_prefers_output_modalities():
|
def test_extract_output_modality_prefers_output_modalities():
|
||||||
model = {
|
model = {
|
||||||
"architecture": {
|
"architecture": {
|
||||||
"modality": "text->text",
|
"modality": "text->text",
|
||||||
"output_modalities": ["image"],
|
"output_modalities": ["image"],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
assert _extract_output_modality(model) == "image"
|
assert _extract_output_modality(model) == "image"
|
||||||
|
|
||||||
|
|
||||||
def test_extract_output_modality_legacy_fallback():
|
def test_extract_output_modality_legacy_fallback():
|
||||||
model = {"architecture": {"modality": "text->audio"}}
|
model = {"architecture": {"modality": "text->audio"}}
|
||||||
assert _extract_output_modality(model) == "audio"
|
assert _extract_output_modality(model) == "audio"
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Unit tests: is_cache_stale
|
# Unit tests: is_cache_stale
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
def test_cache_stale_when_empty():
|
def test_cache_stale_when_empty():
|
||||||
conn = db_module.get_conn()
|
conn = db_module.get_conn()
|
||||||
assert is_cache_stale(conn) is True
|
assert is_cache_stale(conn) is True
|
||||||
|
|
||||||
|
|
||||||
def test_cache_not_stale_after_fresh_insert():
|
def test_cache_not_stale_after_fresh_insert():
|
||||||
conn = db_module.get_conn()
|
conn = db_module.get_conn()
|
||||||
now = datetime.now(timezone.utc).replace(tzinfo=None)
|
now = datetime.now(timezone.utc).replace(tzinfo=None)
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT INTO models_cache (model_id, name, modality, fetched_at) VALUES (?, ?, ?, ?)",
|
"INSERT INTO models_cache (model_id, name, modality, fetched_at) VALUES (?, ?, ?, ?)",
|
||||||
["openai/gpt-4o", "GPT-4o", "text", now],
|
["openai/gpt-4o", "GPT-4o", "text", now],
|
||||||
)
|
)
|
||||||
assert is_cache_stale(conn) is False
|
assert is_cache_stale(conn) is False
|
||||||
|
|
||||||
|
|
||||||
def test_cache_stale_after_ttl_exceeded():
|
def test_cache_stale_after_ttl_exceeded():
|
||||||
conn = db_module.get_conn()
|
conn = db_module.get_conn()
|
||||||
# Store naive UTC to match DuckDB TIMESTAMP behaviour
|
# Store naive UTC to match DuckDB TIMESTAMP behaviour
|
||||||
old_time = datetime.now(timezone.utc).replace(
|
old_time = datetime.now(timezone.utc).replace(
|
||||||
tzinfo=None) - timedelta(hours=25)
|
tzinfo=None) - timedelta(hours=25)
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT INTO models_cache (model_id, name, modality, fetched_at) VALUES (?, ?, ?, ?)",
|
"INSERT INTO models_cache (model_id, name, modality, fetched_at) VALUES (?, ?, ?, ?)",
|
||||||
["openai/gpt-4o", "GPT-4o", "text", old_time],
|
["openai/gpt-4o", "GPT-4o", "text", old_time],
|
||||||
)
|
)
|
||||||
assert is_cache_stale(conn) is True
|
assert is_cache_stale(conn) is True
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Unit tests: refresh_models_cache + get_cached_models
|
# Unit tests: refresh_models_cache + get_cached_models
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
async def test_refresh_stores_models():
|
async def test_refresh_stores_models():
|
||||||
conn = db_module.get_conn()
|
conn = db_module.get_conn()
|
||||||
with patch(
|
with patch(
|
||||||
"app.services.models.openrouter.list_models",
|
"app.services.models.openrouter.list_models",
|
||||||
new_callable=AsyncMock,
|
new_callable=AsyncMock,
|
||||||
return_value=FAKE_MODELS_RAW,
|
return_value=FAKE_MODELS_RAW,
|
||||||
):
|
):
|
||||||
count = await refresh_models_cache(conn)
|
count = await refresh_models_cache(conn)
|
||||||
assert count == 5
|
assert count == 5
|
||||||
all_models = get_cached_models(conn)
|
all_models = get_cached_models(conn)
|
||||||
assert len(all_models) == 5
|
assert len(all_models) == 5
|
||||||
|
|
||||||
|
|
||||||
async def test_refresh_replaces_old_cache():
|
async def test_refresh_replaces_old_cache():
|
||||||
conn = db_module.get_conn()
|
conn = db_module.get_conn()
|
||||||
old_time = datetime.now(timezone.utc).replace(
|
old_time = datetime.now(timezone.utc).replace(
|
||||||
tzinfo=None) - timedelta(hours=30)
|
tzinfo=None) - timedelta(hours=30)
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT INTO models_cache (model_id, name, modality, fetched_at) VALUES (?, ?, ?, ?)",
|
"INSERT INTO models_cache (model_id, name, modality, fetched_at) VALUES (?, ?, ?, ?)",
|
||||||
["old/model", "Old Model", "text", old_time],
|
["old/model", "Old Model", "text", old_time],
|
||||||
)
|
)
|
||||||
with patch(
|
with patch(
|
||||||
"app.services.models.openrouter.list_models",
|
"app.services.models.openrouter.list_models",
|
||||||
new_callable=AsyncMock,
|
new_callable=AsyncMock,
|
||||||
return_value=FAKE_MODELS_RAW,
|
return_value=FAKE_MODELS_RAW,
|
||||||
):
|
):
|
||||||
await refresh_models_cache(conn)
|
await refresh_models_cache(conn)
|
||||||
ids = [m["id"] for m in get_cached_models(conn)]
|
ids = [m["id"] for m in get_cached_models(conn)]
|
||||||
assert "old/model" not in ids
|
assert "old/model" not in ids
|
||||||
assert "openai/gpt-4o" in ids
|
assert "openai/gpt-4o" in ids
|
||||||
assert len(ids) == 5
|
assert len(ids) == 5
|
||||||
|
|
||||||
|
|
||||||
def test_get_cached_models_filter_by_modality():
|
def test_get_cached_models_filter_by_modality():
|
||||||
conn = db_module.get_conn()
|
conn = db_module.get_conn()
|
||||||
now = datetime.now(timezone.utc).replace(tzinfo=None)
|
now = datetime.now(timezone.utc).replace(tzinfo=None)
|
||||||
for m in FAKE_MODELS_RAW:
|
for m in FAKE_MODELS_RAW:
|
||||||
modality = _extract_output_modality(m)
|
modality = _extract_output_modality(m)
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT INTO models_cache (model_id, name, modality, fetched_at) VALUES (?, ?, ?, ?)",
|
"INSERT INTO models_cache (model_id, name, modality, fetched_at) VALUES (?, ?, ?, ?)",
|
||||||
[m["id"], m["name"], modality, now],
|
[m["id"], m["name"], modality, now],
|
||||||
)
|
)
|
||||||
text_models = get_cached_models(conn, modality="text")
|
text_models = get_cached_models(conn, modality="text")
|
||||||
# gpt-4o, claude-3-haiku (gemini has output_modalities=["image","text"] → classified as "image")
|
# gpt-4o, claude-3-haiku (gemini has output_modalities=["image","text"] → classified as "image")
|
||||||
assert len(text_models) == 2
|
assert len(text_models) == 2
|
||||||
assert all(m["modality"] == "text" for m in text_models)
|
assert all(m["modality"] == "text" for m in text_models)
|
||||||
|
|
||||||
image_models = get_cached_models(conn, modality="image")
|
image_models = get_cached_models(conn, modality="image")
|
||||||
# dall-e-3 + gemini (output_modalities starts with image)
|
# dall-e-3 + gemini (output_modalities starts with image)
|
||||||
assert len(image_models) == 2
|
assert len(image_models) == 2
|
||||||
image_ids = [m["id"] for m in image_models]
|
image_ids = [m["id"] for m in image_models]
|
||||||
assert "openai/dall-e-3" in image_ids
|
assert "openai/dall-e-3" in image_ids
|
||||||
assert "google/gemini-2.5-flash-image" in image_ids
|
assert "google/gemini-2.5-flash-image" in image_ids
|
||||||
|
|
||||||
video_models = get_cached_models(conn, modality="video")
|
video_models = get_cached_models(conn, modality="video")
|
||||||
assert len(video_models) == 1
|
assert len(video_models) == 1
|
||||||
assert video_models[0]["id"] == "openai/sora-2"
|
assert video_models[0]["id"] == "openai/sora-2"
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Integration tests: GET /models/
|
# Integration tests: GET /models/
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
async def test_list_models_endpoint_auto_refreshes(client):
|
async def test_list_models_endpoint_auto_refreshes(client):
|
||||||
token = await _register_login(client, "user@example.com", "secret123")
|
token = await _register_login(client, "user@example.com", "secret123")
|
||||||
with patch(
|
with patch(
|
||||||
"app.services.models.openrouter.list_models",
|
"app.services.models.openrouter.list_models",
|
||||||
new_callable=AsyncMock,
|
new_callable=AsyncMock,
|
||||||
return_value=FAKE_MODELS_RAW,
|
return_value=FAKE_MODELS_RAW,
|
||||||
) as mock_fetch:
|
) as mock_fetch:
|
||||||
resp = await client.get(
|
resp = await client.get(
|
||||||
"/models/", headers={"Authorization": f"Bearer {token}"}
|
"/models/", headers={"Authorization": f"Bearer {token}"}
|
||||||
)
|
)
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
assert len(resp.json()) == 5
|
assert len(resp.json()) == 5
|
||||||
assert mock_fetch.await_count >= 1
|
assert mock_fetch.await_count >= 1
|
||||||
|
|
||||||
|
|
||||||
async def test_list_models_endpoint_uses_cache(client):
|
async def test_list_models_endpoint_uses_cache(client):
|
||||||
token = await _register_login(client, "user@example.com", "secret123")
|
token = await _register_login(client, "user@example.com", "secret123")
|
||||||
conn = db_module.get_conn()
|
conn = db_module.get_conn()
|
||||||
now = datetime.now(timezone.utc).replace(tzinfo=None)
|
now = datetime.now(timezone.utc).replace(tzinfo=None)
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT INTO models_cache (model_id, name, modality, fetched_at) VALUES (?, ?, ?, ?)",
|
"INSERT INTO models_cache (model_id, name, modality, fetched_at) VALUES (?, ?, ?, ?)",
|
||||||
["cached/model", "Cached Model", "text", now],
|
["cached/model", "Cached Model", "text", now],
|
||||||
)
|
)
|
||||||
with patch(
|
with patch(
|
||||||
"app.services.models.openrouter.list_models",
|
"app.services.models.openrouter.list_models",
|
||||||
new_callable=AsyncMock,
|
new_callable=AsyncMock,
|
||||||
) as mock_fetch:
|
) as mock_fetch:
|
||||||
resp = await client.get(
|
resp = await client.get(
|
||||||
"/models/?modality=text", headers={"Authorization": f"Bearer {token}"}
|
"/models/?modality=text", headers={"Authorization": f"Bearer {token}"}
|
||||||
)
|
)
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
assert resp.json()[0]["id"] == "cached/model"
|
assert resp.json()[0]["id"] == "cached/model"
|
||||||
mock_fetch.assert_not_awaited()
|
mock_fetch.assert_not_awaited()
|
||||||
|
|
||||||
|
|
||||||
async def test_list_models_endpoint_requires_auth(client):
|
async def test_list_models_endpoint_requires_auth(client):
|
||||||
resp = await client.get("/models/")
|
resp = await client.get("/models/")
|
||||||
assert resp.status_code == 401
|
assert resp.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
async def test_list_models_filter_by_modality(client):
|
async def test_list_models_filter_by_modality(client):
|
||||||
token = await _register_login(client, "user@example.com", "secret123")
|
token = await _register_login(client, "user@example.com", "secret123")
|
||||||
with patch(
|
with patch(
|
||||||
"app.services.models.openrouter.list_models",
|
"app.services.models.openrouter.list_models",
|
||||||
new_callable=AsyncMock,
|
new_callable=AsyncMock,
|
||||||
return_value=FAKE_MODELS_RAW,
|
return_value=FAKE_MODELS_RAW,
|
||||||
):
|
):
|
||||||
resp = await client.get(
|
resp = await client.get(
|
||||||
"/models/?modality=image", headers={"Authorization": f"Bearer {token}"}
|
"/models/?modality=image", headers={"Authorization": f"Bearer {token}"}
|
||||||
)
|
)
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
data = resp.json()
|
data = resp.json()
|
||||||
assert len(data) == 2 # dall-e-3 + gemini-2.5-flash-image
|
assert len(data) == 2 # dall-e-3 + gemini-2.5-flash-image
|
||||||
image_ids = [m["id"] for m in data]
|
image_ids = [m["id"] for m in data]
|
||||||
assert "openai/dall-e-3" in image_ids
|
assert "openai/dall-e-3" in image_ids
|
||||||
assert "google/gemini-2.5-flash-image" in image_ids
|
assert "google/gemini-2.5-flash-image" in image_ids
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Integration tests: POST /models/refresh
|
# Integration tests: POST /models/refresh
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
async def test_refresh_endpoint_requires_admin(client):
|
async def test_refresh_endpoint_requires_admin(client):
|
||||||
token = await _register_login(client, "user@example.com", "secret123")
|
token = await _register_login(client, "user@example.com", "secret123")
|
||||||
resp = await client.post(
|
resp = await client.post(
|
||||||
"/models/refresh", headers={"Authorization": f"Bearer {token}"}
|
"/models/refresh", headers={"Authorization": f"Bearer {token}"}
|
||||||
)
|
)
|
||||||
assert resp.status_code == 403
|
assert resp.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
async def test_refresh_endpoint_admin_succeeds(client):
|
async def test_refresh_endpoint_admin_succeeds(client):
|
||||||
token = await _register_login(client, "admin@example.com", "secret123", is_admin=True)
|
token = await _register_login(client, "admin@example.com", "secret123", is_admin=True)
|
||||||
with patch(
|
with patch(
|
||||||
"app.services.models.openrouter.list_models",
|
"app.services.models.openrouter.list_models",
|
||||||
new_callable=AsyncMock,
|
new_callable=AsyncMock,
|
||||||
return_value=FAKE_MODELS_RAW,
|
return_value=FAKE_MODELS_RAW,
|
||||||
):
|
):
|
||||||
resp = await client.post(
|
resp = await client.post(
|
||||||
"/models/refresh", headers={"Authorization": f"Bearer {token}"}
|
"/models/refresh", headers={"Authorization": f"Bearer {token}"}
|
||||||
)
|
)
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
assert resp.json()["refreshed"] == 5
|
assert resp.json()["refreshed"] == 5
|
||||||
|
|
||||||
|
|
||||||
async def test_refresh_endpoint_502_on_openrouter_error(client):
|
async def test_refresh_endpoint_502_on_openrouter_error(client):
|
||||||
token = await _register_login(client, "admin@example.com", "secret123", is_admin=True)
|
token = await _register_login(client, "admin@example.com", "secret123", is_admin=True)
|
||||||
with patch(
|
with patch(
|
||||||
"app.services.models.openrouter.list_models",
|
"app.services.models.openrouter.list_models",
|
||||||
new_callable=AsyncMock,
|
new_callable=AsyncMock,
|
||||||
side_effect=RuntimeError("network error"),
|
side_effect=RuntimeError("network error"),
|
||||||
):
|
):
|
||||||
resp = await client.post(
|
resp = await client.post(
|
||||||
"/models/refresh", headers={"Authorization": f"Bearer {token}"}
|
"/models/refresh", headers={"Authorization": f"Bearer {token}"}
|
||||||
)
|
)
|
||||||
assert resp.status_code == 502
|
assert resp.status_code == 502
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Unit tests: get_model_output_modalities
|
# Unit tests: get_model_output_modalities
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
async def test_get_model_output_modalities_image_only():
|
async def test_get_model_output_modalities_image_only():
|
||||||
conn = db_module.get_conn()
|
conn = db_module.get_conn()
|
||||||
with patch(
|
with patch(
|
||||||
"app.services.models.openrouter.list_models",
|
"app.services.models.openrouter.list_models",
|
||||||
new_callable=AsyncMock,
|
new_callable=AsyncMock,
|
||||||
return_value=FAKE_MODELS_RAW,
|
return_value=FAKE_MODELS_RAW,
|
||||||
):
|
):
|
||||||
await refresh_models_cache(conn)
|
await refresh_models_cache(conn)
|
||||||
modalities = get_model_output_modalities(conn, "openai/dall-e-3")
|
modalities = get_model_output_modalities(conn, "openai/dall-e-3")
|
||||||
assert modalities == ["image"]
|
assert modalities == ["image"]
|
||||||
|
|
||||||
|
|
||||||
async def test_get_model_output_modalities_image_text():
|
async def test_get_model_output_modalities_image_text():
|
||||||
conn = db_module.get_conn()
|
conn = db_module.get_conn()
|
||||||
with patch(
|
with patch(
|
||||||
"app.services.models.openrouter.list_models",
|
"app.services.models.openrouter.list_models",
|
||||||
new_callable=AsyncMock,
|
new_callable=AsyncMock,
|
||||||
return_value=FAKE_MODELS_RAW,
|
return_value=FAKE_MODELS_RAW,
|
||||||
):
|
):
|
||||||
await refresh_models_cache(conn)
|
await refresh_models_cache(conn)
|
||||||
modalities = get_model_output_modalities(
|
modalities = get_model_output_modalities(
|
||||||
conn, "google/gemini-2.5-flash-image")
|
conn, "google/gemini-2.5-flash-image")
|
||||||
assert set(modalities) == {"image", "text"}
|
assert set(modalities) == {"image", "text"}
|
||||||
|
|
||||||
|
|
||||||
def test_get_model_output_modalities_unknown_model():
|
def test_get_model_output_modalities_unknown_model():
|
||||||
conn = db_module.get_conn()
|
conn = db_module.get_conn()
|
||||||
result = get_model_output_modalities(conn, "unknown/model")
|
result = get_model_output_modalities(conn, "unknown/model")
|
||||||
assert result == []
|
assert result == []
|
||||||
+208
-208
@@ -1,208 +1,208 @@
|
|||||||
"""Integration tests for user management endpoints."""
|
"""Integration tests for user management endpoints."""
|
||||||
import os
|
import os
|
||||||
import pytest
|
import pytest
|
||||||
import pytest_asyncio
|
import pytest_asyncio
|
||||||
from httpx import AsyncClient, ASGITransport
|
from httpx import AsyncClient, ASGITransport
|
||||||
|
|
||||||
from app.main import app
|
from app.main import app
|
||||||
from app import db as db_module
|
from app import db as db_module
|
||||||
|
|
||||||
os.environ.setdefault("JWT_SECRET", "test-secret-key-for-testing-only")
|
os.environ.setdefault("JWT_SECRET", "test-secret-key-for-testing-only")
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
@pytest.fixture(autouse=True)
|
||||||
def fresh_db():
|
def fresh_db():
|
||||||
db_module._conn = None
|
db_module._conn = None
|
||||||
db_module.init_db(":memory:")
|
db_module.init_db(":memory:")
|
||||||
yield
|
yield
|
||||||
db_module.close_db()
|
db_module.close_db()
|
||||||
db_module._conn = None
|
db_module._conn = None
|
||||||
|
|
||||||
|
|
||||||
@pytest_asyncio.fixture
|
@pytest_asyncio.fixture
|
||||||
async def client(fresh_db):
|
async def client(fresh_db):
|
||||||
transport = ASGITransport(app=app)
|
transport = ASGITransport(app=app)
|
||||||
async with AsyncClient(transport=transport, base_url="http://test") as ac:
|
async with AsyncClient(transport=transport, base_url="http://test") as ac:
|
||||||
yield ac
|
yield ac
|
||||||
|
|
||||||
|
|
||||||
async def _register_and_login(client, email="user@example.com", password="secret123"):
|
async def _register_and_login(client, email="user@example.com", password="secret123"):
|
||||||
await client.post("/auth/register", json={"email": email, "password": password})
|
await client.post("/auth/register", json={"email": email, "password": password})
|
||||||
resp = await client.post("/auth/login", json={"email": email, "password": password})
|
resp = await client.post("/auth/login", json={"email": email, "password": password})
|
||||||
return resp.json()["access_token"]
|
return resp.json()["access_token"]
|
||||||
|
|
||||||
|
|
||||||
async def _make_admin(user_id: str):
|
async def _make_admin(user_id: str):
|
||||||
"""Directly set a user's role to admin in the DB."""
|
"""Directly set a user's role to admin in the DB."""
|
||||||
conn = db_module.get_conn()
|
conn = db_module.get_conn()
|
||||||
conn.execute("UPDATE users SET role = 'admin' WHERE id = ?", [user_id])
|
conn.execute("UPDATE users SET role = 'admin' WHERE id = ?", [user_id])
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# GET /users/me
|
# GET /users/me
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
async def test_get_me(client):
|
async def test_get_me(client):
|
||||||
token = await _register_and_login(client)
|
token = await _register_and_login(client)
|
||||||
resp = await client.get("/users/me", headers={"Authorization": f"Bearer {token}"})
|
resp = await client.get("/users/me", headers={"Authorization": f"Bearer {token}"})
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
data = resp.json()
|
data = resp.json()
|
||||||
assert data["email"] == "user@example.com"
|
assert data["email"] == "user@example.com"
|
||||||
assert data["role"] == "user"
|
assert data["role"] == "user"
|
||||||
assert "id" in data
|
assert "id" in data
|
||||||
|
|
||||||
|
|
||||||
async def test_get_me_unauthenticated(client):
|
async def test_get_me_unauthenticated(client):
|
||||||
resp = await client.get("/users/me")
|
resp = await client.get("/users/me")
|
||||||
assert resp.status_code == 401
|
assert resp.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# PUT /users/me
|
# PUT /users/me
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
async def test_update_me_email(client):
|
async def test_update_me_email(client):
|
||||||
token = await _register_and_login(client)
|
token = await _register_and_login(client)
|
||||||
resp = await client.put(
|
resp = await client.put(
|
||||||
"/users/me",
|
"/users/me",
|
||||||
json={"email": "new@example.com"},
|
json={"email": "new@example.com"},
|
||||||
headers={"Authorization": f"Bearer {token}"},
|
headers={"Authorization": f"Bearer {token}"},
|
||||||
)
|
)
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
assert resp.json()["email"] == "new@example.com"
|
assert resp.json()["email"] == "new@example.com"
|
||||||
|
|
||||||
|
|
||||||
async def test_update_me_password(client):
|
async def test_update_me_password(client):
|
||||||
token = await _register_and_login(client)
|
token = await _register_and_login(client)
|
||||||
resp = await client.put(
|
resp = await client.put(
|
||||||
"/users/me",
|
"/users/me",
|
||||||
json={"password": "newpassword123"},
|
json={"password": "newpassword123"},
|
||||||
headers={"Authorization": f"Bearer {token}"},
|
headers={"Authorization": f"Bearer {token}"},
|
||||||
)
|
)
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
# Verify new password works for login
|
# Verify new password works for login
|
||||||
login = await client.post(
|
login = await client.post(
|
||||||
"/auth/login", json={"email": "user@example.com", "password": "newpassword123"}
|
"/auth/login", json={"email": "user@example.com", "password": "newpassword123"}
|
||||||
)
|
)
|
||||||
assert login.status_code == 200
|
assert login.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
async def test_update_me_duplicate_email(client):
|
async def test_update_me_duplicate_email(client):
|
||||||
await _register_and_login(client, "other@example.com")
|
await _register_and_login(client, "other@example.com")
|
||||||
token = await _register_and_login(client, "user@example.com")
|
token = await _register_and_login(client, "user@example.com")
|
||||||
resp = await client.put(
|
resp = await client.put(
|
||||||
"/users/me",
|
"/users/me",
|
||||||
json={"email": "other@example.com"},
|
json={"email": "other@example.com"},
|
||||||
headers={"Authorization": f"Bearer {token}"},
|
headers={"Authorization": f"Bearer {token}"},
|
||||||
)
|
)
|
||||||
assert resp.status_code == 409
|
assert resp.status_code == 409
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# GET /users (admin only)
|
# GET /users (admin only)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
async def test_list_users_as_admin(client):
|
async def test_list_users_as_admin(client):
|
||||||
token = await _register_and_login(client)
|
token = await _register_and_login(client)
|
||||||
me = await client.get("/users/me", headers={"Authorization": f"Bearer {token}"})
|
me = await client.get("/users/me", headers={"Authorization": f"Bearer {token}"})
|
||||||
await _make_admin(me.json()["id"])
|
await _make_admin(me.json()["id"])
|
||||||
|
|
||||||
# Re-login to get a token with admin role
|
# Re-login to get a token with admin role
|
||||||
login = await client.post(
|
login = await client.post(
|
||||||
"/auth/login", json={"email": "user@example.com", "password": "secret123"}
|
"/auth/login", json={"email": "user@example.com", "password": "secret123"}
|
||||||
)
|
)
|
||||||
admin_token = login.json()["access_token"]
|
admin_token = login.json()["access_token"]
|
||||||
resp = await client.get("/users", headers={"Authorization": f"Bearer {admin_token}"})
|
resp = await client.get("/users", headers={"Authorization": f"Bearer {admin_token}"})
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
assert isinstance(resp.json(), list)
|
assert isinstance(resp.json(), list)
|
||||||
assert len(resp.json()) >= 1
|
assert len(resp.json()) >= 1
|
||||||
emails = [u["email"] for u in resp.json()]
|
emails = [u["email"] for u in resp.json()]
|
||||||
assert "user@example.com" in emails
|
assert "user@example.com" in emails
|
||||||
|
|
||||||
|
|
||||||
async def test_list_users_as_regular_user(client):
|
async def test_list_users_as_regular_user(client):
|
||||||
token = await _register_and_login(client)
|
token = await _register_and_login(client)
|
||||||
resp = await client.get("/users", headers={"Authorization": f"Bearer {token}"})
|
resp = await client.get("/users", headers={"Authorization": f"Bearer {token}"})
|
||||||
assert resp.status_code == 403
|
assert resp.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# DELETE /users/{id} (admin only)
|
# DELETE /users/{id} (admin only)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
async def test_delete_user_as_admin(client):
|
async def test_delete_user_as_admin(client):
|
||||||
# Register target user
|
# Register target user
|
||||||
await client.post("/auth/register", json={"email": "target@example.com", "password": "secret123"})
|
await client.post("/auth/register", json={"email": "target@example.com", "password": "secret123"})
|
||||||
target_resp = await client.post("/auth/login", json={"email": "target@example.com", "password": "secret123"})
|
target_resp = await client.post("/auth/login", json={"email": "target@example.com", "password": "secret123"})
|
||||||
target_token = target_resp.json()["access_token"]
|
target_token = target_resp.json()["access_token"]
|
||||||
target_me = await client.get("/users/me", headers={"Authorization": f"Bearer {target_token}"})
|
target_me = await client.get("/users/me", headers={"Authorization": f"Bearer {target_token}"})
|
||||||
target_id = target_me.json()["id"]
|
target_id = target_me.json()["id"]
|
||||||
|
|
||||||
# Register admin
|
# Register admin
|
||||||
token = await _register_and_login(client)
|
token = await _register_and_login(client)
|
||||||
me = await client.get("/users/me", headers={"Authorization": f"Bearer {token}"})
|
me = await client.get("/users/me", headers={"Authorization": f"Bearer {token}"})
|
||||||
await _make_admin(me.json()["id"])
|
await _make_admin(me.json()["id"])
|
||||||
login = await client.post("/auth/login", json={"email": "user@example.com", "password": "secret123"})
|
login = await client.post("/auth/login", json={"email": "user@example.com", "password": "secret123"})
|
||||||
admin_token = login.json()["access_token"]
|
admin_token = login.json()["access_token"]
|
||||||
|
|
||||||
resp = await client.delete(f"/users/{target_id}", headers={"Authorization": f"Bearer {admin_token}"})
|
resp = await client.delete(f"/users/{target_id}", headers={"Authorization": f"Bearer {admin_token}"})
|
||||||
assert resp.status_code == 204
|
assert resp.status_code == 204
|
||||||
|
|
||||||
|
|
||||||
async def test_delete_own_account_forbidden(client):
|
async def test_delete_own_account_forbidden(client):
|
||||||
token = await _register_and_login(client)
|
token = await _register_and_login(client)
|
||||||
me = await client.get("/users/me", headers={"Authorization": f"Bearer {token}"})
|
me = await client.get("/users/me", headers={"Authorization": f"Bearer {token}"})
|
||||||
user_id = me.json()["id"]
|
user_id = me.json()["id"]
|
||||||
await _make_admin(user_id)
|
await _make_admin(user_id)
|
||||||
login = await client.post("/auth/login", json={"email": "user@example.com", "password": "secret123"})
|
login = await client.post("/auth/login", json={"email": "user@example.com", "password": "secret123"})
|
||||||
admin_token = login.json()["access_token"]
|
admin_token = login.json()["access_token"]
|
||||||
|
|
||||||
resp = await client.delete(f"/users/{user_id}", headers={"Authorization": f"Bearer {admin_token}"})
|
resp = await client.delete(f"/users/{user_id}", headers={"Authorization": f"Bearer {admin_token}"})
|
||||||
assert resp.status_code == 400
|
assert resp.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# PUT /users/{id}/role (admin only)
|
# PUT /users/{id}/role (admin only)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
async def test_set_role_as_admin(client):
|
async def test_set_role_as_admin(client):
|
||||||
# Register target
|
# Register target
|
||||||
await client.post("/auth/register", json={"email": "target@example.com", "password": "secret123"})
|
await client.post("/auth/register", json={"email": "target@example.com", "password": "secret123"})
|
||||||
target_resp = await client.post("/auth/login", json={"email": "target@example.com", "password": "secret123"})
|
target_resp = await client.post("/auth/login", json={"email": "target@example.com", "password": "secret123"})
|
||||||
target_me = await client.get("/users/me", headers={"Authorization": f"Bearer {target_resp.json()['access_token']}"})
|
target_me = await client.get("/users/me", headers={"Authorization": f"Bearer {target_resp.json()['access_token']}"})
|
||||||
target_id = target_me.json()["id"]
|
target_id = target_me.json()["id"]
|
||||||
|
|
||||||
# Register admin
|
# Register admin
|
||||||
token = await _register_and_login(client)
|
token = await _register_and_login(client)
|
||||||
me = await client.get("/users/me", headers={"Authorization": f"Bearer {token}"})
|
me = await client.get("/users/me", headers={"Authorization": f"Bearer {token}"})
|
||||||
await _make_admin(me.json()["id"])
|
await _make_admin(me.json()["id"])
|
||||||
login = await client.post("/auth/login", json={"email": "user@example.com", "password": "secret123"})
|
login = await client.post("/auth/login", json={"email": "user@example.com", "password": "secret123"})
|
||||||
admin_token = login.json()["access_token"]
|
admin_token = login.json()["access_token"]
|
||||||
|
|
||||||
resp = await client.put(
|
resp = await client.put(
|
||||||
f"/users/{target_id}/role",
|
f"/users/{target_id}/role",
|
||||||
json={"role": "admin"},
|
json={"role": "admin"},
|
||||||
headers={"Authorization": f"Bearer {admin_token}"},
|
headers={"Authorization": f"Bearer {admin_token}"},
|
||||||
)
|
)
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
assert resp.json()["role"] == "admin"
|
assert resp.json()["role"] == "admin"
|
||||||
|
|
||||||
|
|
||||||
async def test_set_invalid_role(client):
|
async def test_set_invalid_role(client):
|
||||||
await client.post("/auth/register", json={"email": "target@example.com", "password": "secret123"})
|
await client.post("/auth/register", json={"email": "target@example.com", "password": "secret123"})
|
||||||
target_resp = await client.post("/auth/login", json={"email": "target@example.com", "password": "secret123"})
|
target_resp = await client.post("/auth/login", json={"email": "target@example.com", "password": "secret123"})
|
||||||
target_me = await client.get("/users/me", headers={"Authorization": f"Bearer {target_resp.json()['access_token']}"})
|
target_me = await client.get("/users/me", headers={"Authorization": f"Bearer {target_resp.json()['access_token']}"})
|
||||||
target_id = target_me.json()["id"]
|
target_id = target_me.json()["id"]
|
||||||
|
|
||||||
token = await _register_and_login(client)
|
token = await _register_and_login(client)
|
||||||
me = await client.get("/users/me", headers={"Authorization": f"Bearer {token}"})
|
me = await client.get("/users/me", headers={"Authorization": f"Bearer {token}"})
|
||||||
await _make_admin(me.json()["id"])
|
await _make_admin(me.json()["id"])
|
||||||
login = await client.post("/auth/login", json={"email": "user@example.com", "password": "secret123"})
|
login = await client.post("/auth/login", json={"email": "user@example.com", "password": "secret123"})
|
||||||
admin_token = login.json()["access_token"]
|
admin_token = login.json()["access_token"]
|
||||||
|
|
||||||
resp = await client.put(
|
resp = await client.put(
|
||||||
f"/users/{target_id}/role",
|
f"/users/{target_id}/role",
|
||||||
json={"role": "superuser"},
|
json={"role": "superuser"},
|
||||||
headers={"Authorization": f"Bearer {admin_token}"},
|
headers={"Authorization": f"Bearer {admin_token}"},
|
||||||
)
|
)
|
||||||
assert resp.status_code == 422
|
assert resp.status_code == 422
|
||||||
@@ -1,34 +1,34 @@
|
|||||||
# 1. Introduction & Goals
|
# 1. Introduction & Goals
|
||||||
|
|
||||||
Describes the relevant requirements and the driving forces that software architects and the development team must consider. These include underlying business goals, essential features, essential functional requirements, quality goals for the architecture, and relevant stakeholders and their expectations.
|
Describes the relevant requirements and the driving forces that software architects and the development team must consider. These include underlying business goals, essential features, essential functional requirements, quality goals for the architecture, and relevant stakeholders and their expectations.
|
||||||
|
|
||||||
## Requirements Overview
|
## Requirements Overview
|
||||||
|
|
||||||
**Project name**: All You Can GET AI
|
**Project name**: All You Can GET AI
|
||||||
**URL**: [https://ai.allucanget.biz](https://ai.allucanget.biz)
|
**URL**: [https://ai.allucanget.biz](https://ai.allucanget.biz)
|
||||||
**Purpose**: Provide AI‑powered text, image, and video generation services via a web application.
|
**Purpose**: Provide AI‑powered text, image, and video generation services via a web application.
|
||||||
|
|
||||||
Users can choose between different AI models for:
|
Users can choose between different AI models for:
|
||||||
|
|
||||||
- Text generation
|
- Text generation
|
||||||
- Text‑to‑image generation
|
- Text‑to‑image generation
|
||||||
- Text‑to‑video generation
|
- Text‑to‑video generation
|
||||||
- Image‑to‑video generation
|
- Image‑to‑video generation
|
||||||
|
|
||||||
Users can create accounts, log in, and view their generation history in a gallery. An admin dashboard allows managing users, models, and video generation jobs.
|
Users can create accounts, log in, and view their generation history in a gallery. An admin dashboard allows managing users, models, and video generation jobs.
|
||||||
|
|
||||||
## Quality Goals
|
## Quality Goals
|
||||||
|
|
||||||
| Priority | Quality Goal | Scenario |
|
| Priority | Quality Goal | Scenario |
|
||||||
| -------- | ----------------- | --------------------------------------------- |
|
| -------- | ----------------- | --------------------------------------------- |
|
||||||
| 1 | High availability | Service is accessible 99.9% of the time |
|
| 1 | High availability | Service is accessible 99.9% of the time |
|
||||||
| 2 | Low latency | Generation endpoints respond within 200 ms |
|
| 2 | Low latency | Generation endpoints respond within 200 ms |
|
||||||
| 3 | Data privacy | User data is encrypted at rest and in transit |
|
| 3 | Data privacy | User data is encrypted at rest and in transit |
|
||||||
|
|
||||||
## Stakeholders
|
## Stakeholders
|
||||||
|
|
||||||
| Role/Name | Contact | Expectations |
|
| Role/Name | Contact | Expectations |
|
||||||
| ------------- | ---------- | --------------------------------------------- |
|
| ------------- | ---------- | --------------------------------------------- |
|
||||||
| Developer | Team | Clean APIs, testable code, good documentation |
|
| Developer | Team | Clean APIs, testable code, good documentation |
|
||||||
| End User | Public | Fast, reliable AI generation features |
|
| End User | Public | Fast, reliable AI generation features |
|
||||||
| Product Owner | Management | Feature completeness, uptime, cost control |
|
| Product Owner | Management | Feature completeness, uptime, cost control |
|
||||||
+26
-26
@@ -1,26 +1,26 @@
|
|||||||
# 2. Architecture Constraints
|
# 2. Architecture Constraints
|
||||||
|
|
||||||
Any requirement that constrains software architects in their freedom of design and implementation decisions or decision about the development process. These constraints sometimes go beyond individual systems and are valid for whole organizations and companies.
|
Any requirement that constrains software architects in their freedom of design and implementation decisions or decision about the development process. These constraints sometimes go beyond individual systems and are valid for whole organizations and companies.
|
||||||
|
|
||||||
## Technical Constraints
|
## Technical Constraints
|
||||||
|
|
||||||
| Constraint | Background / Motivation |
|
| Constraint | Background / Motivation |
|
||||||
| ----------------------------- | ------------------------------------------- |
|
| ----------------------------- | ------------------------------------------- |
|
||||||
| Must run on Windows and Linux | Cross-platform developer environments |
|
| Must run on Windows and Linux | Cross-platform developer environments |
|
||||||
| FastAPI for backend | Async performance, OpenAPI support |
|
| FastAPI for backend | Async performance, OpenAPI support |
|
||||||
| Flask for frontend | Lightweight UI serving |
|
| Flask for frontend | Lightweight UI serving |
|
||||||
| DuckDB as embedded database | Lightweight, no separate DB server required |
|
| DuckDB as embedded database | Lightweight, no separate DB server required |
|
||||||
| External AI via openrouter.ai | Unified access to multiple AI models |
|
| External AI via openrouter.ai | Unified access to multiple AI models |
|
||||||
|
|
||||||
## Organizational Constraints
|
## Organizational Constraints
|
||||||
|
|
||||||
| Constraint | Background / Motivation |
|
| Constraint | Background / Motivation |
|
||||||
| ----------------- | --------------------------------- |
|
| ----------------- | --------------------------------- |
|
||||||
| Open source stack | Cost reduction, community support |
|
| Open source stack | Cost reduction, community support |
|
||||||
|
|
||||||
## Conventions
|
## Conventions
|
||||||
|
|
||||||
| Convention | Background / Motivation |
|
| Convention | Background / Motivation |
|
||||||
| -------------------- | --------------------------------------------------- |
|
| -------------------- | --------------------------------------------------- |
|
||||||
| Python 3.12+ | Modern language features, type hints |
|
| Python 3.12+ | Modern language features, type hints |
|
||||||
| pytest for all tests | Consistent test tooling across backend and frontend |
|
| pytest for all tests | Consistent test tooling across backend and frontend |
|
||||||
@@ -1,26 +1,26 @@
|
|||||||
# 3. Context and Scope
|
# 3. Context and Scope
|
||||||
|
|
||||||
Delimits your system (i.e. your scope) from all its communication partners (neighboring systems and users, i.e. the context of your system). It thereby specifies the external interfaces.
|
Delimits your system (i.e. your scope) from all its communication partners (neighboring systems and users, i.e. the context of your system). It thereby specifies the external interfaces.
|
||||||
|
|
||||||
## Business Context
|
## Business Context
|
||||||
|
|
||||||
Specification of **all** communication partners (users, IT-systems, …) with explanations of domain specific inputs and outputs or interfaces.
|
Specification of **all** communication partners (users, IT-systems, …) with explanations of domain specific inputs and outputs or interfaces.
|
||||||
|
|
||||||
The system exposes REST APIs for authentication, user management, and AI generation. The Flask frontend consumes these APIs and serves pages at `http://ai.allucanget.biz`.
|
The system exposes REST APIs for authentication, user management, and AI generation. The Flask frontend consumes these APIs and serves pages at `http://ai.allucanget.biz`.
|
||||||
|
|
||||||
| Communication Partner | Input | Output |
|
| Communication Partner | Input | Output |
|
||||||
| --------------------- | ------------------------------ | ------------------------------ |
|
| --------------------- | ------------------------------ | ------------------------------ |
|
||||||
| End User (browser) | Form submissions, API requests | Generated text, images, videos |
|
| End User (browser) | Form submissions, API requests | Generated text, images, videos |
|
||||||
| openrouter.ai API | Prompt + model selection | AI-generated content |
|
| openrouter.ai API | Prompt + model selection | AI-generated content |
|
||||||
| DuckDB | SQL queries | User data, session data |
|
| DuckDB | SQL queries | User data, session data |
|
||||||
|
|
||||||
## Technical Context
|
## Technical Context
|
||||||
|
|
||||||
| Channel | Protocol | Description |
|
| Channel | Protocol | Description |
|
||||||
| -------------------------------- | ---------- | ------------------ |
|
| -------------------------------- | ---------- | ------------------ |
|
||||||
| User ↔ Flask frontend | HTTPS | Browser-based UI |
|
| User ↔ Flask frontend | HTTPS | Browser-based UI |
|
||||||
| Flask frontend ↔ FastAPI backend | HTTP REST | Internal API calls |
|
| Flask frontend ↔ FastAPI backend | HTTP REST | Internal API calls |
|
||||||
| FastAPI backend ↔ openrouter.ai | HTTPS REST | AI model inference |
|
| FastAPI backend ↔ openrouter.ai | HTTPS REST | AI model inference |
|
||||||
| FastAPI backend ↔ DuckDB | In-process | Embedded DB access |
|
| FastAPI backend ↔ DuckDB | In-process | Embedded DB access |
|
||||||
|
|
||||||
**Mapping Input/Output to Channels:** All user-facing traffic flows through HTTPS. The backend communicates with openrouter.ai over HTTPS. DuckDB is accessed in-process with no network channel.
|
**Mapping Input/Output to Channels:** All user-facing traffic flows through HTTPS. The backend communicates with openrouter.ai over HTTPS. DuckDB is accessed in-process with no network channel.
|
||||||
@@ -1,23 +1,23 @@
|
|||||||
# 4. Solution Strategy
|
# 4. Solution Strategy
|
||||||
|
|
||||||
A short summary and explanation of the fundamental decisions and solution strategies that shape system architecture. It includes:
|
A short summary and explanation of the fundamental decisions and solution strategies that shape system architecture. It includes:
|
||||||
|
|
||||||
- Technology decisions
|
- Technology decisions
|
||||||
- Decisions about the top-level decomposition of the system, e.g. usage of an architectural pattern or design pattern
|
- Decisions about the top-level decomposition of the system, e.g. usage of an architectural pattern or design pattern
|
||||||
- Decisions on how to achieve key quality goals
|
- Decisions on how to achieve key quality goals
|
||||||
- Relevant organizational decisions, e.g. selecting a development process or delegating certain tasks to third parties
|
- Relevant organizational decisions, e.g. selecting a development process or delegating certain tasks to third parties
|
||||||
|
|
||||||
Micro‑service style: a single FastAPI process handles all API endpoints; a separate Flask process serves the UI.
|
Micro‑service style: a single FastAPI process handles all API endpoints; a separate Flask process serves the UI.
|
||||||
|
|
||||||
| Quality Goal | Scenario | Solution Approach |
|
| Quality Goal | Scenario | Solution Approach |
|
||||||
| ----------------- | ------------------------ | --------------------------------------------------------- |
|
| ----------------- | ------------------------ | --------------------------------------------------------- |
|
||||||
| High availability | Service must be up 99.9% | Docker containers with health checks and restart policies |
|
| High availability | Service must be up 99.9% | Docker containers with health checks and restart policies |
|
||||||
| Low latency | Responses < 200 ms | FastAPI async endpoints; DuckDB in-process |
|
| Low latency | Responses < 200 ms | FastAPI async endpoints; DuckDB in-process |
|
||||||
| Data privacy | Encrypted data | JWT auth, HTTPS everywhere, DuckDB file encryption |
|
| Data privacy | Encrypted data | JWT auth, HTTPS everywhere, DuckDB file encryption |
|
||||||
|
|
||||||
**Key technology decisions:**
|
**Key technology decisions:**
|
||||||
|
|
||||||
- **FastAPI** for async performance and automatic OpenAPI docs
|
- **FastAPI** for async performance and automatic OpenAPI docs
|
||||||
- **DuckDB** for a lightweight embedded DB with no separate server
|
- **DuckDB** for a lightweight embedded DB with no separate server
|
||||||
- **Flask** for a thin frontend layer with Jinja2 templates
|
- **Flask** for a thin frontend layer with Jinja2 templates
|
||||||
- **openrouter.ai** for unified access to multiple AI model providers
|
- **openrouter.ai** for unified access to multiple AI model providers
|
||||||
+110
-110
@@ -1,110 +1,110 @@
|
|||||||
# 5. Building Block View
|
# 5. Building Block View
|
||||||
|
|
||||||
Static decomposition of the system into building blocks (modules, components, subsystems, classes, interfaces, packages, libraries, frameworks, layers, partitions, tiers, functions, macros, operations, data structures, …) as well as their dependencies (relationships, associations, …).
|
Static decomposition of the system into building blocks (modules, components, subsystems, classes, interfaces, packages, libraries, frameworks, layers, partitions, tiers, functions, macros, operations, data structures, …) as well as their dependencies (relationships, associations, …).
|
||||||
|
|
||||||
## Level 1 – Whitebox Overall System
|
## Level 1 – Whitebox Overall System
|
||||||
|
|
||||||
```text
|
```text
|
||||||
┌────────────────────────┐
|
┌────────────────────────┐
|
||||||
│ Frontend (Flask) │
|
│ Frontend (Flask) │
|
||||||
└───────┬────────────────┘
|
└───────┬────────────────┘
|
||||||
│ REST API calls
|
│ REST API calls
|
||||||
┌───────▼────────────────┐
|
┌───────▼────────────────┐
|
||||||
│ FastAPI Backend │
|
│ FastAPI Backend │
|
||||||
│ ├─ Auth Service │
|
│ ├─ Auth Service │
|
||||||
│ ├─ User Service │
|
│ ├─ User Service │
|
||||||
│ ├─ AI Service │
|
│ ├─ AI Service │
|
||||||
│ └─ DB Service (DuckDB)│
|
│ └─ DB Service (DuckDB)│
|
||||||
└───────┬────────────────┘
|
└───────┬────────────────┘
|
||||||
│ DB access
|
│ DB access
|
||||||
┌───────▼────────────────┐
|
┌───────▼────────────────┐
|
||||||
│ DuckDB Database │
|
│ DuckDB Database │
|
||||||
└────────────────────────┘
|
└────────────────────────┘
|
||||||
```
|
```
|
||||||
|
|
||||||
**Motivation:** Separating the UI (Flask) from the API (FastAPI) allows independent scaling and testing of each layer.
|
**Motivation:** Separating the UI (Flask) from the API (FastAPI) allows independent scaling and testing of each layer.
|
||||||
|
|
||||||
**Contained Building Blocks:**
|
**Contained Building Blocks:**
|
||||||
|
|
||||||
| Name | Responsibility |
|
| Name | Responsibility |
|
||||||
| ---------------- | -------------------------------------------------- |
|
| ---------------- | -------------------------------------------------- |
|
||||||
| Frontend (Flask) | Serves HTML/CSS/JS UI, proxies requests to FastAPI |
|
| Frontend (Flask) | Serves HTML/CSS/JS UI, proxies requests to FastAPI |
|
||||||
| FastAPI Backend | Handles auth, user management, AI generation API |
|
| FastAPI Backend | Handles auth, user management, AI generation API |
|
||||||
| Auth Service | JWT issuance and validation |
|
| Auth Service | JWT issuance and validation |
|
||||||
| User Service | CRUD operations for user accounts |
|
| User Service | CRUD operations for user accounts |
|
||||||
| AI Service | Sends prompts to openrouter.ai, returns results |
|
| AI Service | Sends prompts to openrouter.ai, returns results |
|
||||||
| DB Service | Reads/writes to DuckDB |
|
| DB Service | Reads/writes to DuckDB |
|
||||||
| DuckDB Database | Persistent storage for users and session data |
|
| DuckDB Database | Persistent storage for users and session data |
|
||||||
|
|
||||||
## Level 2 – FastAPI Backend internals
|
## Level 2 – FastAPI Backend internals
|
||||||
|
|
||||||
### White Box Auth Service (`/auth`)
|
### White Box Auth Service (`/auth`)
|
||||||
|
|
||||||
Handles JWT issuance, validation, and refresh token lifecycle.
|
Handles JWT issuance, validation, and refresh token lifecycle.
|
||||||
|
|
||||||
| Method | Path | Auth required | Description |
|
| Method | Path | Auth required | Description |
|
||||||
| ------ | ---------------- | :-----------: | ------------------------------------------------ |
|
| ------ | ---------------- | :-----------: | ------------------------------------------------ |
|
||||||
| POST | `/auth/register` | — | Create a new user account |
|
| POST | `/auth/register` | — | Create a new user account |
|
||||||
| POST | `/auth/login` | — | Authenticate and receive access + refresh tokens |
|
| POST | `/auth/login` | — | Authenticate and receive access + refresh tokens |
|
||||||
| POST | `/auth/refresh` | — | Rotate refresh token, get a new access token |
|
| POST | `/auth/refresh` | — | Rotate refresh token, get a new access token |
|
||||||
| POST | `/auth/logout` | — | Revoke refresh token |
|
| POST | `/auth/logout` | — | Revoke refresh token |
|
||||||
|
|
||||||
### White Box User Service (`/users`)
|
### White Box User Service (`/users`)
|
||||||
|
|
||||||
Self-service profile management and admin user CRUD.
|
Self-service profile management and admin user CRUD.
|
||||||
|
|
||||||
| Method | Path | Auth required | Admin only | Description |
|
| Method | Path | Auth required | Admin only | Description |
|
||||||
| ------ | ------------------ | ------------- | ---------- | ---------------------------------------- |
|
| ------ | ------------------ | ------------- | ---------- | ---------------------------------------- |
|
||||||
| GET | `/users/me` | ✓ | — | Get current user profile |
|
| GET | `/users/me` | ✓ | — | Get current user profile |
|
||||||
| PUT | `/users/me` | ✓ | — | Update own email or password |
|
| PUT | `/users/me` | ✓ | — | Update own email or password |
|
||||||
| GET | `/users` | ✓ | ✓ | List all users |
|
| GET | `/users` | ✓ | ✓ | List all users |
|
||||||
| DELETE | `/users/{id}` | ✓ | ✓ | Delete a user (cannot self-delete) |
|
| DELETE | `/users/{id}` | ✓ | ✓ | Delete a user (cannot self-delete) |
|
||||||
| PUT | `/users/{id}/role` | ✓ | ✓ | Change a user's role (`user` \| `admin`) |
|
| PUT | `/users/{id}/role` | ✓ | ✓ | Change a user's role (`user` \| `admin`) |
|
||||||
|
|
||||||
### White Box Admin Service (`/admin`)
|
### White Box Admin Service (`/admin`)
|
||||||
|
|
||||||
Operational endpoints for application management.
|
Operational endpoints for application management.
|
||||||
|
|
||||||
| Method | Path | Auth required | Admin only | Description |
|
| Method | Path | Auth required | Admin only | Description |
|
||||||
| ------ | --------------------------- | ------------- | ---------- | ------------------------------------------ |
|
| ------ | --------------------------- | ------------- | ---------- | ------------------------------------------ |
|
||||||
| GET | `/admin/stats` | ✓ | ✓ | User counts by role, token activity |
|
| GET | `/admin/stats` | ✓ | ✓ | User counts by role, token activity |
|
||||||
| GET | `/admin/health/db` | ✓ | ✓ | DuckDB connectivity check |
|
| GET | `/admin/health/db` | ✓ | ✓ | DuckDB connectivity check |
|
||||||
| POST | `/admin/tokens/purge` | ✓ | ✓ | Remove expired/revoked refresh tokens |
|
| POST | `/admin/tokens/purge` | ✓ | ✓ | Remove expired/revoked refresh tokens |
|
||||||
| GET | `/admin/videos` | ✓ | ✓ | List all video jobs with user emails |
|
| GET | `/admin/videos` | ✓ | ✓ | List all video jobs with user emails |
|
||||||
| POST | `/admin/videos/{id}/cancel` | ✓ | ✓ | Cancel a queued/processing video job |
|
| POST | `/admin/videos/{id}/cancel` | ✓ | ✓ | Cancel a queued/processing video job |
|
||||||
| POST | `/admin/videos/{id}/retry` | ✓ | ✓ | Retry a failed/cancelled video job |
|
| POST | `/admin/videos/{id}/retry` | ✓ | ✓ | Retry a failed/cancelled video job |
|
||||||
| DELETE | `/admin/videos/{id}` | ✓ | ✓ | Permanently delete a video job |
|
| DELETE | `/admin/videos/{id}` | ✓ | ✓ | Permanently delete a video job |
|
||||||
| POST | `/admin/videos/purge` | ✓ | ✓ | Delete old completed/failed/cancelled jobs |
|
| POST | `/admin/videos/purge` | ✓ | ✓ | Delete old completed/failed/cancelled jobs |
|
||||||
| POST | `/admin/videos/timed-out` | ✓ | ✓ | Mark stale processing jobs as failed |
|
| POST | `/admin/videos/timed-out` | ✓ | ✓ | Mark stale processing jobs as failed |
|
||||||
| GET | `/admin/models` | ✓ | ✓ | List cached OpenRouter models |
|
| GET | `/admin/models` | ✓ | ✓ | List cached OpenRouter models |
|
||||||
| POST | `/admin/models/refresh` | ✓ | ✓ | Refresh model cache from OpenRouter |
|
| POST | `/admin/models/refresh` | ✓ | ✓ | Refresh model cache from OpenRouter |
|
||||||
|
|
||||||
### White Box AI Service (`/ai`, `/generate`)
|
### White Box AI Service (`/ai`, `/generate`)
|
||||||
|
|
||||||
Model listing and multi-modal generation via openrouter.ai.
|
Model listing and multi-modal generation via openrouter.ai.
|
||||||
|
|
||||||
| Method | Path | Auth required | Description |
|
| Method | Path | Auth required | Description |
|
||||||
| ------ | ------------------------------ | ------------- | ------------------------------------------------------------------------------------------------------------------- |
|
| ------ | ------------------------------ | ------------- | ------------------------------------------------------------------------------------------------------------------- |
|
||||||
| GET | `/ai/models` | ✓ | List available OpenRouter models |
|
| GET | `/ai/models` | ✓ | List available OpenRouter models |
|
||||||
| POST | `/ai/chat` | ✓ | Multi-turn chat completion |
|
| POST | `/ai/chat` | ✓ | Multi-turn chat completion |
|
||||||
| POST | `/generate/text` | ✓ | Single-prompt text generation (optional system prompt) |
|
| POST | `/generate/text` | ✓ | Single-prompt text generation (optional system prompt) |
|
||||||
| POST | `/generate/image` | ✓ | Text-to-image (DALL-E via `/images/generations` or FLUX/GPT-5 Image Mini via `/chat/completions` with `modalities`) |
|
| POST | `/generate/image` | ✓ | Text-to-image (DALL-E via `/images/generations` or FLUX/GPT-5 Image Mini via `/chat/completions` with `modalities`) |
|
||||||
| POST | `/generate/video` | ✓ | Text-to-video (Sora 2 Pro, Veo 3.1 Fast) — returns `polling_url` |
|
| POST | `/generate/video` | ✓ | Text-to-video (Sora 2 Pro, Veo 3.1 Fast) — returns `polling_url` |
|
||||||
| POST | `/generate/video/from-image` | ✓ | Image-to-video — returns `polling_url` |
|
| POST | `/generate/video/from-image` | ✓ | Image-to-video — returns `polling_url` |
|
||||||
| GET | `/generate/video/status` | ✓ | Poll video generation status via `polling_url` |
|
| GET | `/generate/video/status` | ✓ | Poll video generation status via `polling_url` |
|
||||||
| GET | `/generate/images` | ✓ | List current user's generated images |
|
| GET | `/generate/images` | ✓ | List current user's generated images |
|
||||||
| GET | `/generate/images/{id}` | ✓ | Get a single generated image |
|
| GET | `/generate/images/{id}` | ✓ | Get a single generated image |
|
||||||
| GET | `/generate/videos` | ✓ | List current user's video jobs |
|
| GET | `/generate/videos` | ✓ | List current user's video jobs |
|
||||||
| GET | `/generate/videos/{id}` | ✓ | Get a single video job |
|
| GET | `/generate/videos/{id}` | ✓ | Get a single video job |
|
||||||
| POST | `/generate/videos/{id}/cancel` | ✓ | Cancel a queued/processing video job |
|
| POST | `/generate/videos/{id}/cancel` | ✓ | Cancel a queued/processing video job |
|
||||||
|
|
||||||
**Video generation flow:** The `/generate/video` and `/generate/video/from-image` endpoints queue a job in the local database and return immediately with `status: "queued"`. A background worker (`video_worker.py`) submits the job to OpenRouter's `/api/v1/videos` endpoint, receives a `polling_url`, and polls it periodically until the job reaches `"completed"` or `"failed"`. The frontend polls `GET /generate/video/{id}/status` every 5 seconds to show live status updates.
|
**Video generation flow:** The `/generate/video` and `/generate/video/from-image` endpoints queue a job in the local database and return immediately with `status: "queued"`. A background worker (`video_worker.py`) submits the job to OpenRouter's `/api/v1/videos` endpoint, receives a `polling_url`, and polls it periodically until the job reaches `"completed"` or `"failed"`. The frontend polls `GET /generate/video/{id}/status` every 5 seconds to show live status updates.
|
||||||
|
|
||||||
**Image generation routing:** The router auto-detects the model type — models containing `"flux"` or `"gpt-5-image-mini"` are routed to `/chat/completions` with `modalities: ["image"]` (or `["image", "text"]` depending on cached output modalities), while others (e.g. DALL-E 3) use the legacy `/images/generations` endpoint.
|
**Image generation routing:** The router auto-detects the model type — models containing `"flux"` or `"gpt-5-image-mini"` are routed to `/chat/completions` with `modalities: ["image"]` (or `["image", "text"]` depending on cached output modalities), while others (e.g. DALL-E 3) use the legacy `/images/generations` endpoint.
|
||||||
|
|
||||||
### White Box DB Service (`db.py`)
|
### White Box DB Service (`db.py`)
|
||||||
|
|
||||||
- Singleton DuckDB connection, opened on FastAPI startup via lifespan hook.
|
- Singleton DuckDB connection, opened on FastAPI startup via lifespan hook.
|
||||||
- `asyncio.Lock` serialises all write operations within the single process.
|
- `asyncio.Lock` serialises all write operations within the single process.
|
||||||
- Schema migration runs automatically on first startup.
|
- Schema migration runs automatically on first startup.
|
||||||
- Tables: `users`, `refresh_tokens` (see Section 8 for schema details).
|
- Tables: `users`, `refresh_tokens` (see Section 8 for schema details).
|
||||||
+98
-98
@@ -1,98 +1,98 @@
|
|||||||
# 6. Runtime View
|
# 6. Runtime View
|
||||||
|
|
||||||
Describes concrete behavior and interactions of the system's building blocks in form of scenarios from the following areas:
|
Describes concrete behavior and interactions of the system's building blocks in form of scenarios from the following areas:
|
||||||
|
|
||||||
- Important use cases or features: how do building blocks execute them?
|
- Important use cases or features: how do building blocks execute them?
|
||||||
- Interactions at critical external interfaces: how do building blocks cooperate with users and neighboring systems?
|
- Interactions at critical external interfaces: how do building blocks cooperate with users and neighboring systems?
|
||||||
- Operation and administration: launch, start-up, stop
|
- Operation and administration: launch, start-up, stop
|
||||||
- Error and exception scenarios
|
- Error and exception scenarios
|
||||||
|
|
||||||
## Scenario 1: User Authentication
|
## Scenario 1: User Authentication
|
||||||
|
|
||||||
1. User submits login form in Flask frontend
|
1. User submits login form in Flask frontend
|
||||||
2. Flask POSTs credentials to `POST /auth/login`
|
2. Flask POSTs credentials to `POST /auth/login`
|
||||||
3. Auth Service validates credentials against DuckDB
|
3. Auth Service validates credentials against DuckDB
|
||||||
4. Auth Service returns JWT token
|
4. Auth Service returns JWT token
|
||||||
5. Flask stores token in session cookie
|
5. Flask stores token in session cookie
|
||||||
6. User is redirected to dashboard
|
6. User is redirected to dashboard
|
||||||
|
|
||||||
## Scenario 2: AI Text Generation
|
## Scenario 2: AI Text Generation
|
||||||
|
|
||||||
1. User fills in text generation form in Flask frontend
|
1. User fills in text generation form in Flask frontend
|
||||||
2. Flask POSTs prompt + model to `POST /generate/text` with JWT header
|
2. Flask POSTs prompt + model to `POST /generate/text` with JWT header
|
||||||
3. Auth Service validates JWT
|
3. Auth Service validates JWT
|
||||||
4. AI Service sends prompt to openrouter.ai
|
4. AI Service sends prompt to openrouter.ai
|
||||||
5. openrouter.ai returns generated text
|
5. openrouter.ai returns generated text
|
||||||
6. FastAPI returns result to Flask
|
6. FastAPI returns result to Flask
|
||||||
7. Flask renders result page for user
|
7. Flask renders result page for user
|
||||||
|
|
||||||
## Scenario 3: Image Generation
|
## Scenario 3: Image Generation
|
||||||
|
|
||||||
1. User submits image generation form with prompt, model, size, aspect ratio, and resolution
|
1. User submits image generation form with prompt, model, size, aspect ratio, and resolution
|
||||||
2. Flask POSTs to `POST /generate/image` with JWT header
|
2. Flask POSTs to `POST /generate/image` with JWT header
|
||||||
3. Router auto-detects model type:
|
3. Router auto-detects model type:
|
||||||
- **FLUX / GPT-5 Image Mini**: calls `/chat/completions` with `modalities: ["image"]` and `image_config`
|
- **FLUX / GPT-5 Image Mini**: calls `/chat/completions` with `modalities: ["image"]` and `image_config`
|
||||||
- **DALL-E 3**: calls `/images/generations` with `size` and `n`
|
- **DALL-E 3**: calls `/images/generations` with `size` and `n`
|
||||||
4. Image URL (base64 data URL or hosted URL) returned to Flask
|
4. Image URL (base64 data URL or hosted URL) returned to Flask
|
||||||
5. Flask renders page with generated image(s)
|
5. Flask renders page with generated image(s)
|
||||||
|
|
||||||
## Scenario 3a: Image Generation with Aspect Ratio & Resolution
|
## Scenario 3a: Image Generation with Aspect Ratio & Resolution
|
||||||
|
|
||||||
1. User selects aspect ratio (e.g. `16:9`) and resolution (`2K`) on the image generation form
|
1. User selects aspect ratio (e.g. `16:9`) and resolution (`2K`) on the image generation form
|
||||||
2. Flask POSTs `aspect_ratio` and `image_size` to `POST /generate/image`
|
2. Flask POSTs `aspect_ratio` and `image_size` to `POST /generate/image`
|
||||||
3. Backend passes these as `image_config` to the chat completions endpoint (for FLUX/GPT-5 Image Mini)
|
3. Backend passes these as `image_config` to the chat completions endpoint (for FLUX/GPT-5 Image Mini)
|
||||||
4. Generated image respects the requested aspect ratio and resolution
|
4. Generated image respects the requested aspect ratio and resolution
|
||||||
|
|
||||||
## Scenario 4: Video Generation (Text-to-Video)
|
## Scenario 4: Video Generation (Text-to-Video)
|
||||||
|
|
||||||
1. User submits video generation form with prompt, model, aspect ratio, resolution, and duration
|
1. User submits video generation form with prompt, model, aspect ratio, resolution, and duration
|
||||||
2. Flask POSTs to `POST /generate/video` with JWT header
|
2. Flask POSTs to `POST /generate/video` with JWT header
|
||||||
3. Auth Service validates JWT
|
3. Auth Service validates JWT
|
||||||
4. Backend inserts a row into `generated_videos` with `status: "queued"` and returns the DB job ID
|
4. Backend inserts a row into `generated_videos` with `status: "queued"` and returns the DB job ID
|
||||||
5. Flask renders result page with polling UI
|
5. Flask renders result page with polling UI
|
||||||
6. Background worker (`video_worker.py`) picks up queued jobs every 15 seconds:
|
6. Background worker (`video_worker.py`) picks up queued jobs every 15 seconds:
|
||||||
- Calls OpenRouter `POST /api/v1/videos` with model, prompt, and parameters
|
- Calls OpenRouter `POST /api/v1/videos` with model, prompt, and parameters
|
||||||
- Receives `{"id": "...", "polling_url": "..."}` and updates the DB row to `status: "processing"`
|
- Receives `{"id": "...", "polling_url": "..."}` and updates the DB row to `status: "processing"`
|
||||||
- Polls the `polling_url` every 15 seconds until `status` is `"completed"` or `"failed"`
|
- Polls the `polling_url` every 15 seconds until `status` is `"completed"` or `"failed"`
|
||||||
- Updates the DB row with the final status and video URL
|
- Updates the DB row with the final status and video URL
|
||||||
7. Frontend JavaScript polls `GET /generate/video/{db_id}/status` every 5 seconds
|
7. Frontend JavaScript polls `GET /generate/video/{db_id}/status` every 5 seconds
|
||||||
8. When `status` becomes `"completed"`, the response includes `video_url` — the video is displayed in a `<video>` element
|
8. When `status` becomes `"completed"`, the response includes `video_url` — the video is displayed in a `<video>` element
|
||||||
9. If `status` becomes `"failed"`, an error message is shown
|
9. If `status` becomes `"failed"`, an error message is shown
|
||||||
10. User can click "Cancel Job" to mark the job as `"cancelled"` (stops local polling, does not stop the provider job)
|
10. User can click "Cancel Job" to mark the job as `"cancelled"` (stops local polling, does not stop the provider job)
|
||||||
|
|
||||||
## Scenario 4a: Video Generation (Image-to-Video)
|
## Scenario 4a: Video Generation (Image-to-Video)
|
||||||
|
|
||||||
1. User provides an image URL, motion prompt, model, aspect ratio, resolution, and duration
|
1. User provides an image URL, motion prompt, model, aspect ratio, resolution, and duration
|
||||||
2. Flask POSTs to `POST /generate/video/from-image` with JWT header
|
2. Flask POSTs to `POST /generate/video/from-image` with JWT header
|
||||||
3. Same background worker flow as Scenario 4, with `generation_type: "image_to_video"`
|
3. Same background worker flow as Scenario 4, with `generation_type: "image_to_video"`
|
||||||
|
|
||||||
## Scenario 4b: Video Job Cancellation
|
## Scenario 4b: Video Job Cancellation
|
||||||
|
|
||||||
1. User clicks "Cancel Job" on the video detail page or gallery pending card
|
1. User clicks "Cancel Job" on the video detail page or gallery pending card
|
||||||
2. Frontend POSTs to `/generate/video/{id}/cancel`
|
2. Frontend POSTs to `/generate/video/{id}/cancel`
|
||||||
3. Backend verifies the job belongs to the user and is not in a terminal state
|
3. Backend verifies the job belongs to the user and is not in a terminal state
|
||||||
4. Backend updates the DB row `status` to `"cancelled"`
|
4. Backend updates the DB row `status` to `"cancelled"`
|
||||||
5. Frontend stops polling and updates the UI to show "Job cancelled"
|
5. Frontend stops polling and updates the UI to show "Job cancelled"
|
||||||
|
|
||||||
## Scenario 5: Token Refresh
|
## Scenario 5: Token Refresh
|
||||||
|
|
||||||
1. Access token expires (TTL 15 min)
|
1. Access token expires (TTL 15 min)
|
||||||
2. Client POSTs current refresh token to `POST /auth/refresh`
|
2. Client POSTs current refresh token to `POST /auth/refresh`
|
||||||
3. Auth Service validates JTI against `refresh_tokens` table (not revoked, not expired)
|
3. Auth Service validates JTI against `refresh_tokens` table (not revoked, not expired)
|
||||||
4. Old JTI is revoked; new JTI inserted into `refresh_tokens`
|
4. Old JTI is revoked; new JTI inserted into `refresh_tokens`
|
||||||
5. New access token + new refresh token returned to client
|
5. New access token + new refresh token returned to client
|
||||||
|
|
||||||
## Scenario 6: Admin User Management
|
## Scenario 6: Admin User Management
|
||||||
|
|
||||||
1. Admin logs in and receives access token with `role: admin`
|
1. Admin logs in and receives access token with `role: admin`
|
||||||
2. Admin GETs `/admin/stats` to view user and token counts
|
2. Admin GETs `/admin/stats` to view user and token counts
|
||||||
3. Admin DELETEs `/users/{id}` to remove a user — refresh tokens for that user are cascade-deleted
|
3. Admin DELETEs `/users/{id}` to remove a user — refresh tokens for that user are cascade-deleted
|
||||||
4. Admin PUTs `/users/{id}/role` to promote a user to admin or demote to user
|
4. Admin PUTs `/users/{id}/role` to promote a user to admin or demote to user
|
||||||
|
|
||||||
## Scenario 7: User Profile Update
|
## Scenario 7: User Profile Update
|
||||||
|
|
||||||
1. Authenticated user navigates to `/users/profile`
|
1. Authenticated user navigates to `/users/profile`
|
||||||
2. User submits updated email and/or new password
|
2. User submits updated email and/or new password
|
||||||
3. Flask POSTs to `PUT /users/me` with JWT header
|
3. Flask POSTs to `PUT /users/me` with JWT header
|
||||||
4. Auth Service validates credentials and updates user record in DuckDB
|
4. Auth Service validates credentials and updates user record in DuckDB
|
||||||
5. Session `user_email` is updated; user sees success message
|
5. Session `user_email` is updated; user sees success message
|
||||||
+83
-83
@@ -1,83 +1,83 @@
|
|||||||
# 7. Deployment View
|
# 7. Deployment View
|
||||||
|
|
||||||
Describes:
|
Describes:
|
||||||
|
|
||||||
1. Technical infrastructure used to execute your system, with infrastructure elements like geographical locations, environments, computers, processors, channels and net topologies.
|
1. Technical infrastructure used to execute your system, with infrastructure elements like geographical locations, environments, computers, processors, channels and net topologies.
|
||||||
2. Mapping of (software) building blocks to that infrastructure elements.
|
2. Mapping of (software) building blocks to that infrastructure elements.
|
||||||
|
|
||||||
**See**: [Coolify Deployment Guide](./deployment/coolify.md) for detailed instructions.
|
**See**: [Coolify Deployment Guide](./deployment/coolify.md) for detailed instructions.
|
||||||
|
|
||||||
## Infrastructure Level 1
|
## Infrastructure Level 1
|
||||||
|
|
||||||
Hosted on a single VM running docker containers, deployed via Coolify with Nixpacks to 192.168.88.18 for production.
|
Hosted on a single VM running docker containers, deployed via Coolify with Nixpacks to 192.168.88.18 for production.
|
||||||
|
|
||||||
Containers run behind nginx at 192.168.88.11 which handles TLS termination and reverse proxying to the frontend on port 12016 and backend on port 12015. The database is a file on the host filesystem at `data/app.db` accessed by the backend service.
|
Containers run behind nginx at 192.168.88.11 which handles TLS termination and reverse proxying to the frontend on port 12016 and backend on port 12015. The database is a file on the host filesystem at `data/app.db` accessed by the backend service.
|
||||||
|
|
||||||
```mermaid
|
```mermaid
|
||||||
graph TD
|
graph TD
|
||||||
Users[Users / Internet]
|
Users[Users / Internet]
|
||||||
Nginx[nginx reverse proxy\nTLS termination]
|
Nginx[nginx reverse proxy\nTLS termination]
|
||||||
Users -->|HTTPS| Nginx
|
Users -->|HTTPS| Nginx
|
||||||
|
|
||||||
subgraph Coolify Server
|
subgraph Coolify Server
|
||||||
direction TB
|
direction TB
|
||||||
subgraph AI Frontend
|
subgraph AI Frontend
|
||||||
AI_Frontend[AI Frontend\nFlask\nServes HTML/CSS/JS UI]
|
AI_Frontend[AI Frontend\nFlask\nServes HTML/CSS/JS UI]
|
||||||
end
|
end
|
||||||
subgraph AI Backend
|
subgraph AI Backend
|
||||||
AI_Backend[AI Backend\nFastAPI\nCommunicates with openrouter.ai API]
|
AI_Backend[AI Backend\nFastAPI\nCommunicates with openrouter.ai API]
|
||||||
db[(DuckDB Database\nFile: data/app.db)]
|
db[(DuckDB Database\nFile: data/app.db)]
|
||||||
AI_Backend --> db
|
AI_Backend --> db
|
||||||
end
|
end
|
||||||
AI_Frontend -->|BACKEND_URL:12015| AI_Backend
|
AI_Frontend -->|BACKEND_URL:12015| AI_Backend
|
||||||
end
|
end
|
||||||
Nginx -->|12016| AI_Frontend
|
Nginx -->|12016| AI_Frontend
|
||||||
```
|
```
|
||||||
|
|
||||||
**Motivation:** All three components run as Docker containers for simplicity and low operational overhead.
|
**Motivation:** All three components run as Docker containers for simplicity and low operational overhead.
|
||||||
|
|
||||||
**Quality and/or Performance Features:** The frontend and backend are stateless; DuckDB persists data on the host filesystem.
|
**Quality and/or Performance Features:** The frontend and backend are stateless; DuckDB persists data on the host filesystem.
|
||||||
|
|
||||||
**Mapping of Building Blocks to Infrastructure:**
|
**Mapping of Building Blocks to Infrastructure:**
|
||||||
|
|
||||||
| Building Block | Container / Process | Port |
|
| Building Block | Container / Process | Port |
|
||||||
| --------------- | ---------------------------- | --------------- |
|
| --------------- | ---------------------------- | --------------- |
|
||||||
| Nginx | `nginx` | 80/443 (public) |
|
| Nginx | `nginx` | 80/443 (public) |
|
||||||
| Coolify Server | `coolify` | — |
|
| Coolify Server | `coolify` | — |
|
||||||
| Flask frontend | `frontend` | 12016 |
|
| Flask frontend | `frontend` | 12016 |
|
||||||
| FastAPI backend | `backend` | 12015 |
|
| FastAPI backend | `backend` | 12015 |
|
||||||
| DuckDB | File on host (`data/app.db`) | — |
|
| DuckDB | File on host (`data/app.db`) | — |
|
||||||
|
|
||||||
## Infrastructure Level 2
|
## Infrastructure Level 2
|
||||||
|
|
||||||
### Coolify with Nixpacks (Production)
|
### Coolify with Nixpacks (Production)
|
||||||
|
|
||||||
Both services are deployed as separate Nixpacks resources in Coolify, which results in two separate containers running on the same host. The database is a file on the host filesystem, mounted as a volume in the backend container.
|
Both services are deployed as separate Nixpacks resources in Coolify, which results in two separate containers running on the same host. The database is a file on the host filesystem, mounted as a volume in the backend container.
|
||||||
|
|
||||||
#### Frontend
|
#### Frontend
|
||||||
|
|
||||||
```mermaid
|
```mermaid
|
||||||
graph TD
|
graph TD
|
||||||
subgraph Coolify Server
|
subgraph Coolify Server
|
||||||
direction TB
|
direction TB
|
||||||
subgraph AI Frontend
|
subgraph AI Frontend
|
||||||
AI_Frontend[AI Frontend\nNixpacks\nBase Dir: /frontend]
|
AI_Frontend[AI Frontend\nNixpacks\nBase Dir: /frontend]
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
Users[Users / Internet] -->|HTTPS| AI_Frontend
|
Users[Users / Internet] -->|HTTPS| AI_Frontend
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Backend
|
#### Backend
|
||||||
|
|
||||||
```mermaid
|
```mermaid
|
||||||
graph TD
|
graph TD
|
||||||
subgraph Coolify Server
|
subgraph Coolify Server
|
||||||
direction TB
|
direction TB
|
||||||
subgraph AI Backend
|
subgraph AI Backend
|
||||||
AI_Backend[AI Backend\nNixpacks\nBase Dir: /backend]
|
AI_Backend[AI Backend\nNixpacks\nBase Dir: /backend]
|
||||||
db[(DuckDB Database\nVolume: /app/data)]
|
db[(DuckDB Database\nVolume: /app/data)]
|
||||||
AI_Backend --> db
|
AI_Backend --> db
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
Frontend[Frontend Container] -->|BACKEND_URL:12015| AI_Backend
|
Frontend[Frontend Container] -->|BACKEND_URL:12015| AI_Backend
|
||||||
```
|
```
|
||||||
@@ -1,35 +1,35 @@
|
|||||||
# 8. Cross-cutting Concepts
|
# 8. Cross-cutting Concepts
|
||||||
|
|
||||||
Describes crosscutting concepts (practices, patterns, regulations or solution ideas). Such concepts are often related to multiple building blocks. They may include many different topics such as domain models, architecture patterns, rules for using specific technology, security, logging, and error handling.
|
Describes crosscutting concepts (practices, patterns, regulations or solution ideas). Such concepts are often related to multiple building blocks. They may include many different topics such as domain models, architecture patterns, rules for using specific technology, security, logging, and error handling.
|
||||||
|
|
||||||
> Pick **only** the most-needed topics for your system.
|
> Pick **only** the most-needed topics for your system.
|
||||||
|
|
||||||
## OpenRouter API Integration
|
## OpenRouter API Integration
|
||||||
|
|
||||||
see [docs/8.1-openrouter.md](./8.1-openrouter.md) for details on how the backend integrates with OpenRouter for multi-modal AI generation, including image and video generation flows.
|
see [docs/8.1-openrouter.md](./8.1-openrouter.md) for details on how the backend integrates with OpenRouter for multi-modal AI generation, including image and video generation flows.
|
||||||
|
|
||||||
## DuckDB Concurrency and Storage
|
## DuckDB Concurrency and Storage
|
||||||
|
|
||||||
See [docs/8.2-duckdb.md](./8.2-duckdb.md) for details on how the backend handles concurrent access to DuckDB and manages the database file on the host filesystem.
|
See [docs/8.2-duckdb.md](./8.2-duckdb.md) for details on how the backend handles concurrent access to DuckDB and manages the database file on the host filesystem.
|
||||||
|
|
||||||
## Security
|
## Security
|
||||||
|
|
||||||
- All API endpoints (except `/auth/login`) require a valid JWT in the `Authorization: Bearer` header.
|
- All API endpoints (except `/auth/login`) require a valid JWT in the `Authorization: Bearer` header.
|
||||||
- HTTPS enforced in production via reverse proxy (nginx or Caddy).
|
- HTTPS enforced in production via reverse proxy (nginx or Caddy).
|
||||||
- Passwords stored as bcrypt hashes.
|
- Passwords stored as bcrypt hashes.
|
||||||
|
|
||||||
## Logging
|
## Logging
|
||||||
|
|
||||||
- Structured JSON logs from FastAPI via Python `logging` + `structlog`.
|
- Structured JSON logs from FastAPI via Python `logging` + `structlog`.
|
||||||
- OpenTelemetry traces exported for observability.
|
- OpenTelemetry traces exported for observability.
|
||||||
- Log level configurable via environment variable `LOG_LEVEL`.
|
- Log level configurable via environment variable `LOG_LEVEL`.
|
||||||
|
|
||||||
## Error Handling
|
## Error Handling
|
||||||
|
|
||||||
- All API errors return a unified JSON shape: `{ "error": "<code>", "message": "<description>" }`.
|
- All API errors return a unified JSON shape: `{ "error": "<code>", "message": "<description>" }`.
|
||||||
- HTTP status codes follow REST conventions (400, 401, 403, 404, 422, 500).
|
- HTTP status codes follow REST conventions (400, 401, 403, 404, 422, 500).
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
- All secrets (API keys, DB path, JWT secret) loaded from environment variables or `.env` file.
|
- All secrets (API keys, DB path, JWT secret) loaded from environment variables or `.env` file.
|
||||||
- No secrets committed to source control.
|
- No secrets committed to source control.
|
||||||
+31
-31
@@ -1,31 +1,31 @@
|
|||||||
# OpenRouter API Integration
|
# OpenRouter API Integration
|
||||||
|
|
||||||
## Text Generation
|
## Text Generation
|
||||||
|
|
||||||
> [!warning]
|
> [!warning]
|
||||||
> TODO: Add more details on how the backend integrates with OpenRouter for text generation, including chat completions and single-prompt generation flows.
|
> TODO: Add more details on how the backend integrates with OpenRouter for text generation, including chat completions and single-prompt generation flows.
|
||||||
|
|
||||||
## Image Generation
|
## Image Generation
|
||||||
|
|
||||||
Image generation uses two different OpenRouter endpoints depending on the model:
|
Image generation uses two different OpenRouter endpoints depending on the model:
|
||||||
|
|
||||||
- **Legacy endpoint** (`/images/generations`): Used by DALL-E 3 and similar models. Returns `data[].url` and `data[].b64_json`.
|
- **Legacy endpoint** (`/images/generations`): Used by DALL-E 3 and similar models. Returns `data[].url` and `data[].b64_json`.
|
||||||
- **Chat completions** (`/chat/completions` with `modalities: ["image"]`): Used by FLUX.2 Klein 4B and GPT-5 Image Mini. Returns `choices[0].message.images[].image_url.url` as base64 data URLs.
|
- **Chat completions** (`/chat/completions` with `modalities: ["image"]`): Used by FLUX.2 Klein 4B and GPT-5 Image Mini. Returns `choices[0].message.images[].image_url.url` as base64 data URLs.
|
||||||
|
|
||||||
The router auto-detects the model type and routes accordingly. Image configuration (`aspect_ratio`, `image_size`) is passed via `image_config` for chat-based models.
|
The router auto-detects the model type and routes accordingly. Image configuration (`aspect_ratio`, `image_size`) is passed via `image_config` for chat-based models.
|
||||||
|
|
||||||
## Video Generation
|
## Video Generation
|
||||||
|
|
||||||
Video generation uses OpenRouter's `/api/v1/videos` endpoint with a **submit-and-poll** pattern orchestrated by a background worker:
|
Video generation uses OpenRouter's `/api/v1/videos` endpoint with a **submit-and-poll** pattern orchestrated by a background worker:
|
||||||
|
|
||||||
1. User submits a video request via `POST /generate/video` (or `/generate/video/from-image`)
|
1. User submits a video request via `POST /generate/video` (or `/generate/video/from-image`)
|
||||||
2. Backend inserts a row into `generated_videos` with `status: "queued"` and returns immediately
|
2. Backend inserts a row into `generated_videos` with `status: "queued"` and returns immediately
|
||||||
3. Background worker (`video_worker.py`) picks up queued jobs every 15 seconds:
|
3. Background worker (`video_worker.py`) picks up queued jobs every 15 seconds:
|
||||||
- Calls `POST /api/v1/videos` with `model`, `prompt`, `aspect_ratio`, `resolution`, `duration`
|
- Calls `POST /api/v1/videos` with `model`, `prompt`, `aspect_ratio`, `resolution`, `duration`
|
||||||
- Receives `{"id": "job_id", "polling_url": "https://..."}` and updates DB to `status: "processing"`
|
- Receives `{"id": "job_id", "polling_url": "https://..."}` and updates DB to `status: "processing"`
|
||||||
- Polls `GET polling_url` every 15 seconds until `status` is `"completed"` or `"failed"`
|
- Polls `GET polling_url` every 15 seconds until `status` is `"completed"` or `"failed"`
|
||||||
- Updates DB with final status, `video_url`, and any `error` message
|
- Updates DB with final status, `video_url`, and any `error` message
|
||||||
4. Frontend polls `GET /generate/video/{db_id}/status` every 5 seconds to show live updates
|
4. Frontend polls `GET /generate/video/{db_id}/status` every 5 seconds to show live updates
|
||||||
5. Completed response includes `video_url` — the video is displayed in a `<video>` element
|
5. Completed response includes `video_url` — the video is displayed in a `<video>` element
|
||||||
|
|
||||||
Supported models: `openai/sora-2-pro`, `google/veo-3.1-fast`. Both text-to-video and image-to-video use the same `/api/v1/videos` endpoint (image-to-video includes `frame_images` with `first_frame` in the request body).
|
Supported models: `openai/sora-2-pro`, `google/veo-3.1-fast`. Both text-to-video and image-to-video use the same `/api/v1/videos` endpoint (image-to-video includes `frame_images` with `first_frame` in the request body).
|
||||||
+46
-46
@@ -1,46 +1,46 @@
|
|||||||
# DuckDB Concurrency and Storage
|
# DuckDB Concurrency and Storage
|
||||||
|
|
||||||
## Single Writer Per Process
|
## Single Writer Per Process
|
||||||
|
|
||||||
DuckDB allows only one process to open the database file in read-write mode at a time. The FastAPI backend must be run with a single worker (`uvicorn --workers 1`). Running multiple workers against the same DuckDB file will cause startup errors.
|
DuckDB allows only one process to open the database file in read-write mode at a time. The FastAPI backend must be run with a single worker (`uvicorn --workers 1`). Running multiple workers against the same DuckDB file will cause startup errors.
|
||||||
|
|
||||||
## asyncio.Lock for Writes
|
## asyncio.Lock for Writes
|
||||||
|
|
||||||
All database write operations (`INSERT`, `UPDATE`, `DELETE`) in the FastAPI async context are wrapped in a single `asyncio.Lock` (`get_write_lock()` from `backend/app/db.py`). This prevents concurrent coroutines from issuing overlapping writes within the single process, which would otherwise raise DuckDB optimistic concurrency errors.
|
All database write operations (`INSERT`, `UPDATE`, `DELETE`) in the FastAPI async context are wrapped in a single `asyncio.Lock` (`get_write_lock()` from `backend/app/db.py`). This prevents concurrent coroutines from issuing overlapping writes within the single process, which would otherwise raise DuckDB optimistic concurrency errors.
|
||||||
|
|
||||||
Read operations (`SELECT`) do not require the lock — DuckDB's MVCC provides consistent read snapshots.
|
Read operations (`SELECT`) do not require the lock — DuckDB's MVCC provides consistent read snapshots.
|
||||||
|
|
||||||
## Schema
|
## Schema
|
||||||
|
|
||||||
```sql
|
```sql
|
||||||
CREATE TABLE users (
|
CREATE TABLE users (
|
||||||
id UUID DEFAULT uuid() PRIMARY KEY,
|
id UUID DEFAULT uuid() PRIMARY KEY,
|
||||||
email VARCHAR NOT NULL UNIQUE,
|
email VARCHAR NOT NULL UNIQUE,
|
||||||
password_hash VARCHAR NOT NULL,
|
password_hash VARCHAR NOT NULL,
|
||||||
role VARCHAR DEFAULT 'user',
|
role VARCHAR DEFAULT 'user',
|
||||||
created_at TIMESTAMP DEFAULT now(),
|
created_at TIMESTAMP DEFAULT now(),
|
||||||
updated_at TIMESTAMP DEFAULT now()
|
updated_at TIMESTAMP DEFAULT now()
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE refresh_tokens (
|
CREATE TABLE refresh_tokens (
|
||||||
jti UUID DEFAULT uuid() PRIMARY KEY,
|
jti UUID DEFAULT uuid() PRIMARY KEY,
|
||||||
user_id UUID NOT NULL, -- soft FK to users.id
|
user_id UUID NOT NULL, -- soft FK to users.id
|
||||||
issued_at TIMESTAMP DEFAULT now(),
|
issued_at TIMESTAMP DEFAULT now(),
|
||||||
expires_at TIMESTAMP NOT NULL,
|
expires_at TIMESTAMP NOT NULL,
|
||||||
revoked BOOLEAN DEFAULT false
|
revoked BOOLEAN DEFAULT false
|
||||||
);
|
);
|
||||||
```
|
```
|
||||||
|
|
||||||
> The `REFERENCES users(id)` foreign key is intentionally omitted from `refresh_tokens`. DuckDB fires FK checks on `UPDATE` of the parent table (including email changes), causing false constraint violations. Referential integrity is enforced manually: deleting a user also deletes their refresh tokens in the same write transaction.
|
> The `REFERENCES users(id)` foreign key is intentionally omitted from `refresh_tokens`. DuckDB fires FK checks on `UPDATE` of the parent table (including email changes), causing false constraint violations. Referential integrity is enforced manually: deleting a user also deletes their refresh tokens in the same write transaction.
|
||||||
|
|
||||||
## Access Tokens
|
## Access Tokens
|
||||||
|
|
||||||
Access tokens are **stateless** JWTs — not stored in the database. They are validated by signature and expiry claim only. The short TTL (15 minutes) limits the blast radius if a token is leaked.
|
Access tokens are **stateless** JWTs — not stored in the database. They are validated by signature and expiry claim only. The short TTL (15 minutes) limits the blast radius if a token is leaked.
|
||||||
|
|
||||||
## Refresh Tokens
|
## Refresh Tokens
|
||||||
|
|
||||||
Refresh tokens store a JTI (JWT ID) UUID in the `refresh_tokens` table. On each use the old JTI is revoked and a new one issued (rotation). On logout the JTI is immediately revoked. Expired and revoked tokens can be purged via `POST /admin/tokens/purge`.
|
Refresh tokens store a JTI (JWT ID) UUID in the `refresh_tokens` table. On each use the old JTI is revoked and a new one issued (rotation). On logout the JTI is immediately revoked. Expired and revoked tokens can be purged via `POST /admin/tokens/purge`.
|
||||||
|
|
||||||
## Future: AI Generation History
|
## Future: AI Generation History
|
||||||
|
|
||||||
AI generation metadata (model, prompt, cost, result URLs) can be stored as JSON columns in a future `generation_history` table in DuckDB, enabling per-user analytics and usage dashboards at zero extra infrastructure cost.
|
AI generation metadata (model, prompt, cost, result URLs) can be stored as JSON columns in a future `generation_history` table in DuckDB, enabling per-user analytics and usage dashboards at zero extra infrastructure cost.
|
||||||
+113
-113
@@ -1,113 +1,113 @@
|
|||||||
# 9. Architecture Decisions
|
# 9. Architecture Decisions
|
||||||
|
|
||||||
Important, expensive, large scale or risky architecture decisions including rationales. With "decisions" we mean selecting one alternative based on given criteria.
|
Important, expensive, large scale or risky architecture decisions including rationales. With "decisions" we mean selecting one alternative based on given criteria.
|
||||||
|
|
||||||
Refer to section 4 (Solution Strategy) where the most important decisions are already captured. Avoid redundancy.
|
Refer to section 4 (Solution Strategy) where the most important decisions are already captured. Avoid redundancy.
|
||||||
|
|
||||||
> Consider using [ADRs (Architecture Decision Records)](https://cognitect.com/blog/2011/11/15/documenting-architecture-decisions) for every important decision.
|
> Consider using [ADRs (Architecture Decision Records)](https://cognitect.com/blog/2011/11/15/documenting-architecture-decisions) for every important decision.
|
||||||
|
|
||||||
## ADR-001: Use DuckDB as the embedded database
|
## ADR-001: Use DuckDB as the embedded database
|
||||||
|
|
||||||
**Status:** accepted
|
**Status:** accepted
|
||||||
|
|
||||||
**Context:** The application needs persistent storage for user data. A full RDBMS (PostgreSQL, MySQL) would require a separate server process and adds operational complexity.
|
**Context:** The application needs persistent storage for user data. A full RDBMS (PostgreSQL, MySQL) would require a separate server process and adds operational complexity.
|
||||||
|
|
||||||
**Decision:** Use DuckDB as an embedded, file-based database accessed in-process by the FastAPI backend.
|
**Decision:** Use DuckDB as an embedded, file-based database accessed in-process by the FastAPI backend.
|
||||||
|
|
||||||
**Consequences:** No separate DB server needed. Limited to single-writer access patterns. Suitable for the expected load.
|
**Consequences:** No separate DB server needed. Limited to single-writer access patterns. Suitable for the expected load.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## ADR-002: Use FastAPI for the backend
|
## ADR-002: Use FastAPI for the backend
|
||||||
|
|
||||||
**Status:** accepted
|
**Status:** accepted
|
||||||
|
|
||||||
**Context:** The backend needs async performance for concurrent AI generation requests and automatic OpenAPI documentation.
|
**Context:** The backend needs async performance for concurrent AI generation requests and automatic OpenAPI documentation.
|
||||||
|
|
||||||
**Decision:** Use FastAPI with uvicorn as the ASGI server.
|
**Decision:** Use FastAPI with uvicorn as the ASGI server.
|
||||||
|
|
||||||
**Consequences:** Async endpoints enable high concurrency. Auto-generated OpenAPI docs simplify frontend integration and testing.
|
**Consequences:** Async endpoints enable high concurrency. Auto-generated OpenAPI docs simplify frontend integration and testing.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## ADR-003: Use Flask for the frontend
|
## ADR-003: Use Flask for the frontend
|
||||||
|
|
||||||
**Status:** accepted
|
**Status:** accepted
|
||||||
|
|
||||||
**Context:** A lightweight server-side rendering layer is needed for the UI with minimal frontend complexity.
|
**Context:** A lightweight server-side rendering layer is needed for the UI with minimal frontend complexity.
|
||||||
|
|
||||||
**Decision:** Use Flask with Jinja2 templates to serve HTML pages.
|
**Decision:** Use Flask with Jinja2 templates to serve HTML pages.
|
||||||
|
|
||||||
**Consequences:** Simple, familiar framework. No JavaScript build toolchain required. Frontend calls FastAPI over HTTP.
|
**Consequences:** Simple, familiar framework. No JavaScript build toolchain required. Frontend calls FastAPI over HTTP.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## ADR-004: Serialize DuckDB writes with asyncio.Lock
|
## ADR-004: Serialize DuckDB writes with asyncio.Lock
|
||||||
|
|
||||||
**Status:** accepted
|
**Status:** accepted
|
||||||
|
|
||||||
**Context:** FastAPI runs async coroutines concurrently in a single process. DuckDB's optimistic concurrency model raises errors when multiple coroutines issue writes simultaneously to the same connection.
|
**Context:** FastAPI runs async coroutines concurrently in a single process. DuckDB's optimistic concurrency model raises errors when multiple coroutines issue writes simultaneously to the same connection.
|
||||||
|
|
||||||
**Decision:** All write operations (`INSERT`, `UPDATE`, `DELETE`) acquire a single process-wide `asyncio.Lock` before executing. The lock is released immediately after the statement completes.
|
**Decision:** All write operations (`INSERT`, `UPDATE`, `DELETE`) acquire a single process-wide `asyncio.Lock` before executing. The lock is released immediately after the statement completes.
|
||||||
|
|
||||||
**Consequences:** Writes are serialised within the process, eliminating concurrency errors. Read performance is unaffected. Throughput is acceptable for the expected user load. If write throughput becomes a bottleneck in future, migrating to PostgreSQL is the preferred path.
|
**Consequences:** Writes are serialised within the process, eliminating concurrency errors. Read performance is unaffected. Throughput is acceptable for the expected user load. If write throughput becomes a bottleneck in future, migrating to PostgreSQL is the preferred path.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## ADR-005: Use OpenRouter as the unified AI provider gateway
|
## ADR-005: Use OpenRouter as the unified AI provider gateway
|
||||||
|
|
||||||
**Status:** accepted
|
**Status:** accepted
|
||||||
|
|
||||||
**Context:** The application needs access to multiple AI model providers (OpenAI, Anthropic, Stability AI, Runway, etc.) for text, image, and video generation.
|
**Context:** The application needs access to multiple AI model providers (OpenAI, Anthropic, Stability AI, Runway, etc.) for text, image, and video generation.
|
||||||
|
|
||||||
**Decision:** Route all AI generation requests through the [OpenRouter](https://openrouter.ai) API, which exposes an OpenAI-compatible REST interface for hundreds of models.
|
**Decision:** Route all AI generation requests through the [OpenRouter](https://openrouter.ai) API, which exposes an OpenAI-compatible REST interface for hundreds of models.
|
||||||
|
|
||||||
**Consequences:** Single API key and base URL for all model providers. Model switching requires only a change to the `model` field in the request payload. If OpenRouter is unavailable, all generation endpoints return `502 Bad Gateway`. Pricing and rate limits are governed by OpenRouter's policies per model.
|
**Consequences:** Single API key and base URL for all model providers. Model switching requires only a change to the `model` field in the request payload. If OpenRouter is unavailable, all generation endpoints return `502 Bad Gateway`. Pricing and rate limits are governed by OpenRouter's policies per model.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## ADR-006: Use submit-and-poll pattern for video generation
|
## ADR-006: Use submit-and-poll pattern for video generation
|
||||||
|
|
||||||
**Status:** accepted
|
**Status:** accepted
|
||||||
|
|
||||||
**Context:** OpenRouter's video generation models (Sora 2 Pro, Veo 3.1 Fast) do not return video URLs immediately. Video generation is a long-running operation (typically 30-120 seconds) that requires polling.
|
**Context:** OpenRouter's video generation models (Sora 2 Pro, Veo 3.1 Fast) do not return video URLs immediately. Video generation is a long-running operation (typically 30-120 seconds) that requires polling.
|
||||||
|
|
||||||
**Decision:** Use the `/api/v1/videos` endpoint with a two-step pattern: (1) `POST` to submit the job and receive a `polling_url`, (2) `GET` the `polling_url` every 5 seconds until `status` is `"completed"` or `"failed"`. The Flask frontend proxies polling requests via `GET /generate/video/status?polling_url=...` and the frontend JavaScript polls this endpoint automatically.
|
**Decision:** Use the `/api/v1/videos` endpoint with a two-step pattern: (1) `POST` to submit the job and receive a `polling_url`, (2) `GET` the `polling_url` every 5 seconds until `status` is `"completed"` or `"failed"`. The Flask frontend proxies polling requests via `GET /generate/video/status?polling_url=...` and the frontend JavaScript polls this endpoint automatically.
|
||||||
|
|
||||||
**Consequences:** The video generation endpoint returns immediately with `status: "queued"` and a `polling_url`. The frontend displays a "Processing..." message and polls for updates. When complete, the video is displayed in a `<video>` element. This adds complexity to the frontend but is necessary for long-running operations. If OpenRouter's polling endpoint is unavailable, the frontend shows an error after a timeout.
|
**Consequences:** The video generation endpoint returns immediately with `status: "queued"` and a `polling_url`. The frontend displays a "Processing..." message and polls for updates. When complete, the video is displayed in a `<video>` element. This adds complexity to the frontend but is necessary for long-running operations. If OpenRouter's polling endpoint is unavailable, the frontend shows an error after a timeout.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## ADR-007: Auto-detect image generation model type
|
## ADR-007: Auto-detect image generation model type
|
||||||
|
|
||||||
**Status:** accepted
|
**Status:** accepted
|
||||||
|
|
||||||
**Context:** OpenRouter supports image generation through two different endpoints: the legacy `/images/generations` endpoint (DALL-E 3) and the chat completions endpoint with `modalities: ["image"]` (FLUX.2 Klein 4B, GPT-5 Image Mini). These endpoints have different request/response formats.
|
**Context:** OpenRouter supports image generation through two different endpoints: the legacy `/images/generations` endpoint (DALL-E 3) and the chat completions endpoint with `modalities: ["image"]` (FLUX.2 Klein 4B, GPT-5 Image Mini). These endpoints have different request/response formats.
|
||||||
|
|
||||||
**Decision:** The `/generate/image` router auto-detects the model type by checking if the model slug contains `"flux"` or `"gpt-5-image-mini"`. If so, it routes to `/chat/completions` with `modalities: ["image"]` and `image_config` (aspect_ratio, image_size). Otherwise, it uses `/images/generations` with `size` and `n`.
|
**Decision:** The `/generate/image` router auto-detects the model type by checking if the model slug contains `"flux"` or `"gpt-5-image-mini"`. If so, it routes to `/chat/completions` with `modalities: ["image"]` and `image_config` (aspect_ratio, image_size). Otherwise, it uses `/images/generations` with `size` and `n`.
|
||||||
|
|
||||||
**Consequences:** Users can specify any image generation model in the form without needing to know which endpoint it uses. The router handles the routing transparently. Adding new image models requires only updating the detection logic if they use a different endpoint.
|
**Consequences:** Users can specify any image generation model in the form without needing to know which endpoint it uses. The router handles the routing transparently. Adding new image models requires only updating the detection logic if they use a different endpoint.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## ADR-008: Flask session-based auth with role caching
|
## ADR-008: Flask session-based auth with role caching
|
||||||
|
|
||||||
**Status:** accepted
|
**Status:** accepted
|
||||||
|
|
||||||
**Context:** The Flask frontend needs to know the user's authentication state and role for route protection (`@login_required`, `@admin_required`) without making an extra API call on every request.
|
**Context:** The Flask frontend needs to know the user's authentication state and role for route protection (`@login_required`, `@admin_required`) without making an extra API call on every request.
|
||||||
|
|
||||||
**Decision:** Store the JWT access token, refresh token, user email, and user role in the Flask server-side session cookie after login. The `@login_required` decorator checks for `access_token` in the session. The `@admin_required` decorator checks `session["user_role"] == "admin"`. This avoids an extra API call to `/users/me` on every request.
|
**Decision:** Store the JWT access token, refresh token, user email, and user role in the Flask server-side session cookie after login. The `@login_required` decorator checks for `access_token` in the session. The `@admin_required` decorator checks `session["user_role"] == "admin"`. This avoids an extra API call to `/users/me` on every request.
|
||||||
|
|
||||||
**Consequences:** The user role is cached in the session and may become stale if an admin changes a user's role while the user is logged in. The user must log out and log back in to see the updated role. This is acceptable for the expected usage pattern. The session cookie is signed (Flask's default) to prevent tampering.
|
**Consequences:** The user role is cached in the session and may become stale if an admin changes a user's role while the user is logged in. The user must log out and log back in to see the updated role. This is acceptable for the expected usage pattern. The session cookie is signed (Flask's default) to prevent tampering.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## ADR-009: Separate generation pages in frontend
|
## ADR-009: Separate generation pages in frontend
|
||||||
|
|
||||||
**Status:** accepted
|
**Status:** accepted
|
||||||
|
|
||||||
**Context:** The original `/generate` page handled text, image, and video generation in a single form, which became unwieldy as more generation types were added.
|
**Context:** The original `/generate` page handled text, image, and video generation in a single form, which became unwieldy as more generation types were added.
|
||||||
|
|
||||||
**Decision:** Create separate Flask routes and Jinja2 templates for each generation type: `/generate/text`, `/generate/image`, `/generate/video`. The `/generate` route redirects to `/generate/text`. The navigation bar includes a "Generate" dropdown with links to each sub-page. The video page uses tabs for text-to-video and image-to-video.
|
**Decision:** Create separate Flask routes and Jinja2 templates for each generation type: `/generate/text`, `/generate/image`, `/generate/video`. The `/generate` route redirects to `/generate/text`. The navigation bar includes a "Generate" dropdown with links to each sub-page. The video page uses tabs for text-to-video and image-to-video.
|
||||||
|
|
||||||
**Consequences:** Each generation type has its own URL, making it bookmarkable and shareable. The navigation is clearer with a dropdown menu. Adding new generation types (e.g., audio) follows the same pattern. The `/generate` redirect provides a sensible default entry point.
|
**Consequences:** Each generation type has its own URL, making it bookmarkable and shareable. The navigation is clearer with a dropdown menu. Adding new generation types (e.g., audio) follows the same pattern. The `/generate` redirect provides a sensible default entry point.
|
||||||
@@ -1,21 +1,21 @@
|
|||||||
# 10. Quality Requirements
|
# 10. Quality Requirements
|
||||||
|
|
||||||
This section contains all relevant quality requirements. The most important ones are already described in section 1.2 (Quality Goals), and should only be referenced here.
|
This section contains all relevant quality requirements. The most important ones are already described in section 1.2 (Quality Goals), and should only be referenced here.
|
||||||
|
|
||||||
## Quality Requirements Overview
|
## Quality Requirements Overview
|
||||||
|
|
||||||
| Category | Quality Requirement |
|
| Category | Quality Requirement |
|
||||||
| --------------- | ----------------------------------------------------------------------- |
|
| --------------- | ----------------------------------------------------------------------- |
|
||||||
| Availability | Service available ≥ 99.9% of the time |
|
| Availability | Service available ≥ 99.9% of the time |
|
||||||
| Performance | Generation endpoints respond within 200 ms (excluding AI model latency) |
|
| Performance | Generation endpoints respond within 200 ms (excluding AI model latency) |
|
||||||
| Security | All user data encrypted at rest and in transit; JWT-protected endpoints |
|
| Security | All user data encrypted at rest and in transit; JWT-protected endpoints |
|
||||||
| Maintainability | Test coverage > 80% on `app/`; CI pipeline enforced |
|
| Maintainability | Test coverage > 80% on `app/`; CI pipeline enforced |
|
||||||
| Portability | Runs on Windows and Linux without modification |
|
| Portability | Runs on Windows and Linux without modification |
|
||||||
|
|
||||||
## Quality Scenarios
|
## Quality Scenarios
|
||||||
|
|
||||||
| Scenario ID | Source | Stimulus | Environment | Artifact | Response | Response Measure |
|
| Scenario ID | Source | Stimulus | Environment | Artifact | Response | Response Measure |
|
||||||
| ----------- | --------- | ------------------------------- | ----------- | ------------ | ------------------------------- | ----------------------------------------- |
|
| ----------- | --------- | ------------------------------- | ----------- | ------------ | ------------------------------- | ----------------------------------------- |
|
||||||
| QS-01 | End user | Submits text generation request | Production | AI Service | Returns generated text | < 200 ms (excluding model latency) |
|
| QS-01 | End user | Submits text generation request | Production | AI Service | Returns generated text | < 200 ms (excluding model latency) |
|
||||||
| QS-02 | Attacker | Sends request without valid JWT | Production | Auth Service | Returns 401 Unauthorized | 100% of unauthenticated requests rejected |
|
| QS-02 | Attacker | Sends request without valid JWT | Production | Auth Service | Returns 401 Unauthorized | 100% of unauthenticated requests rejected |
|
||||||
| QS-03 | Developer | Adds new API endpoint | Development | FastAPI app | Tests pass, coverage maintained | CI pipeline green |
|
| QS-03 | Developer | Adds new API endpoint | Development | FastAPI app | Tests pass, coverage maintained | CI pipeline green |
|
||||||
@@ -1,12 +1,12 @@
|
|||||||
# 11. Risks and Technical Debt
|
# 11. Risks and Technical Debt
|
||||||
|
|
||||||
A list of identified technical risks or technical debts, ordered by priority.
|
A list of identified technical risks or technical debts, ordered by priority.
|
||||||
|
|
||||||
> "Risk management is project management for grown-ups." — Tim Lister, Atlantic Systems Guild.
|
> "Risk management is project management for grown-ups." — Tim Lister, Atlantic Systems Guild.
|
||||||
|
|
||||||
| Priority | Risk / Technical Debt | Probability | Impact | Suggested Measures |
|
| Priority | Risk / Technical Debt | Probability | Impact | Suggested Measures |
|
||||||
| -------- | ---------------------------------------------- | ----------- | ------ | ------------------------------------------------------------------------ |
|
| -------- | ---------------------------------------------- | ----------- | ------ | ------------------------------------------------------------------------ |
|
||||||
| 1 | Dependency on openrouter.ai availability | medium | high | Add fallback models; implement retry logic with exponential backoff |
|
| 1 | Dependency on openrouter.ai availability | medium | high | Add fallback models; implement retry logic with exponential backoff |
|
||||||
| 2 | DuckDB schema changes break existing data | low | high | Version migrations; backup strategy before upgrades |
|
| 2 | DuckDB schema changes break existing data | low | high | Version migrations; backup strategy before upgrades |
|
||||||
| 3 | Single-process DuckDB limits write concurrency | low | medium | Monitor load; consider migration to PostgreSQL if needed |
|
| 3 | Single-process DuckDB limits write concurrency | low | medium | Monitor load; consider migration to PostgreSQL if needed |
|
||||||
| 4 | JWT secret leak | low | high | Rotate secrets via environment variables; never commit to source control |
|
| 4 | JWT secret leak | low | high | Rotate secrets via environment variables; never commit to source control |
|
||||||
+15
-15
@@ -1,15 +1,15 @@
|
|||||||
# 12. Glossary
|
# 12. Glossary
|
||||||
|
|
||||||
The most important domain and technical terms that stakeholders use when discussing the system.
|
The most important domain and technical terms that stakeholders use when discussing the system.
|
||||||
|
|
||||||
| Term | Definition |
|
| Term | Definition |
|
||||||
| ------------- | --------------------------------------------------------------------------------------- |
|
| ------------- | --------------------------------------------------------------------------------------- |
|
||||||
| AI | Artificial Intelligence — machine learning models that generate text, images, or video |
|
| AI | Artificial Intelligence — machine learning models that generate text, images, or video |
|
||||||
| API | Application Programming Interface — HTTP endpoints exposed by the FastAPI backend |
|
| API | Application Programming Interface — HTTP endpoints exposed by the FastAPI backend |
|
||||||
| JWT | JSON Web Token — signed token used for stateless authentication |
|
| JWT | JSON Web Token — signed token used for stateless authentication |
|
||||||
| DuckDB | Embedded analytical database used for persistent storage |
|
| DuckDB | Embedded analytical database used for persistent storage |
|
||||||
| FastAPI | Python async web framework used for the backend |
|
| FastAPI | Python async web framework used for the backend |
|
||||||
| Flask | Python web framework used for the frontend |
|
| Flask | Python web framework used for the frontend |
|
||||||
| openrouter.ai | Third-party API aggregator providing access to multiple AI model providers |
|
| openrouter.ai | Third-party API aggregator providing access to multiple AI model providers |
|
||||||
| ADR | Architecture Decision Record — a document capturing an important architectural decision |
|
| ADR | Architecture Decision Record — a document capturing an important architectural decision |
|
||||||
| HTTPS | Hypertext Transfer Protocol Secure — encrypted HTTP communication |
|
| HTTPS | Hypertext Transfer Protocol Secure — encrypted HTTP communication |
|
||||||
+19
-19
@@ -1,19 +1,19 @@
|
|||||||
# Testing Strategy
|
# Testing Strategy
|
||||||
|
|
||||||
The FastAPI backend will be tested using a combination of unit and integration tests.
|
The FastAPI backend will be tested using a combination of unit and integration tests.
|
||||||
|
|
||||||
## Unit Tests
|
## Unit Tests
|
||||||
|
|
||||||
- Use `pytest` with `pytest-asyncio` for async endpoint handlers.
|
- Use `pytest` with `pytest-asyncio` for async endpoint handlers.
|
||||||
- Mock external dependencies such as the DuckDB connection and the openrouter.ai API.
|
- Mock external dependencies such as the DuckDB connection and the openrouter.ai API.
|
||||||
|
|
||||||
## Integration Tests
|
## Integration Tests
|
||||||
|
|
||||||
- Use `pytest-flask` to spin up the FastAPI app in a test client.
|
- Use `pytest-flask` to spin up the FastAPI app in a test client.
|
||||||
- Test full request/response cycles for authentication, user CRUD, and AI generation endpoints.
|
- Test full request/response cycles for authentication, user CRUD, and AI generation endpoints.
|
||||||
- Use an in‑memory DuckDB instance for database tests.
|
- Use an in‑memory DuckDB instance for database tests.
|
||||||
|
|
||||||
## Test Coverage
|
## Test Coverage
|
||||||
|
|
||||||
- Aim for >80 % coverage on the `app/` package.
|
- Aim for >80 % coverage on the `app/` package.
|
||||||
- Generate coverage reports with `pytest-cov`.
|
- Generate coverage reports with `pytest-cov`.
|
||||||
+175
-175
@@ -1,175 +1,175 @@
|
|||||||
# Coolify Deployment Guide
|
# Coolify Deployment Guide
|
||||||
|
|
||||||
This guide covers deploying `ai.allucanget.biz` using [Coolify](https://coolify.io) with Nixpacks from the repository `https://git.allucanget.biz/allucanget/ai.allucanget.biz.git`.
|
This guide covers deploying `ai.allucanget.biz` using [Coolify](https://coolify.io) with Nixpacks from the repository `https://git.allucanget.biz/allucanget/ai.allucanget.biz.git`.
|
||||||
|
|
||||||
## Architecture Overview
|
## Architecture Overview
|
||||||
|
|
||||||
The application consists of two Python services:
|
The application consists of two Python services:
|
||||||
|
|
||||||
| Service | Framework | Port | Description |
|
| Service | Framework | Port | Description |
|
||||||
| -------- | ----------------- | ----- | ------------------------------------------ |
|
| -------- | ----------------- | ----- | ------------------------------------------ |
|
||||||
| Backend | FastAPI + uvicorn | 12015 | REST API, auth, AI generation, DuckDB |
|
| Backend | FastAPI + uvicorn | 12015 | REST API, auth, AI generation, DuckDB |
|
||||||
| Frontend | Flask + gunicorn | 12016 | SSR web UI, session auth, proxy to backend |
|
| Frontend | Flask + gunicorn | 12016 | SSR web UI, session auth, proxy to backend |
|
||||||
|
|
||||||
Coolify's built-in reverse proxy routes traffic:
|
Coolify's built-in reverse proxy routes traffic:
|
||||||
|
|
||||||
- `/api/*` → Backend (port 12015)
|
- `/api/*` → Backend (port 12015)
|
||||||
- `/` → Frontend (port 12016)
|
- `/` → Frontend (port 12016)
|
||||||
|
|
||||||
## Prerequisites
|
## Prerequisites
|
||||||
|
|
||||||
- A Coolify instance (self-hosted or Cloud)
|
- A Coolify instance (self-hosted or Cloud)
|
||||||
- Git repository pushed to `https://git.allucanget.biz/allucanget/ai.allucanget.biz.git`
|
- Git repository pushed to `https://git.allucanget.biz/allucanget/ai.allucanget.biz.git`
|
||||||
- Domain configured to point to your Coolify server
|
- Domain configured to point to your Coolify server
|
||||||
|
|
||||||
## Step 1: Create Backend Service
|
## Step 1: Create Backend Service
|
||||||
|
|
||||||
1. In Coolify, click **Add Resource** → **Deploy a new resource** → **Git**
|
1. In Coolify, click **Add Resource** → **Deploy a new resource** → **Git**
|
||||||
2. Connect your Git repository (`git.allucanget.biz`)
|
2. Connect your Git repository (`git.allucanget.biz`)
|
||||||
3. Select the `ai.allucanget.biz` repository
|
3. Select the `ai.allucanget.biz` repository
|
||||||
4. Choose the `main` branch
|
4. Choose the `main` branch
|
||||||
5. Set **Build Pack** to `nixpacks`
|
5. Set **Build Pack** to `nixpacks`
|
||||||
6. Set **Base Directory** to `/backend` - this tells Nixpacks to look in the `backend/` subdirectory for `requirements.txt` and the Python application
|
6. Set **Base Directory** to `/backend` - this tells Nixpacks to look in the `backend/` subdirectory for `requirements.txt` and the Python application
|
||||||
7. Set **Ports Exposed** to `12015`
|
7. Set **Ports Exposed** to `12015`
|
||||||
8. Set **Start Command** to:
|
8. Set **Start Command** to:
|
||||||
|
|
||||||
```txt
|
```txt
|
||||||
uvicorn app.main:app --host 0.0.0.0 --port 12015
|
uvicorn app.main:app --host 0.0.0.0 --port 12015
|
||||||
```
|
```
|
||||||
|
|
||||||
9. Click **Create Resource**
|
9. Click **Create Resource**
|
||||||
|
|
||||||
> **Important:** Nixpacks copies the **contents** of the Base Directory to `/app/` in the container. When Base Directory is `/backend`, the `backend/` folder wrapper is removed — only `app/`, `tests/`, and `requirements.txt` are copied. Therefore the start command uses `app.main:app` (not `backend.app.main:app`).
|
> **Important:** Nixpacks copies the **contents** of the Base Directory to `/app/` in the container. When Base Directory is `/backend`, the `backend/` folder wrapper is removed — only `app/`, `tests/`, and `requirements.txt` are copied. Therefore the start command uses `app.main:app` (not `backend.app.main:app`).
|
||||||
|
|
||||||
### Backend Environment Variables
|
### Backend Environment Variables
|
||||||
|
|
||||||
Add these as **Runtime** environment variables in Coolify:
|
Add these as **Runtime** environment variables in Coolify:
|
||||||
|
|
||||||
| Variable | Description | Example |
|
| Variable | Description | Example |
|
||||||
| -------------------- | ------------------------------------ | ------------------------------------ |
|
| -------------------- | ------------------------------------ | ------------------------------------ |
|
||||||
| `OPENROUTER_API_KEY` | OpenRouter API key for AI generation | `sk-or-v1-...` |
|
| `OPENROUTER_API_KEY` | OpenRouter API key for AI generation | `sk-or-v1-...` |
|
||||||
| `JWT_SECRET` | Secret key for JWT token signing | Generate with `openssl rand -hex 32` |
|
| `JWT_SECRET` | Secret key for JWT token signing | Generate with `openssl rand -hex 32` |
|
||||||
| `APP_URL` | Public URL of the backend | `https://api.ai.allucanget.biz` |
|
| `APP_URL` | Public URL of the backend | `https://api.ai.allucanget.biz` |
|
||||||
| `APP_NAME` | Application name | `All You Can GET AI` |
|
| `APP_NAME` | Application name | `All You Can GET AI` |
|
||||||
| `CORS_ORIGINS` | Comma-separated allowed origins | `https://ai.allucanget.biz` |
|
| `CORS_ORIGINS` | Comma-separated allowed origins | `https://ai.allucanget.biz` |
|
||||||
|
|
||||||
## Step 2: Create Frontend Service
|
## Step 2: Create Frontend Service
|
||||||
|
|
||||||
1. In Coolify, click **Add Resource** → **Deploy a new resource** → **Git**
|
1. In Coolify, click **Add Resource** → **Deploy a new resource** → **Git**
|
||||||
2. Select the same repository
|
2. Select the same repository
|
||||||
3. Choose the `main` branch
|
3. Choose the `main` branch
|
||||||
4. Set **Build Pack** to `nixpacks`
|
4. Set **Build Pack** to `nixpacks`
|
||||||
5. Set **Base Directory** to `/frontend` - this tells Nixpacks to look in the `frontend/` subdirectory for `requirements.txt` and the Python application
|
5. Set **Base Directory** to `/frontend` - this tells Nixpacks to look in the `frontend/` subdirectory for `requirements.txt` and the Python application
|
||||||
6. Set **Ports Exposed** to `12016`
|
6. Set **Ports Exposed** to `12016`
|
||||||
7. Set **Start Command** to:
|
7. Set **Start Command** to:
|
||||||
|
|
||||||
```txt
|
```txt
|
||||||
gunicorn app.main:app --bind 0.0.0.0:12016 --workers 2 --timeout 120
|
gunicorn app.main:app --bind 0.0.0.0:12016 --workers 2 --timeout 120
|
||||||
```
|
```
|
||||||
|
|
||||||
8. Click **Create Resource**
|
8. Click **Create Resource**
|
||||||
|
|
||||||
> **Note:** Nixpacks will automatically detect and install only the production dependencies from `requirements.txt`.
|
> **Note:** Nixpacks will automatically detect and install only the production dependencies from `requirements.txt`.
|
||||||
> **Important:** Nixpacks copies the **contents** of the Base Directory to `/app/` in the container. When Base Directory is `/frontend`, the `frontend/` folder wrapper is removed — only `app/`, `tests/`, and `requirements.txt` are copied. Therefore the start command uses `app.main:app` (not `frontend.app.main:app`).
|
> **Important:** Nixpacks copies the **contents** of the Base Directory to `/app/` in the container. When Base Directory is `/frontend`, the `frontend/` folder wrapper is removed — only `app/`, `tests/`, and `requirements.txt` are copied. Therefore the start command uses `app.main:app` (not `frontend.app.main:app`).
|
||||||
|
|
||||||
### Frontend Environment Variables
|
### Frontend Environment Variables
|
||||||
|
|
||||||
Add these as **Runtime** environment variables in Coolify:
|
Add these as **Runtime** environment variables in Coolify:
|
||||||
|
|
||||||
| Variable | Description | Example |
|
| Variable | Description | Example |
|
||||||
| ------------------ | ----------------------------------------- | --------------------------------------------------------------- |
|
| ------------------ | ----------------------------------------- | --------------------------------------------------------------- |
|
||||||
| `FLASK_SECRET_KEY` | Flask session cookie signing key | Generate with `openssl rand -hex 32` |
|
| `FLASK_SECRET_KEY` | Flask session cookie signing key | Generate with `openssl rand -hex 32` |
|
||||||
| `BACKEND_URL` | Internal URL to reach the backend service | `http://localhost:12015` (or use Coolify's internal networking) |
|
| `BACKEND_URL` | Internal URL to reach the backend service | `http://localhost:12015` (or use Coolify's internal networking) |
|
||||||
|
|
||||||
## Step 3: Configure Reverse Proxy
|
## Step 3: Configure Reverse Proxy
|
||||||
|
|
||||||
Coolify provides a built-in reverse proxy. Configure routing rules:
|
Coolify provides a built-in reverse proxy. Configure routing rules:
|
||||||
|
|
||||||
### Backend Proxy Rules
|
### Backend Proxy Rules
|
||||||
|
|
||||||
- **Domain**: `api.ai.allucanget.biz` (or subdomain of your choice)
|
- **Domain**: `api.ai.allucanget.biz` (or subdomain of your choice)
|
||||||
- **Port**: `12015`
|
- **Port**: `12015`
|
||||||
- **Path**: `/api/*` → forward to backend
|
- **Path**: `/api/*` → forward to backend
|
||||||
|
|
||||||
### Frontend Proxy Rules
|
### Frontend Proxy Rules
|
||||||
|
|
||||||
- **Domain**: `ai.allucanget.biz`
|
- **Domain**: `ai.allucanget.biz`
|
||||||
- **Port**: `12016`
|
- **Port**: `12016`
|
||||||
- **Path**: `/` → forward to frontend
|
- **Path**: `/` → forward to frontend
|
||||||
|
|
||||||
## Step 4: SSL/TLS
|
## Step 4: SSL/TLS
|
||||||
|
|
||||||
Enable HTTPS in Coolify for both services:
|
Enable HTTPS in Coolify for both services:
|
||||||
|
|
||||||
1. Go to each service's settings
|
1. Go to each service's settings
|
||||||
2. Enable **Auto HTTPS** (Let's Encrypt)
|
2. Enable **Auto HTTPS** (Let's Encrypt)
|
||||||
3. Configure domain names
|
3. Configure domain names
|
||||||
4. Coolify automatically handles certificate renewal
|
4. Coolify automatically handles certificate renewal
|
||||||
|
|
||||||
## Step 5: Persistent Storage (Optional)
|
## Step 5: Persistent Storage (Optional)
|
||||||
|
|
||||||
If you want to persist DuckDB data:
|
If you want to persist DuckDB data:
|
||||||
|
|
||||||
1. In Coolify, go to the **Backend** service
|
1. In Coolify, go to the **Backend** service
|
||||||
2. Navigate to **Persistent Storage**
|
2. Navigate to **Persistent Storage**
|
||||||
3. Add a volume mount:
|
3. Add a volume mount:
|
||||||
- **Host Path**: `/data` (or any path on the host)
|
- **Host Path**: `/data` (or any path on the host)
|
||||||
- **Container Path**: `/app/data`
|
- **Container Path**: `/app/data`
|
||||||
- **Type**: `Bind Mount` or `Volume`
|
- **Type**: `Bind Mount` or `Volume`
|
||||||
|
|
||||||
## Troubleshooting
|
## Troubleshooting
|
||||||
|
|
||||||
### Backend healthcheck stays unhealthy
|
### Backend healthcheck stays unhealthy
|
||||||
|
|
||||||
- Check backend logs in Coolify
|
- Check backend logs in Coolify
|
||||||
- Verify `OPENROUTER_API_KEY` and `JWT_SECRET` are set
|
- Verify `OPENROUTER_API_KEY` and `JWT_SECRET` are set
|
||||||
- Verify volume mount at `/app/data` is writable
|
- Verify volume mount at `/app/data` is writable
|
||||||
|
|
||||||
### Backend won't start
|
### Backend won't start
|
||||||
|
|
||||||
- Check that `OPENROUTER_API_KEY` is set
|
- Check that `OPENROUTER_API_KEY` is set
|
||||||
- Verify `JWT_SECRET` is a sufficiently long random string
|
- Verify `JWT_SECRET` is a sufficiently long random string
|
||||||
- Check logs in Coolify's **Logs** tab
|
- Check logs in Coolify's **Logs** tab
|
||||||
|
|
||||||
### Frontend can't reach backend
|
### Frontend can't reach backend
|
||||||
|
|
||||||
- Ensure `BACKEND_URL` points to the correct internal URL
|
- Ensure `BACKEND_URL` points to the correct internal URL
|
||||||
- If both services are on the same Coolify server, use `http://localhost:12015`
|
- If both services are on the same Coolify server, use `http://localhost:12015`
|
||||||
- Check that the backend service is running and healthy
|
- Check that the backend service is running and healthy
|
||||||
|
|
||||||
### CORS errors
|
### CORS errors
|
||||||
|
|
||||||
- Set `CORS_ORIGINS` to include your frontend domain
|
- Set `CORS_ORIGINS` to include your frontend domain
|
||||||
- Example: `https://ai.allucanget.biz`
|
- Example: `https://ai.allucanget.biz`
|
||||||
|
|
||||||
### Nixpacks build fails
|
### Nixpacks build fails
|
||||||
|
|
||||||
- Verify the base directory is correct (`/backend` or `/frontend`)
|
- Verify the base directory is correct (`/backend` or `/frontend`)
|
||||||
- Check that `requirements.txt` exists in the base directory
|
- Check that `requirements.txt` exists in the base directory
|
||||||
- Review build logs in Coolify
|
- Review build logs in Coolify
|
||||||
|
|
||||||
## Environment Variable Summary
|
## Environment Variable Summary
|
||||||
|
|
||||||
All required environment variables:
|
All required environment variables:
|
||||||
|
|
||||||
| Variable | Service | Required |
|
| Variable | Service | Required |
|
||||||
| -------------------- | -------- | ------------------------------------- |
|
| -------------------- | -------- | ------------------------------------- |
|
||||||
| `OPENROUTER_API_KEY` | Backend | Yes |
|
| `OPENROUTER_API_KEY` | Backend | Yes |
|
||||||
| `JWT_SECRET` | Backend | Yes |
|
| `JWT_SECRET` | Backend | Yes |
|
||||||
| `APP_URL` | Backend | Yes |
|
| `APP_URL` | Backend | Yes |
|
||||||
| `APP_NAME` | Backend | No (defaults to "All You Can GET AI") |
|
| `APP_NAME` | Backend | No (defaults to "All You Can GET AI") |
|
||||||
| `CORS_ORIGINS` | Backend | Yes |
|
| `CORS_ORIGINS` | Backend | Yes |
|
||||||
| `FLASK_SECRET_KEY` | Frontend | Yes |
|
| `FLASK_SECRET_KEY` | Frontend | Yes |
|
||||||
| `BACKEND_URL` | Frontend | Yes |
|
| `BACKEND_URL` | Frontend | Yes |
|
||||||
|
|
||||||
## Deployment Checklist
|
## Deployment Checklist
|
||||||
|
|
||||||
- [ ] Repository pushed to Git
|
- [ ] Repository pushed to Git
|
||||||
- [ ] Backend service created with correct base directory (`/backend`)
|
- [ ] Backend service created with correct base directory (`/backend`)
|
||||||
- [ ] Backend environment variables configured
|
- [ ] Backend environment variables configured
|
||||||
- [ ] Frontend service created with correct base directory (`/frontend`)
|
- [ ] Frontend service created with correct base directory (`/frontend`)
|
||||||
- [ ] Frontend environment variables configured
|
- [ ] Frontend environment variables configured
|
||||||
- [ ] SSL certificates enabled
|
- [ ] SSL certificates enabled
|
||||||
- [ ] Domain names configured
|
- [ ] Domain names configured
|
||||||
- [ ] Health checks passing
|
- [ ] Health checks passing
|
||||||
- [ ] Logs reviewed for errors
|
- [ ] Logs reviewed for errors
|
||||||
+21
-21
@@ -1,21 +1,21 @@
|
|||||||
FROM python:3.12-slim
|
FROM python:3.12-slim
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Install system dependencies
|
# Install system dependencies
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
gcc \
|
gcc \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
# Copy requirements and install Python dependencies
|
# Copy requirements and install Python dependencies
|
||||||
COPY requirements.txt .
|
COPY requirements.txt .
|
||||||
RUN pip install --no-cache-dir -r requirements.txt
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
# Copy application code
|
# Copy application code
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
# Expose port
|
# Expose port
|
||||||
EXPOSE 12016
|
EXPOSE 12016
|
||||||
|
|
||||||
# Run the application
|
# Run the application
|
||||||
CMD ["gunicorn", "app.main:app", "--bind", "0.0.0.0:12016", "--workers", "2", "--timeout", "120"]
|
CMD ["gunicorn", "app.main:app", "--bind", "0.0.0.0:12016", "--workers", "2", "--timeout", "120"]
|
||||||
+57
-57
@@ -1,57 +1,57 @@
|
|||||||
# Frontend — Flask Web UI
|
# Frontend — Flask Web UI
|
||||||
|
|
||||||
Server-rendered UI powered by Flask + Jinja2 + Tailwind CSS.
|
Server-rendered UI powered by Flask + Jinja2 + Tailwind CSS.
|
||||||
|
|
||||||
## Tech Stack
|
## Tech Stack
|
||||||
|
|
||||||
| Layer | Choice |
|
| Layer | Choice |
|
||||||
| ----------- | -------------------------------- |
|
| ----------- | -------------------------------- |
|
||||||
| Framework | Flask |
|
| Framework | Flask |
|
||||||
| Templates | Jinja2 |
|
| Templates | Jinja2 |
|
||||||
| Styling | Tailwind CSS (CDN) + custom CSS |
|
| Styling | Tailwind CSS (CDN) + custom CSS |
|
||||||
| HTTP client | httpx |
|
| HTTP client | httpx |
|
||||||
| Server | gunicorn (prod), Flask dev (dev) |
|
| Server | gunicorn (prod), Flask dev (dev) |
|
||||||
|
|
||||||
## Project Structure
|
## Project Structure
|
||||||
|
|
||||||
```txt
|
```txt
|
||||||
frontend/
|
frontend/
|
||||||
app/
|
app/
|
||||||
__init__.py App factory (create_app)
|
__init__.py App factory (create_app)
|
||||||
main.py Entry point — calls create_app()
|
main.py Entry point — calls create_app()
|
||||||
config.py Configuration (secret key, backend URL)
|
config.py Configuration (secret key, backend URL)
|
||||||
filters.py Jinja2 template filters (fromisoformat, humantime)
|
filters.py Jinja2 template filters (fromisoformat, humantime)
|
||||||
helpers.py API client (_api), auth decorators, model helpers
|
helpers.py API client (_api), auth decorators, model helpers
|
||||||
routes/ Blueprint route handlers
|
routes/ Blueprint route handlers
|
||||||
__init__.py register_blueprints()
|
__init__.py register_blueprints()
|
||||||
auth.py /login, /register, /logout, /
|
auth.py /login, /register, /logout, /
|
||||||
dashboard.py /dashboard
|
dashboard.py /dashboard
|
||||||
gallery.py /gallery, image/video/upload detail, /images/<id>/file
|
gallery.py /gallery, image/video/upload detail, /images/<id>/file
|
||||||
generate.py /generate/text, /generate/image, /generate/video
|
generate.py /generate/text, /generate/image, /generate/video
|
||||||
admin.py /admin, /admin/models, /api/admin/*
|
admin.py /admin, /admin/models, /api/admin/*
|
||||||
profile.py /users/profile
|
profile.py /users/profile
|
||||||
templates/ Jinja2 HTML templates
|
templates/ Jinja2 HTML templates
|
||||||
admin/ Admin sub-templates (models, videos)
|
admin/ Admin sub-templates (models, videos)
|
||||||
static/ CSS (style.css), JS (app.js)
|
static/ CSS (style.css), JS (app.js)
|
||||||
tests/ pytest suite
|
tests/ pytest suite
|
||||||
Dockerfile Production container
|
Dockerfile Production container
|
||||||
requirements.txt Runtime dependencies
|
requirements.txt Runtime dependencies
|
||||||
requirements-dev.txt Dev dependencies
|
requirements-dev.txt Dev dependencies
|
||||||
```
|
```
|
||||||
|
|
||||||
## Running locally
|
## Running locally
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd frontend
|
cd frontend
|
||||||
flask --app app.main run --port 12016 --debug
|
flask --app app.main run --port 12016 --debug
|
||||||
```
|
```
|
||||||
|
|
||||||
## Running tests
|
## Running tests
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pytest frontend/tests/
|
pytest frontend/tests/
|
||||||
```
|
```
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
See [docs/ARCHITECTURE.md](../docs/ARCHITECTURE.md) for full documentation (arc42 template).
|
See [docs/ARCHITECTURE.md](../docs/ARCHITECTURE.md) for full documentation (arc42 template).
|
||||||
+16
-16
@@ -1,17 +1,17 @@
|
|||||||
"""Flask frontend application factory."""
|
"""Flask frontend application factory."""
|
||||||
from flask import Flask
|
from flask import Flask
|
||||||
|
|
||||||
from .config import Config
|
from .config import Config
|
||||||
from .filters import register_filters
|
from .filters import register_filters
|
||||||
from .routes import register_blueprints
|
from .routes import register_blueprints
|
||||||
|
|
||||||
|
|
||||||
def create_app() -> Flask:
|
def create_app() -> Flask:
|
||||||
"""Create and configure the Flask application."""
|
"""Create and configure the Flask application."""
|
||||||
app = Flask(__name__)
|
app = Flask(__name__)
|
||||||
app.config.from_object(Config)
|
app.config.from_object(Config)
|
||||||
|
|
||||||
register_filters(app)
|
register_filters(app)
|
||||||
register_blueprints(app)
|
register_blueprints(app)
|
||||||
|
|
||||||
return app
|
return app
|
||||||
+10
-10
@@ -1,10 +1,10 @@
|
|||||||
"""Flask frontend configuration."""
|
"""Flask frontend configuration."""
|
||||||
import os
|
import os
|
||||||
|
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
SECRET_KEY = os.getenv(
|
SECRET_KEY = os.getenv(
|
||||||
"FLASK_SECRET_KEY", "dev-secret-change-in-production")
|
"FLASK_SECRET_KEY", "dev-secret-change-in-production")
|
||||||
BACKEND_URL = os.getenv("BACKEND_URL", "http://localhost:12015")
|
BACKEND_URL = os.getenv("BACKEND_URL", "http://localhost:12015")
|
||||||
SESSION_COOKIE_HTTPONLY = True
|
SESSION_COOKIE_HTTPONLY = True
|
||||||
SESSION_COOKIE_SAMESITE = "Lax"
|
SESSION_COOKIE_SAMESITE = "Lax"
|
||||||
+41
-41
@@ -1,41 +1,41 @@
|
|||||||
"""Jinja template filters for the frontend app."""
|
"""Jinja template filters for the frontend app."""
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
|
||||||
def from_iso_format(s: str) -> datetime:
|
def from_iso_format(s: str) -> datetime:
|
||||||
"""Convert ISO 8601 string to datetime object."""
|
"""Convert ISO 8601 string to datetime object."""
|
||||||
return datetime.fromisoformat(s)
|
return datetime.fromisoformat(s)
|
||||||
|
|
||||||
|
|
||||||
def human_time(dt: datetime) -> str:
|
def human_time(dt: datetime) -> str:
|
||||||
"""Format a datetime object into a human-readable relative time."""
|
"""Format a datetime object into a human-readable relative time."""
|
||||||
now = datetime.now(timezone.utc)
|
now = datetime.now(timezone.utc)
|
||||||
if dt.tzinfo is None:
|
if dt.tzinfo is None:
|
||||||
dt = dt.replace(tzinfo=timezone.utc)
|
dt = dt.replace(tzinfo=timezone.utc)
|
||||||
|
|
||||||
diff = now - dt
|
diff = now - dt
|
||||||
seconds = diff.total_seconds()
|
seconds = diff.total_seconds()
|
||||||
|
|
||||||
if seconds < 60:
|
if seconds < 60:
|
||||||
return "just now"
|
return "just now"
|
||||||
elif seconds < 3600:
|
elif seconds < 3600:
|
||||||
minutes = int(seconds / 60)
|
minutes = int(seconds / 60)
|
||||||
return f"{minutes} minute{'s' if minutes > 1 else ''} ago"
|
return f"{minutes} minute{'s' if minutes > 1 else ''} ago"
|
||||||
elif seconds < 86400:
|
elif seconds < 86400:
|
||||||
hours = int(seconds / 3600)
|
hours = int(seconds / 3600)
|
||||||
return f"{hours} hour{'s' if hours > 1 else ''} ago"
|
return f"{hours} hour{'s' if hours > 1 else ''} ago"
|
||||||
elif seconds < 2592000:
|
elif seconds < 2592000:
|
||||||
days = int(seconds / 86400)
|
days = int(seconds / 86400)
|
||||||
return f"{days} day{'s' if days > 1 else ''} ago"
|
return f"{days} day{'s' if days > 1 else ''} ago"
|
||||||
elif seconds < 31536000:
|
elif seconds < 31536000:
|
||||||
months = int(seconds / 2592000)
|
months = int(seconds / 2592000)
|
||||||
return f"{months} month{'s' if months > 1 else ''} ago"
|
return f"{months} month{'s' if months > 1 else ''} ago"
|
||||||
else:
|
else:
|
||||||
years = int(seconds / 31536000)
|
years = int(seconds / 31536000)
|
||||||
return f"{years} year{'s' if years > 1 else ''} ago"
|
return f"{years} year{'s' if years > 1 else ''} ago"
|
||||||
|
|
||||||
|
|
||||||
def register_filters(app):
|
def register_filters(app):
|
||||||
"""Register all template filters on the Flask app."""
|
"""Register all template filters on the Flask app."""
|
||||||
app.template_filter("fromisoformat")(from_iso_format)
|
app.template_filter("fromisoformat")(from_iso_format)
|
||||||
app.template_filter("humantime")(human_time)
|
app.template_filter("humantime")(human_time)
|
||||||
+149
-149
@@ -1,149 +1,149 @@
|
|||||||
"""Helper utilities for the frontend app."""
|
"""Helper utilities for the frontend app."""
|
||||||
import functools
|
import functools
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
from flask import redirect, session, url_for, flash
|
from flask import redirect, session, url_for, flash
|
||||||
|
|
||||||
|
|
||||||
def _backend(path: str) -> str:
|
def _backend(path: str) -> str:
|
||||||
from flask import current_app
|
from flask import current_app
|
||||||
return f"{current_app.config['BACKEND_URL']}{path}"
|
return f"{current_app.config['BACKEND_URL']}{path}"
|
||||||
|
|
||||||
|
|
||||||
def _api(method: str, path: str, *, token: str | None = None, **kwargs):
|
def _api(method: str, path: str, *, token: str | None = None, **kwargs):
|
||||||
headers = kwargs.pop("headers", {})
|
headers = kwargs.pop("headers", {})
|
||||||
timeout = kwargs.pop("timeout", 30)
|
timeout = kwargs.pop("timeout", 30)
|
||||||
if token:
|
if token:
|
||||||
headers["Authorization"] = f"Bearer {token}"
|
headers["Authorization"] = f"Bearer {token}"
|
||||||
return httpx.request(method, _backend(path), headers=headers, timeout=timeout, **kwargs)
|
return httpx.request(method, _backend(path), headers=headers, timeout=timeout, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
def _model_matches_modality(model: dict, modality: str) -> bool:
|
def _model_matches_modality(model: dict, modality: str) -> bool:
|
||||||
"""Heuristic fallback when backend modality filter returns empty."""
|
"""Heuristic fallback when backend modality filter returns empty."""
|
||||||
model_modality = (model.get("modality") or "").lower()
|
model_modality = (model.get("modality") or "").lower()
|
||||||
if model_modality == modality:
|
if model_modality == modality:
|
||||||
return True
|
return True
|
||||||
|
|
||||||
text = f"{model.get('id', '')} {model.get('name', '')}".lower()
|
text = f"{model.get('id', '')} {model.get('name', '')}".lower()
|
||||||
keywords = {
|
keywords = {
|
||||||
"image": ["image", "dall-e", "flux", "stable-diffusion", "sdxl", "recraft", "ideogram", "gpt-image"],
|
"image": ["image", "dall-e", "flux", "stable-diffusion", "sdxl", "recraft", "ideogram", "gpt-image"],
|
||||||
"video": ["video", "sora", "runway", "veo", "kling", "pika", "luma", "wan"],
|
"video": ["video", "sora", "runway", "veo", "kling", "pika", "luma", "wan"],
|
||||||
"audio": ["audio", "speech", "voice", "tts", "transcribe", "whisper"],
|
"audio": ["audio", "speech", "voice", "tts", "transcribe", "whisper"],
|
||||||
}
|
}
|
||||||
|
|
||||||
if modality in keywords:
|
if modality in keywords:
|
||||||
return any(k in text for k in keywords[modality])
|
return any(k in text for k in keywords[modality])
|
||||||
|
|
||||||
if modality == "text":
|
if modality == "text":
|
||||||
non_text_hits = any(
|
non_text_hits = any(
|
||||||
k in text for k in keywords["image"] + keywords["video"] + keywords["audio"])
|
k in text for k in keywords["image"] + keywords["video"] + keywords["audio"])
|
||||||
return not non_text_hits
|
return not non_text_hits
|
||||||
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
def _load_models(token: str, modality: str) -> list[dict]:
|
def _load_models(token: str, modality: str) -> list[dict]:
|
||||||
"""Load models for modality; fallback to unfiltered cache if needed."""
|
"""Load models for modality; fallback to unfiltered cache if needed."""
|
||||||
try:
|
try:
|
||||||
models_resp = _api("GET", "/models/", token=token,
|
models_resp = _api("GET", "/models/", token=token,
|
||||||
params={"modality": modality})
|
params={"modality": modality})
|
||||||
except httpx.RequestError:
|
except httpx.RequestError:
|
||||||
return []
|
return []
|
||||||
if models_resp.status_code == 200:
|
if models_resp.status_code == 200:
|
||||||
try:
|
try:
|
||||||
models = models_resp.json()
|
models = models_resp.json()
|
||||||
except ValueError:
|
except ValueError:
|
||||||
models = []
|
models = []
|
||||||
if models:
|
if models:
|
||||||
return models
|
return models
|
||||||
|
|
||||||
try:
|
try:
|
||||||
all_resp = _api("GET", "/models/", token=token)
|
all_resp = _api("GET", "/models/", token=token)
|
||||||
except httpx.RequestError:
|
except httpx.RequestError:
|
||||||
return []
|
return []
|
||||||
if all_resp.status_code != 200:
|
if all_resp.status_code != 200:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
try:
|
try:
|
||||||
all_models = all_resp.json()
|
all_models = all_resp.json()
|
||||||
except ValueError:
|
except ValueError:
|
||||||
return []
|
return []
|
||||||
filtered = [m for m in all_models if _model_matches_modality(m, modality)]
|
filtered = [m for m in all_models if _model_matches_modality(m, modality)]
|
||||||
return filtered or all_models
|
return filtered or all_models
|
||||||
|
|
||||||
|
|
||||||
def login_required(view):
|
def login_required(view):
|
||||||
@functools.wraps(view)
|
@functools.wraps(view)
|
||||||
def wrapped(*args, **kwargs):
|
def wrapped(*args, **kwargs):
|
||||||
if "access_token" not in session:
|
if "access_token" not in session:
|
||||||
return redirect(url_for("auth.login"))
|
return redirect(url_for("auth.login"))
|
||||||
# Validate, with auto-refresh on expiry
|
# Validate, with auto-refresh on expiry
|
||||||
if not _validate_and_refresh():
|
if not _validate_and_refresh():
|
||||||
return redirect(url_for("auth.login"))
|
return redirect(url_for("auth.login"))
|
||||||
return view(*args, **kwargs)
|
return view(*args, **kwargs)
|
||||||
return wrapped
|
return wrapped
|
||||||
|
|
||||||
|
|
||||||
def admin_required(view):
|
def admin_required(view):
|
||||||
@functools.wraps(view)
|
@functools.wraps(view)
|
||||||
def wrapped(*args, **kwargs):
|
def wrapped(*args, **kwargs):
|
||||||
if "access_token" not in session:
|
if "access_token" not in session:
|
||||||
return redirect(url_for("auth.login"))
|
return redirect(url_for("auth.login"))
|
||||||
if not _validate_and_refresh():
|
if not _validate_and_refresh():
|
||||||
return redirect(url_for("auth.login"))
|
return redirect(url_for("auth.login"))
|
||||||
if session.get("user_role") != "admin":
|
if session.get("user_role") != "admin":
|
||||||
flash("Admin access required.", "error")
|
flash("Admin access required.", "error")
|
||||||
return redirect(url_for("dashboard.index"))
|
return redirect(url_for("dashboard.index"))
|
||||||
return view(*args, **kwargs)
|
return view(*args, **kwargs)
|
||||||
return wrapped
|
return wrapped
|
||||||
|
|
||||||
|
|
||||||
# ── Token validation & refresh ────────────────────────────────────────────
|
# ── Token validation & refresh ────────────────────────────────────────────
|
||||||
|
|
||||||
def _validate_access_token(token: str) -> bool:
|
def _validate_access_token(token: str) -> bool:
|
||||||
"""Return True if the access token is still valid."""
|
"""Return True if the access token is still valid."""
|
||||||
try:
|
try:
|
||||||
resp = _api("GET", "/auth/validate", token=token)
|
resp = _api("GET", "/auth/validate", token=token)
|
||||||
return resp.status_code == 200
|
return resp.status_code == 200
|
||||||
except httpx.RequestError:
|
except httpx.RequestError:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
def _try_refresh() -> bool:
|
def _try_refresh() -> bool:
|
||||||
"""Attempt to refresh an expired access token using the stored refresh token.
|
"""Attempt to refresh an expired access token using the stored refresh token.
|
||||||
|
|
||||||
On success, updates session tokens in place. Returns True if a valid
|
On success, updates session tokens in place. Returns True if a valid
|
||||||
access token exists after the attempt.
|
access token exists after the attempt.
|
||||||
"""
|
"""
|
||||||
refresh_token = session.get("refresh_token")
|
refresh_token = session.get("refresh_token")
|
||||||
if not refresh_token:
|
if not refresh_token:
|
||||||
return False
|
return False
|
||||||
try:
|
try:
|
||||||
resp = _api("POST", "/auth/refresh",
|
resp = _api("POST", "/auth/refresh",
|
||||||
json={"refresh_token": refresh_token})
|
json={"refresh_token": refresh_token})
|
||||||
except httpx.RequestError:
|
except httpx.RequestError:
|
||||||
return False
|
return False
|
||||||
if resp.status_code != 200:
|
if resp.status_code != 200:
|
||||||
return False
|
return False
|
||||||
data = resp.json()
|
data = resp.json()
|
||||||
session["access_token"] = data["access_token"]
|
session["access_token"] = data["access_token"]
|
||||||
session["refresh_token"] = data["refresh_token"]
|
session["refresh_token"] = data["refresh_token"]
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
def _validate_and_refresh() -> bool:
|
def _validate_and_refresh() -> bool:
|
||||||
"""Check access token validity; attempt refresh if expired.
|
"""Check access token validity; attempt refresh if expired.
|
||||||
|
|
||||||
Returns True if a valid session exists after the check.
|
Returns True if a valid session exists after the check.
|
||||||
"""
|
"""
|
||||||
token = session.get("access_token")
|
token = session.get("access_token")
|
||||||
if not token:
|
if not token:
|
||||||
return False
|
return False
|
||||||
if _validate_access_token(token):
|
if _validate_access_token(token):
|
||||||
return True
|
return True
|
||||||
# Access token expired — try to refresh
|
# Access token expired — try to refresh
|
||||||
if _try_refresh():
|
if _try_refresh():
|
||||||
return True
|
return True
|
||||||
# Both tokens are dead — clear session
|
# Both tokens are dead — clear session
|
||||||
session.clear()
|
session.clear()
|
||||||
return False
|
return False
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
"""Flask frontend application entry point."""
|
"""Flask frontend application entry point."""
|
||||||
from . import create_app
|
from . import create_app
|
||||||
|
|
||||||
app = create_app()
|
app = create_app()
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
app.run(host="0.0.0.0", port=12016, debug=True)
|
app.run(host="0.0.0.0", port=12016, debug=True)
|
||||||
@@ -1,19 +1,19 @@
|
|||||||
"""Blueprint registration."""
|
"""Blueprint registration."""
|
||||||
from flask import Flask
|
from flask import Flask
|
||||||
|
|
||||||
|
|
||||||
def register_blueprints(app: Flask):
|
def register_blueprints(app: Flask):
|
||||||
"""Register all application blueprints."""
|
"""Register all application blueprints."""
|
||||||
from .auth import auth_bp
|
from .auth import auth_bp
|
||||||
from .dashboard import dashboard_bp
|
from .dashboard import dashboard_bp
|
||||||
from .gallery import gallery_bp
|
from .gallery import gallery_bp
|
||||||
from .generate import generate_bp
|
from .generate import generate_bp
|
||||||
from .admin import admin_bp
|
from .admin import admin_bp
|
||||||
from .profile import profile_bp
|
from .profile import profile_bp
|
||||||
|
|
||||||
app.register_blueprint(auth_bp)
|
app.register_blueprint(auth_bp)
|
||||||
app.register_blueprint(dashboard_bp)
|
app.register_blueprint(dashboard_bp)
|
||||||
app.register_blueprint(gallery_bp)
|
app.register_blueprint(gallery_bp)
|
||||||
app.register_blueprint(generate_bp)
|
app.register_blueprint(generate_bp)
|
||||||
app.register_blueprint(admin_bp)
|
app.register_blueprint(admin_bp)
|
||||||
app.register_blueprint(profile_bp)
|
app.register_blueprint(profile_bp)
|
||||||
@@ -1,75 +1,75 @@
|
|||||||
"""Admin blueprint."""
|
"""Admin blueprint."""
|
||||||
from flask import Blueprint, flash, jsonify, redirect, render_template, request, session, url_for
|
from flask import Blueprint, flash, jsonify, redirect, render_template, request, session, url_for
|
||||||
|
|
||||||
from ..helpers import _api, admin_required
|
from ..helpers import _api, admin_required
|
||||||
|
|
||||||
admin_bp = Blueprint("admin", __name__)
|
admin_bp = Blueprint("admin", __name__)
|
||||||
|
|
||||||
|
|
||||||
@admin_bp.get("/admin")
|
@admin_bp.get("/admin")
|
||||||
@admin_required
|
@admin_required
|
||||||
def index():
|
def index():
|
||||||
token = session["access_token"]
|
token = session["access_token"]
|
||||||
stats_resp = _api("GET", "/admin/stats", token=token)
|
stats_resp = _api("GET", "/admin/stats", token=token)
|
||||||
users_resp = _api("GET", "/users", token=token)
|
users_resp = _api("GET", "/users", token=token)
|
||||||
stats = stats_resp.json() if stats_resp.status_code == 200 else {}
|
stats = stats_resp.json() if stats_resp.status_code == 200 else {}
|
||||||
users = users_resp.json() if users_resp.status_code == 200 else []
|
users = users_resp.json() if users_resp.status_code == 200 else []
|
||||||
return render_template("admin.html", stats=stats, users=users)
|
return render_template("admin.html", stats=stats, users=users)
|
||||||
|
|
||||||
|
|
||||||
@admin_bp.post("/admin/users/<user_id>/role")
|
@admin_bp.post("/admin/users/<user_id>/role")
|
||||||
@admin_required
|
@admin_required
|
||||||
def set_role(user_id: str):
|
def set_role(user_id: str):
|
||||||
role = request.form.get("role", "user")
|
role = request.form.get("role", "user")
|
||||||
_api("PUT", f"/users/{user_id}/role",
|
_api("PUT", f"/users/{user_id}/role",
|
||||||
token=session["access_token"], json={"role": role})
|
token=session["access_token"], json={"role": role})
|
||||||
flash(f"Role updated to '{role}'.", "success")
|
flash(f"Role updated to '{role}'.", "success")
|
||||||
return redirect(url_for("admin.index"))
|
return redirect(url_for("admin.index"))
|
||||||
|
|
||||||
|
|
||||||
@admin_bp.post("/admin/users/<user_id>/delete")
|
@admin_bp.post("/admin/users/<user_id>/delete")
|
||||||
@admin_required
|
@admin_required
|
||||||
def delete_user(user_id: str):
|
def delete_user(user_id: str):
|
||||||
_api("DELETE", f"/users/{user_id}", token=session["access_token"])
|
_api("DELETE", f"/users/{user_id}", token=session["access_token"])
|
||||||
flash("User deleted.", "success")
|
flash("User deleted.", "success")
|
||||||
return redirect(url_for("admin.index"))
|
return redirect(url_for("admin.index"))
|
||||||
|
|
||||||
|
|
||||||
@admin_bp.get("/admin/models")
|
@admin_bp.get("/admin/models")
|
||||||
@admin_required
|
@admin_required
|
||||||
def models():
|
def models():
|
||||||
"""Show model cache status and list all models."""
|
"""Show model cache status and list all models."""
|
||||||
return render_template("admin/models.html")
|
return render_template("admin/models.html")
|
||||||
|
|
||||||
|
|
||||||
# ── Admin API proxies (same-origin for browser JS) ────────────────────────
|
# ── Admin API proxies (same-origin for browser JS) ────────────────────────
|
||||||
|
|
||||||
@admin_bp.get("/api/admin/videos")
|
@admin_bp.get("/api/admin/videos")
|
||||||
@admin_required
|
@admin_required
|
||||||
def list_videos():
|
def list_videos():
|
||||||
resp = _api("GET", "/admin/videos", token=session["access_token"])
|
resp = _api("GET", "/admin/videos", token=session["access_token"])
|
||||||
return jsonify(resp.json()), resp.status_code
|
return jsonify(resp.json()), resp.status_code
|
||||||
|
|
||||||
|
|
||||||
@admin_bp.post("/api/admin/videos/<job_id>/retry")
|
@admin_bp.post("/api/admin/videos/<job_id>/retry")
|
||||||
@admin_required
|
@admin_required
|
||||||
def retry_video(job_id: str):
|
def retry_video(job_id: str):
|
||||||
resp = _api(
|
resp = _api(
|
||||||
"POST", f"/admin/videos/{job_id}/retry", token=session["access_token"])
|
"POST", f"/admin/videos/{job_id}/retry", token=session["access_token"])
|
||||||
return jsonify(resp.json()), resp.status_code
|
return jsonify(resp.json()), resp.status_code
|
||||||
|
|
||||||
|
|
||||||
@admin_bp.post("/api/admin/videos/<job_id>/cancel")
|
@admin_bp.post("/api/admin/videos/<job_id>/cancel")
|
||||||
@admin_required
|
@admin_required
|
||||||
def cancel_video(job_id: str):
|
def cancel_video(job_id: str):
|
||||||
resp = _api(
|
resp = _api(
|
||||||
"POST", f"/admin/videos/{job_id}/cancel", token=session["access_token"])
|
"POST", f"/admin/videos/{job_id}/cancel", token=session["access_token"])
|
||||||
return jsonify(resp.json()), resp.status_code
|
return jsonify(resp.json()), resp.status_code
|
||||||
|
|
||||||
|
|
||||||
@admin_bp.delete("/api/admin/videos/<job_id>")
|
@admin_bp.delete("/api/admin/videos/<job_id>")
|
||||||
@admin_required
|
@admin_required
|
||||||
def delete_video(job_id: str):
|
def delete_video(job_id: str):
|
||||||
resp = _api(
|
resp = _api(
|
||||||
"DELETE", f"/admin/videos/{job_id}", token=session["access_token"])
|
"DELETE", f"/admin/videos/{job_id}", token=session["access_token"])
|
||||||
return jsonify(resp.json()), resp.status_code
|
return jsonify(resp.json()), resp.status_code
|
||||||
+57
-57
@@ -1,58 +1,58 @@
|
|||||||
"""Auth blueprint — login, register, logout, index."""
|
"""Auth blueprint — login, register, logout, index."""
|
||||||
from flask import Blueprint, flash, redirect, render_template, request, session, url_for
|
from flask import Blueprint, flash, redirect, render_template, request, session, url_for
|
||||||
|
|
||||||
from ..helpers import _api
|
from ..helpers import _api
|
||||||
|
|
||||||
auth_bp = Blueprint("auth", __name__)
|
auth_bp = Blueprint("auth", __name__)
|
||||||
|
|
||||||
|
|
||||||
@auth_bp.get("/")
|
@auth_bp.get("/")
|
||||||
def index():
|
def index():
|
||||||
if "access_token" in session:
|
if "access_token" in session:
|
||||||
return redirect(url_for("dashboard.index"))
|
return redirect(url_for("dashboard.index"))
|
||||||
return redirect(url_for("auth.login"))
|
return redirect(url_for("auth.login"))
|
||||||
|
|
||||||
|
|
||||||
@auth_bp.route("/login", methods=["GET", "POST"])
|
@auth_bp.route("/login", methods=["GET", "POST"])
|
||||||
def login():
|
def login():
|
||||||
if request.method == "POST":
|
if request.method == "POST":
|
||||||
email = request.form["email"]
|
email = request.form["email"]
|
||||||
password = request.form["password"]
|
password = request.form["password"]
|
||||||
resp = _api("POST", "/auth/login",
|
resp = _api("POST", "/auth/login",
|
||||||
json={"email": email, "password": password})
|
json={"email": email, "password": password})
|
||||||
if resp.status_code == 200:
|
if resp.status_code == 200:
|
||||||
data = resp.json()
|
data = resp.json()
|
||||||
session["access_token"] = data["access_token"]
|
session["access_token"] = data["access_token"]
|
||||||
session["refresh_token"] = data["refresh_token"]
|
session["refresh_token"] = data["refresh_token"]
|
||||||
me = _api("GET", "/users/me", token=data["access_token"])
|
me = _api("GET", "/users/me", token=data["access_token"])
|
||||||
if me.status_code == 200:
|
if me.status_code == 200:
|
||||||
u = me.json()
|
u = me.json()
|
||||||
session["user_email"] = u.get("email", "")
|
session["user_email"] = u.get("email", "")
|
||||||
session["user_role"] = u.get("role", "user")
|
session["user_role"] = u.get("role", "user")
|
||||||
return redirect(url_for("dashboard.index"))
|
return redirect(url_for("dashboard.index"))
|
||||||
flash("Invalid email or password.", "error")
|
flash("Invalid email or password.", "error")
|
||||||
return render_template("login.html")
|
return render_template("login.html")
|
||||||
|
|
||||||
|
|
||||||
@auth_bp.route("/register", methods=["GET", "POST"])
|
@auth_bp.route("/register", methods=["GET", "POST"])
|
||||||
def register():
|
def register():
|
||||||
if request.method == "POST":
|
if request.method == "POST":
|
||||||
email = request.form["email"]
|
email = request.form["email"]
|
||||||
password = request.form["password"]
|
password = request.form["password"]
|
||||||
resp = _api("POST", "/auth/register",
|
resp = _api("POST", "/auth/register",
|
||||||
json={"email": email, "password": password})
|
json={"email": email, "password": password})
|
||||||
if resp.status_code == 201:
|
if resp.status_code == 201:
|
||||||
flash("Account created. Please log in.", "success")
|
flash("Account created. Please log in.", "success")
|
||||||
return redirect(url_for("auth.login"))
|
return redirect(url_for("auth.login"))
|
||||||
detail = resp.json().get("detail", "Registration failed.")
|
detail = resp.json().get("detail", "Registration failed.")
|
||||||
flash(detail, "error")
|
flash(detail, "error")
|
||||||
return render_template("register.html")
|
return render_template("register.html")
|
||||||
|
|
||||||
|
|
||||||
@auth_bp.get("/logout")
|
@auth_bp.get("/logout")
|
||||||
def logout():
|
def logout():
|
||||||
refresh_token = session.get("refresh_token")
|
refresh_token = session.get("refresh_token")
|
||||||
if refresh_token:
|
if refresh_token:
|
||||||
_api("POST", "/auth/logout", json={"refresh_token": refresh_token})
|
_api("POST", "/auth/logout", json={"refresh_token": refresh_token})
|
||||||
session.clear()
|
session.clear()
|
||||||
return redirect(url_for("auth.login"))
|
return redirect(url_for("auth.login"))
|
||||||
@@ -1,29 +1,29 @@
|
|||||||
"""Dashboard blueprint."""
|
"""Dashboard blueprint."""
|
||||||
from flask import Blueprint, render_template, session
|
from flask import Blueprint, render_template, session
|
||||||
|
|
||||||
from ..helpers import _api, login_required
|
from ..helpers import _api, login_required
|
||||||
|
|
||||||
dashboard_bp = Blueprint("dashboard", __name__)
|
dashboard_bp = Blueprint("dashboard", __name__)
|
||||||
|
|
||||||
|
|
||||||
@dashboard_bp.get("/dashboard")
|
@dashboard_bp.get("/dashboard")
|
||||||
@login_required
|
@login_required
|
||||||
def index():
|
def index():
|
||||||
token = session["access_token"]
|
token = session["access_token"]
|
||||||
resp = _api("GET", "/users/me", token=token)
|
resp = _api("GET", "/users/me", token=token)
|
||||||
user = resp.json() if resp.status_code == 200 else {}
|
user = resp.json() if resp.status_code == 200 else {}
|
||||||
img_resp = _api("GET", "/images/", token=token)
|
img_resp = _api("GET", "/images/", token=token)
|
||||||
images = img_resp.json() if img_resp.status_code == 200 else []
|
images = img_resp.json() if img_resp.status_code == 200 else []
|
||||||
gen_resp = _api("GET", "/generate/images", token=token)
|
gen_resp = _api("GET", "/generate/images", token=token)
|
||||||
generated_images = gen_resp.json() if gen_resp.status_code == 200 else []
|
generated_images = gen_resp.json() if gen_resp.status_code == 200 else []
|
||||||
|
|
||||||
vid_resp = _api("GET", "/generate/videos", token=token)
|
vid_resp = _api("GET", "/generate/videos", token=token)
|
||||||
videos = vid_resp.json() if vid_resp.status_code == 200 else []
|
videos = vid_resp.json() if vid_resp.status_code == 200 else []
|
||||||
pending_videos = [v for v in videos if v.get(
|
pending_videos = [v for v in videos if v.get(
|
||||||
"status") not in ("completed", "failed")]
|
"status") not in ("completed", "failed")]
|
||||||
completed_videos = [v for v in videos if v.get("status") == "completed"]
|
completed_videos = [v for v in videos if v.get("status") == "completed"]
|
||||||
|
|
||||||
return render_template("dashboard.html", user=user, images=images,
|
return render_template("dashboard.html", user=user, images=images,
|
||||||
generated_images=generated_images,
|
generated_images=generated_images,
|
||||||
pending_videos=pending_videos,
|
pending_videos=pending_videos,
|
||||||
completed_videos=completed_videos)
|
completed_videos=completed_videos)
|
||||||
@@ -1,75 +1,75 @@
|
|||||||
"""Gallery blueprint."""
|
"""Gallery blueprint."""
|
||||||
from flask import Blueprint, Response, render_template, session
|
from flask import Blueprint, Response, render_template, session
|
||||||
|
|
||||||
from ..helpers import _api, login_required
|
from ..helpers import _api, login_required
|
||||||
|
|
||||||
gallery_bp = Blueprint("gallery", __name__)
|
gallery_bp = Blueprint("gallery", __name__)
|
||||||
|
|
||||||
|
|
||||||
@gallery_bp.get("/gallery")
|
@gallery_bp.get("/gallery")
|
||||||
@login_required
|
@login_required
|
||||||
def index():
|
def index():
|
||||||
token = session["access_token"]
|
token = session["access_token"]
|
||||||
|
|
||||||
uploads_resp = _api("GET", "/images/", token=token)
|
uploads_resp = _api("GET", "/images/", token=token)
|
||||||
uploads = uploads_resp.json() if uploads_resp.status_code == 200 else []
|
uploads = uploads_resp.json() if uploads_resp.status_code == 200 else []
|
||||||
|
|
||||||
gen_images_resp = _api("GET", "/generate/images", token=token)
|
gen_images_resp = _api("GET", "/generate/images", token=token)
|
||||||
generated_images = gen_images_resp.json(
|
generated_images = gen_images_resp.json(
|
||||||
) if gen_images_resp.status_code == 200 else []
|
) if gen_images_resp.status_code == 200 else []
|
||||||
|
|
||||||
videos_resp = _api("GET", "/generate/videos", token=token)
|
videos_resp = _api("GET", "/generate/videos", token=token)
|
||||||
videos = videos_resp.json() if videos_resp.status_code == 200 else []
|
videos = videos_resp.json() if videos_resp.status_code == 200 else []
|
||||||
|
|
||||||
pending_videos = [v for v in videos if v.get(
|
pending_videos = [v for v in videos if v.get(
|
||||||
"status") not in ("completed", "failed")]
|
"status") not in ("completed", "failed")]
|
||||||
completed_videos = [v for v in videos if v.get("status") == "completed"]
|
completed_videos = [v for v in videos if v.get("status") == "completed"]
|
||||||
|
|
||||||
return render_template(
|
return render_template(
|
||||||
"gallery.html",
|
"gallery.html",
|
||||||
uploads=uploads,
|
uploads=uploads,
|
||||||
generated_images=generated_images,
|
generated_images=generated_images,
|
||||||
pending_videos=pending_videos,
|
pending_videos=pending_videos,
|
||||||
completed_videos=completed_videos,
|
completed_videos=completed_videos,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@gallery_bp.get("/gallery/image/<image_id>")
|
@gallery_bp.get("/gallery/image/<image_id>")
|
||||||
@login_required
|
@login_required
|
||||||
def image_detail(image_id: str):
|
def image_detail(image_id: str):
|
||||||
token = session["access_token"]
|
token = session["access_token"]
|
||||||
resp = _api("GET", f"/generate/images/{image_id}", token=token)
|
resp = _api("GET", f"/generate/images/{image_id}", token=token)
|
||||||
image = resp.json() if resp.status_code == 200 else None
|
image = resp.json() if resp.status_code == 200 else None
|
||||||
return render_template("image_detail.html", image=image)
|
return render_template("image_detail.html", image=image)
|
||||||
|
|
||||||
|
|
||||||
@gallery_bp.get("/gallery/video/<video_id>")
|
@gallery_bp.get("/gallery/video/<video_id>")
|
||||||
@login_required
|
@login_required
|
||||||
def video_detail(video_id: str):
|
def video_detail(video_id: str):
|
||||||
token = session["access_token"]
|
token = session["access_token"]
|
||||||
resp = _api("GET", f"/generate/videos/{video_id}", token=token)
|
resp = _api("GET", f"/generate/videos/{video_id}", token=token)
|
||||||
video = resp.json() if resp.status_code == 200 else None
|
video = resp.json() if resp.status_code == 200 else None
|
||||||
return render_template("video_detail.html", video=video)
|
return render_template("video_detail.html", video=video)
|
||||||
|
|
||||||
|
|
||||||
@gallery_bp.get("/gallery/upload/<image_id>")
|
@gallery_bp.get("/gallery/upload/<image_id>")
|
||||||
@login_required
|
@login_required
|
||||||
def upload_detail(image_id: str):
|
def upload_detail(image_id: str):
|
||||||
token = session["access_token"]
|
token = session["access_token"]
|
||||||
resp = _api("GET", f"/images/{image_id}", token=token)
|
resp = _api("GET", f"/images/{image_id}", token=token)
|
||||||
image = resp.json() if resp.status_code == 200 else None
|
image = resp.json() if resp.status_code == 200 else None
|
||||||
return render_template("upload_detail.html", image=image)
|
return render_template("upload_detail.html", image=image)
|
||||||
|
|
||||||
|
|
||||||
@gallery_bp.get("/images/<image_id>/file")
|
@gallery_bp.get("/images/<image_id>/file")
|
||||||
@login_required
|
@login_required
|
||||||
def serve_uploaded_image(image_id: str):
|
def serve_uploaded_image(image_id: str):
|
||||||
resp = _api("GET", f"/images/{image_id}/file",
|
resp = _api("GET", f"/images/{image_id}/file",
|
||||||
token=session["access_token"])
|
token=session["access_token"])
|
||||||
if resp.status_code != 200:
|
if resp.status_code != 200:
|
||||||
return Response("Not found", status=404)
|
return Response("Not found", status=404)
|
||||||
return Response(
|
return Response(
|
||||||
resp.content,
|
resp.content,
|
||||||
status=200,
|
status=200,
|
||||||
content_type=resp.headers.get("content-type", "image/jpeg"),
|
content_type=resp.headers.get("content-type", "image/jpeg"),
|
||||||
)
|
)
|
||||||
+206
-206
@@ -1,206 +1,206 @@
|
|||||||
"""Generate blueprint — text, image, video generation."""
|
"""Generate blueprint — text, image, video generation."""
|
||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
from flask import (
|
from flask import (
|
||||||
Blueprint, flash, jsonify, redirect, render_template, request, session, url_for,
|
Blueprint, flash, jsonify, redirect, render_template, request, session, url_for,
|
||||||
)
|
)
|
||||||
|
|
||||||
from ..helpers import _api, _load_models, login_required
|
from ..helpers import _api, _load_models, login_required
|
||||||
|
|
||||||
generate_bp = Blueprint("generate", __name__)
|
generate_bp = Blueprint("generate", __name__)
|
||||||
|
|
||||||
|
|
||||||
def _detail_or_default(resp, default: str) -> str:
|
def _detail_or_default(resp, default: str) -> str:
|
||||||
try:
|
try:
|
||||||
payload = resp.json()
|
payload = resp.json()
|
||||||
except ValueError:
|
except ValueError:
|
||||||
return default
|
return default
|
||||||
if isinstance(payload, dict):
|
if isinstance(payload, dict):
|
||||||
detail = payload.get("detail")
|
detail = payload.get("detail")
|
||||||
if detail:
|
if detail:
|
||||||
return str(detail)
|
return str(detail)
|
||||||
return default
|
return default
|
||||||
|
|
||||||
|
|
||||||
@generate_bp.get("/generate")
|
@generate_bp.get("/generate")
|
||||||
@login_required
|
@login_required
|
||||||
def index():
|
def index():
|
||||||
return redirect(url_for("generate.text"))
|
return redirect(url_for("generate.text"))
|
||||||
|
|
||||||
|
|
||||||
@generate_bp.route("/generate/text", methods=["GET", "POST"])
|
@generate_bp.route("/generate/text", methods=["GET", "POST"])
|
||||||
@login_required
|
@login_required
|
||||||
def text():
|
def text():
|
||||||
error = None
|
error = None
|
||||||
token = session["access_token"]
|
token = session["access_token"]
|
||||||
chat_history: list[dict] = session.get("chat_history", [])
|
chat_history: list[dict] = session.get("chat_history", [])
|
||||||
system_prompt: str = session.get("chat_system_prompt", "")
|
system_prompt: str = session.get("chat_system_prompt", "")
|
||||||
model: str = session.get("chat_model", "")
|
model: str = session.get("chat_model", "")
|
||||||
|
|
||||||
if request.method == "POST":
|
if request.method == "POST":
|
||||||
action = request.form.get("action", "send")
|
action = request.form.get("action", "send")
|
||||||
|
|
||||||
if action == "clear":
|
if action == "clear":
|
||||||
session.pop("chat_history", None)
|
session.pop("chat_history", None)
|
||||||
session.pop("chat_system_prompt", None)
|
session.pop("chat_system_prompt", None)
|
||||||
session.pop("chat_model", None)
|
session.pop("chat_model", None)
|
||||||
return redirect(url_for("generate.text"))
|
return redirect(url_for("generate.text"))
|
||||||
|
|
||||||
prompt = request.form.get("prompt", "").strip()
|
prompt = request.form.get("prompt", "").strip()
|
||||||
model = request.form.get("model", "").strip()
|
model = request.form.get("model", "").strip()
|
||||||
system_prompt = request.form.get("system_prompt", "").strip()
|
system_prompt = request.form.get("system_prompt", "").strip()
|
||||||
|
|
||||||
session["chat_model"] = model
|
session["chat_model"] = model
|
||||||
session["chat_system_prompt"] = system_prompt
|
session["chat_system_prompt"] = system_prompt
|
||||||
|
|
||||||
if prompt:
|
if prompt:
|
||||||
messages = [m for m in chat_history if m["role"]
|
messages = [m for m in chat_history if m["role"]
|
||||||
in ("user", "assistant")]
|
in ("user", "assistant")]
|
||||||
messages.append({"role": "user", "content": prompt})
|
messages.append({"role": "user", "content": prompt})
|
||||||
|
|
||||||
payload: dict = {
|
payload: dict = {
|
||||||
"model": model,
|
"model": model,
|
||||||
"messages": [{"role": m["role"], "content": m["content"]} for m in messages],
|
"messages": [{"role": m["role"], "content": m["content"]} for m in messages],
|
||||||
}
|
}
|
||||||
if system_prompt:
|
if system_prompt:
|
||||||
payload["system_prompt"] = system_prompt
|
payload["system_prompt"] = system_prompt
|
||||||
|
|
||||||
resp = _api("POST", "/generate/text", token=token, json=payload)
|
resp = _api("POST", "/generate/text", token=token, json=payload)
|
||||||
if resp.status_code == 200:
|
if resp.status_code == 200:
|
||||||
data = resp.json()
|
data = resp.json()
|
||||||
chat_history = list(messages)
|
chat_history = list(messages)
|
||||||
chat_history.append({"role": "assistant", "content": data["content"],
|
chat_history.append({"role": "assistant", "content": data["content"],
|
||||||
"usage": data.get("usage")})
|
"usage": data.get("usage")})
|
||||||
session["chat_history"] = chat_history
|
session["chat_history"] = chat_history
|
||||||
else:
|
else:
|
||||||
try:
|
try:
|
||||||
error = resp.json().get("detail", "Generation failed.")
|
error = resp.json().get("detail", "Generation failed.")
|
||||||
except Exception:
|
except Exception:
|
||||||
error = "Generation failed."
|
error = "Generation failed."
|
||||||
|
|
||||||
models = _load_models(token, "text")
|
models = _load_models(token, "text")
|
||||||
return render_template(
|
return render_template(
|
||||||
"generate_text.html",
|
"generate_text.html",
|
||||||
chat_history=session.get("chat_history", []),
|
chat_history=session.get("chat_history", []),
|
||||||
error=error,
|
error=error,
|
||||||
models=models,
|
models=models,
|
||||||
system_prompt=system_prompt,
|
system_prompt=system_prompt,
|
||||||
current_model=model,
|
current_model=model,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@generate_bp.route("/generate/image", methods=["GET", "POST"])
|
@generate_bp.route("/generate/image", methods=["GET", "POST"])
|
||||||
@login_required
|
@login_required
|
||||||
def image():
|
def image():
|
||||||
result = error = None
|
result = error = None
|
||||||
token = session["access_token"]
|
token = session["access_token"]
|
||||||
if request.method == "POST":
|
if request.method == "POST":
|
||||||
try:
|
try:
|
||||||
ref_file = request.files.get("reference_image")
|
ref_file = request.files.get("reference_image")
|
||||||
if ref_file and ref_file.filename:
|
if ref_file and ref_file.filename:
|
||||||
up_resp = _api(
|
up_resp = _api(
|
||||||
"POST", "/images/upload",
|
"POST", "/images/upload",
|
||||||
token=token,
|
token=token,
|
||||||
files={"file": (ref_file.filename,
|
files={"file": (ref_file.filename,
|
||||||
ref_file.stream, ref_file.content_type)},
|
ref_file.stream, ref_file.content_type)},
|
||||||
)
|
)
|
||||||
if up_resp.status_code not in (200, 201):
|
if up_resp.status_code not in (200, 201):
|
||||||
error = _detail_or_default(up_resp, "Image upload failed.")
|
error = _detail_or_default(up_resp, "Image upload failed.")
|
||||||
models = _load_models(token, "image")
|
models = _load_models(token, "image")
|
||||||
return render_template("generate_image.html", result=result, error=error, models=models)
|
return render_template("generate_image.html", result=result, error=error, models=models)
|
||||||
|
|
||||||
resp = _api("POST", "/generate/image", token=token, timeout=120, json={
|
resp = _api("POST", "/generate/image", token=token, timeout=120, json={
|
||||||
"model": request.form.get("model", "").strip(),
|
"model": request.form.get("model", "").strip(),
|
||||||
"prompt": request.form.get("prompt", "").strip(),
|
"prompt": request.form.get("prompt", "").strip(),
|
||||||
"n": int(request.form.get("n", 1)),
|
"n": int(request.form.get("n", 1)),
|
||||||
"size": request.form.get("size", "1024x1024"),
|
"size": request.form.get("size", "1024x1024"),
|
||||||
"aspect_ratio": request.form.get("aspect_ratio", "").strip() or None,
|
"aspect_ratio": request.form.get("aspect_ratio", "").strip() or None,
|
||||||
"image_size": request.form.get("image_size", "").strip() or None,
|
"image_size": request.form.get("image_size", "").strip() or None,
|
||||||
})
|
})
|
||||||
if resp.status_code == 200:
|
if resp.status_code == 200:
|
||||||
result = resp.json()
|
result = resp.json()
|
||||||
else:
|
else:
|
||||||
error = _detail_or_default(resp, "Generation failed.")
|
error = _detail_or_default(resp, "Generation failed.")
|
||||||
except httpx.TimeoutException:
|
except httpx.TimeoutException:
|
||||||
error = "Image generation timed out. Please try again."
|
error = "Image generation timed out. Please try again."
|
||||||
except httpx.RequestError:
|
except httpx.RequestError:
|
||||||
error = "Cannot reach generation service. Please try again."
|
error = "Cannot reach generation service. Please try again."
|
||||||
models = _load_models(token, "image")
|
models = _load_models(token, "image")
|
||||||
return render_template("generate_image.html", result=result, error=error, models=models)
|
return render_template("generate_image.html", result=result, error=error, models=models)
|
||||||
|
|
||||||
|
|
||||||
@generate_bp.route("/generate/video", methods=["GET", "POST"])
|
@generate_bp.route("/generate/video", methods=["GET", "POST"])
|
||||||
@login_required
|
@login_required
|
||||||
def video():
|
def video():
|
||||||
error = None
|
error = None
|
||||||
token = session["access_token"]
|
token = session["access_token"]
|
||||||
if request.method == "POST":
|
if request.method == "POST":
|
||||||
mode = request.form.get("mode", "text")
|
mode = request.form.get("mode", "text")
|
||||||
duration_raw = request.form.get("duration_seconds", "")
|
duration_raw = request.form.get("duration_seconds", "")
|
||||||
duration = int(
|
duration = int(
|
||||||
duration_raw) if duration_raw.strip().isdigit() else None
|
duration_raw) if duration_raw.strip().isdigit() else None
|
||||||
resolution = request.form.get("resolution", "").strip() or None
|
resolution = request.form.get("resolution", "").strip() or None
|
||||||
|
|
||||||
if mode == "image":
|
if mode == "image":
|
||||||
resp = _api("POST", "/generate/video/from-image", token=token, json={
|
resp = _api("POST", "/generate/video/from-image", token=token, json={
|
||||||
"model": request.form.get("model", "").strip(),
|
"model": request.form.get("model", "").strip(),
|
||||||
"image_url": request.form.get("image_url", "").strip(),
|
"image_url": request.form.get("image_url", "").strip(),
|
||||||
"prompt": request.form.get("prompt", "").strip(),
|
"prompt": request.form.get("prompt", "").strip(),
|
||||||
"aspect_ratio": request.form.get("aspect_ratio", "16:9"),
|
"aspect_ratio": request.form.get("aspect_ratio", "16:9"),
|
||||||
"duration_seconds": duration,
|
"duration_seconds": duration,
|
||||||
"resolution": resolution,
|
"resolution": resolution,
|
||||||
})
|
})
|
||||||
else:
|
else:
|
||||||
resp = _api("POST", "/generate/video", token=token, json={
|
resp = _api("POST", "/generate/video", token=token, json={
|
||||||
"model": request.form.get("model", "").strip(),
|
"model": request.form.get("model", "").strip(),
|
||||||
"prompt": request.form.get("prompt", "").strip(),
|
"prompt": request.form.get("prompt", "").strip(),
|
||||||
"aspect_ratio": request.form.get("aspect_ratio", "16:9"),
|
"aspect_ratio": request.form.get("aspect_ratio", "16:9"),
|
||||||
"duration_seconds": duration,
|
"duration_seconds": duration,
|
||||||
"resolution": resolution,
|
"resolution": resolution,
|
||||||
})
|
})
|
||||||
|
|
||||||
if resp.status_code == 200:
|
if resp.status_code == 200:
|
||||||
result = resp.json()
|
result = resp.json()
|
||||||
db_id = result.get("db_id")
|
db_id = result.get("db_id")
|
||||||
if db_id:
|
if db_id:
|
||||||
return redirect(url_for("gallery.video_detail", video_id=db_id))
|
return redirect(url_for("gallery.video_detail", video_id=db_id))
|
||||||
flash("Video job started.", "success")
|
flash("Video job started.", "success")
|
||||||
return redirect(url_for("gallery.index"))
|
return redirect(url_for("gallery.index"))
|
||||||
else:
|
else:
|
||||||
error = resp.json().get("detail", "Generation failed.")
|
error = resp.json().get("detail", "Generation failed.")
|
||||||
|
|
||||||
models = _load_models(token, "video")
|
models = _load_models(token, "video")
|
||||||
return render_template("generate_video.html", error=error, models=models)
|
return render_template("generate_video.html", error=error, models=models)
|
||||||
|
|
||||||
|
|
||||||
@generate_bp.get("/generate/video/status")
|
@generate_bp.get("/generate/video/status")
|
||||||
@login_required
|
@login_required
|
||||||
def video_status():
|
def video_status():
|
||||||
"""Proxy video status polling to the backend."""
|
"""Proxy video status polling to the backend."""
|
||||||
polling_url = request.args.get("polling_url", "")
|
polling_url = request.args.get("polling_url", "")
|
||||||
if not polling_url:
|
if not polling_url:
|
||||||
return jsonify({"error": "polling_url required"}), 400
|
return jsonify({"error": "polling_url required"}), 400
|
||||||
resp = _api(
|
resp = _api(
|
||||||
"GET", "/generate/video/status",
|
"GET", "/generate/video/status",
|
||||||
token=session["access_token"],
|
token=session["access_token"],
|
||||||
params={"polling_url": polling_url},
|
params={"polling_url": polling_url},
|
||||||
)
|
)
|
||||||
return jsonify(resp.json()), resp.status_code
|
return jsonify(resp.json()), resp.status_code
|
||||||
|
|
||||||
|
|
||||||
@generate_bp.get("/generate/video/<video_id>/status")
|
@generate_bp.get("/generate/video/<video_id>/status")
|
||||||
@login_required
|
@login_required
|
||||||
def video_db_status(video_id: str):
|
def video_db_status(video_id: str):
|
||||||
"""Return current DB status for a video job (polled by frontend JS)."""
|
"""Return current DB status for a video job (polled by frontend JS)."""
|
||||||
resp = _api(
|
resp = _api(
|
||||||
"GET", f"/generate/videos/{video_id}", token=session["access_token"])
|
"GET", f"/generate/videos/{video_id}", token=session["access_token"])
|
||||||
return jsonify(resp.json()), resp.status_code
|
return jsonify(resp.json()), resp.status_code
|
||||||
|
|
||||||
|
|
||||||
@generate_bp.post("/generate/video/<video_id>/cancel")
|
@generate_bp.post("/generate/video/<video_id>/cancel")
|
||||||
@login_required
|
@login_required
|
||||||
def cancel_video_job(video_id: str):
|
def cancel_video_job(video_id: str):
|
||||||
"""Proxy cancel request to backend."""
|
"""Proxy cancel request to backend."""
|
||||||
resp = _api(
|
resp = _api(
|
||||||
"POST", f"/generate/videos/{video_id}/cancel", token=session["access_token"])
|
"POST", f"/generate/videos/{video_id}/cancel", token=session["access_token"])
|
||||||
return jsonify(resp.json()), resp.status_code
|
return jsonify(resp.json()), resp.status_code
|
||||||
@@ -1,33 +1,33 @@
|
|||||||
"""Profile blueprint."""
|
"""Profile blueprint."""
|
||||||
from flask import Blueprint, flash, redirect, render_template, request, session, url_for
|
from flask import Blueprint, flash, redirect, render_template, request, session, url_for
|
||||||
|
|
||||||
from ..helpers import _api, login_required
|
from ..helpers import _api, login_required
|
||||||
|
|
||||||
profile_bp = Blueprint("profile", __name__)
|
profile_bp = Blueprint("profile", __name__)
|
||||||
|
|
||||||
|
|
||||||
@profile_bp.route("/users/profile", methods=["GET", "POST"])
|
@profile_bp.route("/users/profile", methods=["GET", "POST"])
|
||||||
@login_required
|
@login_required
|
||||||
def index():
|
def index():
|
||||||
token = session["access_token"]
|
token = session["access_token"]
|
||||||
if request.method == "POST":
|
if request.method == "POST":
|
||||||
payload: dict = {}
|
payload: dict = {}
|
||||||
new_email = request.form.get("email", "").strip()
|
new_email = request.form.get("email", "").strip()
|
||||||
new_password = request.form.get("password", "").strip()
|
new_password = request.form.get("password", "").strip()
|
||||||
if new_email:
|
if new_email:
|
||||||
payload["email"] = new_email
|
payload["email"] = new_email
|
||||||
if new_password:
|
if new_password:
|
||||||
payload["password"] = new_password
|
payload["password"] = new_password
|
||||||
if payload:
|
if payload:
|
||||||
resp = _api("PUT", "/users/me", token=token, json=payload)
|
resp = _api("PUT", "/users/me", token=token, json=payload)
|
||||||
if resp.status_code == 200:
|
if resp.status_code == 200:
|
||||||
updated = resp.json()
|
updated = resp.json()
|
||||||
session["user_email"] = updated.get(
|
session["user_email"] = updated.get(
|
||||||
"email", session.get("user_email", ""))
|
"email", session.get("user_email", ""))
|
||||||
flash("Profile updated.", "success")
|
flash("Profile updated.", "success")
|
||||||
else:
|
else:
|
||||||
flash(resp.json().get("detail", "Update failed."), "error")
|
flash(resp.json().get("detail", "Update failed."), "error")
|
||||||
return redirect(url_for("profile.index"))
|
return redirect(url_for("profile.index"))
|
||||||
resp = _api("GET", "/users/me", token=token)
|
resp = _api("GET", "/users/me", token=token)
|
||||||
user = resp.json() if resp.status_code == 200 else {}
|
user = resp.json() if resp.status_code == 200 else {}
|
||||||
return render_template("profile.html", user=user)
|
return render_template("profile.html", user=user)
|
||||||
+174
-174
@@ -1,174 +1,174 @@
|
|||||||
document.addEventListener("DOMContentLoaded", () => {
|
document.addEventListener("DOMContentLoaded", () => {
|
||||||
// ── Loading overlay ────────────────────────────────────
|
// ── Loading overlay ────────────────────────────────────
|
||||||
const overlay = document.getElementById("loading-overlay");
|
const overlay = document.getElementById("loading-overlay");
|
||||||
|
|
||||||
document.querySelectorAll("form").forEach((form) => {
|
document.querySelectorAll("form").forEach((form) => {
|
||||||
form.addEventListener("submit", () => {
|
form.addEventListener("submit", () => {
|
||||||
if (overlay) overlay.classList.add("active");
|
if (overlay) overlay.classList.add("active");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Hamburger menu ─────────────────────────────────────
|
// ── Hamburger menu ─────────────────────────────────────
|
||||||
const hamburger = document.querySelector(".hamburger");
|
const hamburger = document.querySelector(".hamburger");
|
||||||
const navLinks = document.querySelector(".nav-links");
|
const navLinks = document.querySelector(".nav-links");
|
||||||
|
|
||||||
if (hamburger && navLinks) {
|
if (hamburger && navLinks) {
|
||||||
hamburger.addEventListener("click", () => {
|
hamburger.addEventListener("click", () => {
|
||||||
navLinks.classList.toggle("open");
|
navLinks.classList.toggle("open");
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Image upload preview ───────────────────────────────
|
// ── Image upload preview ───────────────────────────────
|
||||||
const imageInput = document.getElementById("reference_image");
|
const imageInput = document.getElementById("reference_image");
|
||||||
const imagePreviewWrap = document.getElementById("image-upload-preview");
|
const imagePreviewWrap = document.getElementById("image-upload-preview");
|
||||||
const imagePreview = document.getElementById("image-upload-preview-img");
|
const imagePreview = document.getElementById("image-upload-preview-img");
|
||||||
const imageFilename = document.getElementById("image-upload-filename");
|
const imageFilename = document.getElementById("image-upload-filename");
|
||||||
|
|
||||||
if (imageInput && imagePreviewWrap && imagePreview && imageFilename) {
|
if (imageInput && imagePreviewWrap && imagePreview && imageFilename) {
|
||||||
imageInput.addEventListener("change", () => {
|
imageInput.addEventListener("change", () => {
|
||||||
const file = imageInput.files && imageInput.files[0];
|
const file = imageInput.files && imageInput.files[0];
|
||||||
if (!file) {
|
if (!file) {
|
||||||
imagePreviewWrap.hidden = true;
|
imagePreviewWrap.hidden = true;
|
||||||
imagePreview.removeAttribute("src");
|
imagePreview.removeAttribute("src");
|
||||||
imageFilename.textContent = "";
|
imageFilename.textContent = "";
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
imagePreview.src = URL.createObjectURL(file);
|
imagePreview.src = URL.createObjectURL(file);
|
||||||
imageFilename.textContent = file.name;
|
imageFilename.textContent = file.name;
|
||||||
imagePreviewWrap.hidden = false;
|
imagePreviewWrap.hidden = false;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Generate dropdown tabs ─────────────────────────────
|
// ── Generate dropdown tabs ─────────────────────────────
|
||||||
document.querySelectorAll(".tab-btn").forEach((btn) => {
|
document.querySelectorAll(".tab-btn").forEach((btn) => {
|
||||||
btn.addEventListener("click", () => {
|
btn.addEventListener("click", () => {
|
||||||
const target = btn.dataset.tab;
|
const target = btn.dataset.tab;
|
||||||
const container = btn.closest(".tabs-container");
|
const container = btn.closest(".tabs-container");
|
||||||
if (!container) return;
|
if (!container) return;
|
||||||
|
|
||||||
container
|
container
|
||||||
.querySelectorAll(".tab-btn")
|
.querySelectorAll(".tab-btn")
|
||||||
.forEach((b) => b.classList.remove("active"));
|
.forEach((b) => b.classList.remove("active"));
|
||||||
container
|
container
|
||||||
.querySelectorAll(".tab-panel")
|
.querySelectorAll(".tab-panel")
|
||||||
.forEach((p) => p.classList.remove("active"));
|
.forEach((p) => p.classList.remove("active"));
|
||||||
|
|
||||||
btn.classList.add("active");
|
btn.classList.add("active");
|
||||||
const panel = container.querySelector(`#tab-${target}`);
|
const panel = container.querySelector(`#tab-${target}`);
|
||||||
if (panel) panel.classList.add("active");
|
if (panel) panel.classList.add("active");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Video status polling ───────────────────────────────
|
// ── Video status polling ───────────────────────────────
|
||||||
const pollDiv = document.getElementById("video-poll-status");
|
const pollDiv = document.getElementById("video-poll-status");
|
||||||
if (pollDiv) {
|
if (pollDiv) {
|
||||||
const videoId = pollDiv.dataset.videoId;
|
const videoId = pollDiv.dataset.videoId;
|
||||||
const statusText = document.getElementById("poll-status-text");
|
const statusText = document.getElementById("poll-status-text");
|
||||||
const videoContainer = document.getElementById("poll-video-container");
|
const videoContainer = document.getElementById("poll-video-container");
|
||||||
const cancelBtn = document.getElementById("cancel-video-btn");
|
const cancelBtn = document.getElementById("cancel-video-btn");
|
||||||
const cancelMsg = document.getElementById("cancel-msg");
|
const cancelMsg = document.getElementById("cancel-msg");
|
||||||
const MAX_POLLS = 120; // ~10 minutes at 5s interval
|
const MAX_POLLS = 120; // ~10 minutes at 5s interval
|
||||||
let pollCount = 0;
|
let pollCount = 0;
|
||||||
let interval = null;
|
let interval = null;
|
||||||
|
|
||||||
const stopPolling = () => {
|
const stopPolling = () => {
|
||||||
if (interval) {
|
if (interval) {
|
||||||
clearInterval(interval);
|
clearInterval(interval);
|
||||||
interval = null;
|
interval = null;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (cancelBtn) {
|
if (cancelBtn) {
|
||||||
cancelBtn.addEventListener("click", async () => {
|
cancelBtn.addEventListener("click", async () => {
|
||||||
cancelBtn.disabled = true;
|
cancelBtn.disabled = true;
|
||||||
cancelBtn.textContent = "Cancelling…";
|
cancelBtn.textContent = "Cancelling…";
|
||||||
try {
|
try {
|
||||||
const resp = await fetch(
|
const resp = await fetch(
|
||||||
"/generate/video/" + encodeURIComponent(videoId) + "/cancel",
|
"/generate/video/" + encodeURIComponent(videoId) + "/cancel",
|
||||||
{ method: "POST" },
|
{ method: "POST" },
|
||||||
);
|
);
|
||||||
if (resp.ok) {
|
if (resp.ok) {
|
||||||
stopPolling();
|
stopPolling();
|
||||||
cancelBtn.classList.add("hidden");
|
cancelBtn.classList.add("hidden");
|
||||||
if (cancelMsg) {
|
if (cancelMsg) {
|
||||||
cancelMsg.textContent = "Job cancelled.";
|
cancelMsg.textContent = "Job cancelled.";
|
||||||
cancelMsg.classList.remove("hidden", "text-red-500");
|
cancelMsg.classList.remove("hidden", "text-red-500");
|
||||||
cancelMsg.classList.add("text-gray-300");
|
cancelMsg.classList.add("text-gray-300");
|
||||||
}
|
}
|
||||||
if (statusText) {
|
if (statusText) {
|
||||||
statusText.innerHTML = "Status: <strong>cancelled</strong>";
|
statusText.innerHTML = "Status: <strong>cancelled</strong>";
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const data = await resp.json().catch(() => ({}));
|
const data = await resp.json().catch(() => ({}));
|
||||||
cancelBtn.disabled = false;
|
cancelBtn.disabled = false;
|
||||||
cancelBtn.textContent = "Cancel Job";
|
cancelBtn.textContent = "Cancel Job";
|
||||||
if (cancelMsg) {
|
if (cancelMsg) {
|
||||||
cancelMsg.textContent = data.detail || "Cancel failed.";
|
cancelMsg.textContent = data.detail || "Cancel failed.";
|
||||||
cancelMsg.classList.remove("hidden");
|
cancelMsg.classList.remove("hidden");
|
||||||
cancelMsg.classList.add("text-red-500");
|
cancelMsg.classList.add("text-red-500");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
cancelBtn.disabled = false;
|
cancelBtn.disabled = false;
|
||||||
cancelBtn.textContent = "Cancel Job";
|
cancelBtn.textContent = "Cancel Job";
|
||||||
if (cancelMsg) {
|
if (cancelMsg) {
|
||||||
cancelMsg.textContent = "Network error.";
|
cancelMsg.textContent = "Network error.";
|
||||||
cancelMsg.classList.remove("hidden");
|
cancelMsg.classList.remove("hidden");
|
||||||
cancelMsg.classList.add("text-red-500");
|
cancelMsg.classList.add("text-red-500");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
interval = setInterval(async () => {
|
interval = setInterval(async () => {
|
||||||
try {
|
try {
|
||||||
pollCount++;
|
pollCount++;
|
||||||
if (pollCount > MAX_POLLS) {
|
if (pollCount > MAX_POLLS) {
|
||||||
stopPolling();
|
stopPolling();
|
||||||
pollDiv.innerHTML =
|
pollDiv.innerHTML =
|
||||||
'<div class="alert alert-warning">Polling timed out. Please refresh the page to check status.</div>';
|
'<div class="alert alert-warning">Polling timed out. Please refresh the page to check status.</div>';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const resp = await fetch(
|
const resp = await fetch(
|
||||||
"/generate/video/" + encodeURIComponent(videoId) + "/status",
|
"/generate/video/" + encodeURIComponent(videoId) + "/status",
|
||||||
);
|
);
|
||||||
if (!resp.ok) return;
|
if (!resp.ok) return;
|
||||||
const data = await resp.json();
|
const data = await resp.json();
|
||||||
|
|
||||||
if (statusText) {
|
if (statusText) {
|
||||||
statusText.innerHTML = "Status: <strong>" + data.status + "</strong>";
|
statusText.innerHTML = "Status: <strong>" + data.status + "</strong>";
|
||||||
}
|
}
|
||||||
|
|
||||||
if (data.status === "completed") {
|
if (data.status === "completed") {
|
||||||
stopPolling();
|
stopPolling();
|
||||||
if (data.video_url) {
|
if (data.video_url) {
|
||||||
if (videoContainer) {
|
if (videoContainer) {
|
||||||
const vid = document.createElement("video");
|
const vid = document.createElement("video");
|
||||||
vid.src = data.video_url;
|
vid.src = data.video_url;
|
||||||
vid.controls = true;
|
vid.controls = true;
|
||||||
vid.className = "generated-video";
|
vid.className = "generated-video";
|
||||||
videoContainer.appendChild(vid);
|
videoContainer.appendChild(vid);
|
||||||
const msg = pollDiv.querySelector("p");
|
const msg = pollDiv.querySelector("p");
|
||||||
if (msg) msg.textContent = "Video ready!";
|
if (msg) msg.textContent = "Video ready!";
|
||||||
} else {
|
} else {
|
||||||
// video_detail page: reload to show the video element
|
// video_detail page: reload to show the video element
|
||||||
window.location.reload();
|
window.location.reload();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (data.status === "failed") {
|
} else if (data.status === "failed") {
|
||||||
stopPolling();
|
stopPolling();
|
||||||
pollDiv.innerHTML =
|
pollDiv.innerHTML =
|
||||||
'<div class="alert alert-error">Generation failed.</div>';
|
'<div class="alert alert-error">Generation failed.</div>';
|
||||||
} else if (data.status === "cancelled") {
|
} else if (data.status === "cancelled") {
|
||||||
stopPolling();
|
stopPolling();
|
||||||
if (cancelBtn) cancelBtn.classList.add("hidden");
|
if (cancelBtn) cancelBtn.classList.add("hidden");
|
||||||
pollDiv.innerHTML =
|
pollDiv.innerHTML =
|
||||||
'<div class="alert alert-info">Job was cancelled.</div>';
|
'<div class="alert alert-info">Job was cancelled.</div>';
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Video polling error:", e);
|
console.error("Video polling error:", e);
|
||||||
}
|
}
|
||||||
}, 5000);
|
}, 5000);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
+817
-817
File diff suppressed because it is too large.
Load diff
+279
-279
@@ -1,279 +1,279 @@
|
|||||||
{% extends "base.html" %} {% block title %}Admin — All You Can GET AI{% endblock
|
{% extends "base.html" %} {% block title %}Admin — All You Can GET AI{% endblock
|
||||||
%} {% block content %}
|
%} {% block content %}
|
||||||
<div class="card admin-page">
|
<div class="card admin-page">
|
||||||
<h1>Admin Dashboard</h1>
|
<h1>Admin Dashboard</h1>
|
||||||
|
|
||||||
{% if stats %}
|
{% if stats %}
|
||||||
<div class="stats-grid">
|
<div class="stats-grid">
|
||||||
<div class="stat-box">
|
<div class="stat-box">
|
||||||
<div class="stat-label">Total users</div>
|
<div class="stat-label">Total users</div>
|
||||||
<div class="stat-value">{{ stats.get('total_users', 0) }}</div>
|
<div class="stat-value">{{ stats.get('total_users', 0) }}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-box">
|
<div class="stat-box">
|
||||||
<div class="stat-label">Active tokens</div>
|
<div class="stat-label">Active tokens</div>
|
||||||
<div class="stat-value">{{ stats.get('active_refresh_tokens', 0) }}</div>
|
<div class="stat-value">{{ stats.get('active_refresh_tokens', 0) }}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-box">
|
<div class="stat-box">
|
||||||
<div class="stat-label">Admins</div>
|
<div class="stat-label">Admins</div>
|
||||||
<div class="stat-value">{{ stats.get('admin_users', 0) }}</div>
|
<div class="stat-value">{{ stats.get('admin_users', 0) }}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<h2 class="section-title">Users</h2>
|
<h2 class="section-title">Users</h2>
|
||||||
<div class="table-wrap">
|
<div class="table-wrap">
|
||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Email</th>
|
<th>Email</th>
|
||||||
<th>Role</th>
|
<th>Role</th>
|
||||||
<th>Actions</th>
|
<th>Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{% for u in users %}
|
{% for u in users %}
|
||||||
<tr>
|
<tr>
|
||||||
<td>{{ u.email }}</td>
|
<td>{{ u.email }}</td>
|
||||||
<td>
|
<td>
|
||||||
<span class="role-badge role-{{ u.role }}">{{ u.role }}</span>
|
<span class="role-badge role-{{ u.role }}">{{ u.role }}</span>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<div class="table-actions">
|
<div class="table-actions">
|
||||||
<!-- Role toggle -->
|
<!-- Role toggle -->
|
||||||
<form
|
<form
|
||||||
method="post"
|
method="post"
|
||||||
action="{{ url_for('admin.set_role', user_id=u.id) }}"
|
action="{{ url_for('admin.set_role', user_id=u.id) }}"
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
type="hidden"
|
type="hidden"
|
||||||
name="role"
|
name="role"
|
||||||
value="{{ 'user' if u.role == 'admin' else 'admin' }}"
|
value="{{ 'user' if u.role == 'admin' else 'admin' }}"
|
||||||
/>
|
/>
|
||||||
<button type="submit" class="btn btn-sm">
|
<button type="submit" class="btn btn-sm">
|
||||||
Make {{ 'user' if u.role == 'admin' else 'admin' }}
|
Make {{ 'user' if u.role == 'admin' else 'admin' }}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
<!-- Delete -->
|
<!-- Delete -->
|
||||||
{% if u.id != session.get('user_id') %}
|
{% if u.id != session.get('user_id') %}
|
||||||
<form
|
<form
|
||||||
method="post"
|
method="post"
|
||||||
action="{{ url_for('admin.delete_user', user_id=u.id) }}"
|
action="{{ url_for('admin.delete_user', user_id=u.id) }}"
|
||||||
onsubmit="return confirm('Delete {{ u.email }}?')"
|
onsubmit="return confirm('Delete {{ u.email }}?')"
|
||||||
>
|
>
|
||||||
<button type="submit" class="btn btn-sm btn-danger">
|
<button type="submit" class="btn btn-sm btn-danger">
|
||||||
Delete
|
Delete
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% else %}
|
{% else %}
|
||||||
<tr>
|
<tr>
|
||||||
<td colspan="3" class="text-muted">No users found.</td>
|
<td colspan="3" class="text-muted">No users found.</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ── Video Jobs ──────────────────────────────────────────────── -->
|
<!-- ── Video Jobs ──────────────────────────────────────────────── -->
|
||||||
<h2 class="section-title" style="margin-top: 2rem">Video Jobs</h2>
|
<h2 class="section-title" style="margin-top: 2rem">Video Jobs</h2>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
style="
|
style="
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
"
|
"
|
||||||
>
|
>
|
||||||
<label for="vj-status-filter" style="font-weight: 600"
|
<label for="vj-status-filter" style="font-weight: 600"
|
||||||
>Filter by status:</label
|
>Filter by status:</label
|
||||||
>
|
>
|
||||||
<select id="vj-status-filter" class="form-control" style="width: auto">
|
<select id="vj-status-filter" class="form-control" style="width: auto">
|
||||||
<option value="">All</option>
|
<option value="">All</option>
|
||||||
<option value="queued">Queued</option>
|
<option value="queued">Queued</option>
|
||||||
<option value="processing">Processing</option>
|
<option value="processing">Processing</option>
|
||||||
<option value="completed">Completed</option>
|
<option value="completed">Completed</option>
|
||||||
<option value="failed">Failed</option>
|
<option value="failed">Failed</option>
|
||||||
<option value="cancelled">Cancelled</option>
|
<option value="cancelled">Cancelled</option>
|
||||||
</select>
|
</select>
|
||||||
<label for="vj-sort" style="font-weight: 600">Sort:</label>
|
<label for="vj-sort" style="font-weight: 600">Sort:</label>
|
||||||
<select id="vj-sort" class="form-control" style="width: auto">
|
<select id="vj-sort" class="form-control" style="width: auto">
|
||||||
<option value="created_desc">Created (newest first)</option>
|
<option value="created_desc">Created (newest first)</option>
|
||||||
<option value="created_asc">Created (oldest first)</option>
|
<option value="created_asc">Created (oldest first)</option>
|
||||||
<option value="updated_desc">Updated (newest first)</option>
|
<option value="updated_desc">Updated (newest first)</option>
|
||||||
<option value="status_asc">Status (A–Z)</option>
|
<option value="status_asc">Status (A–Z)</option>
|
||||||
<option value="model_asc">Model (A–Z)</option>
|
<option value="model_asc">Model (A–Z)</option>
|
||||||
</select>
|
</select>
|
||||||
<button id="vj-refresh" class="btn btn-sm">Refresh</button>
|
<button id="vj-refresh" class="btn btn-sm">Refresh</button>
|
||||||
<span
|
<span
|
||||||
id="vj-count"
|
id="vj-count"
|
||||||
style="color: var(--text-muted, #888); font-size: 0.9em"
|
style="color: var(--text-muted, #888); font-size: 0.9em"
|
||||||
></span>
|
></span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="table-wrap">
|
<div class="table-wrap">
|
||||||
<table id="vj-table">
|
<table id="vj-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>User</th>
|
<th>User</th>
|
||||||
<th>Status</th>
|
<th>Status</th>
|
||||||
<th>Model</th>
|
<th>Model</th>
|
||||||
<th>Prompt</th>
|
<th>Prompt</th>
|
||||||
<th>Created</th>
|
<th>Created</th>
|
||||||
<th>Updated</th>
|
<th>Updated</th>
|
||||||
<th>Actions</th>
|
<th>Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody id="vj-tbody">
|
<tbody id="vj-tbody">
|
||||||
<tr>
|
<tr>
|
||||||
<td colspan="7" class="text-muted">Loading…</td>
|
<td colspan="7" class="text-muted">Loading…</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
(function () {
|
(function () {
|
||||||
let allJobs = [];
|
let allJobs = [];
|
||||||
|
|
||||||
async function loadJobs() {
|
async function loadJobs() {
|
||||||
document.getElementById("vj-tbody").innerHTML =
|
document.getElementById("vj-tbody").innerHTML =
|
||||||
'<tr><td colspan="7" class="text-muted">Loading…</td></tr>';
|
'<tr><td colspan="7" class="text-muted">Loading…</td></tr>';
|
||||||
try {
|
try {
|
||||||
const r = await fetch("/api/admin/videos");
|
const r = await fetch("/api/admin/videos");
|
||||||
if (!r.ok) throw new Error(await r.text());
|
if (!r.ok) throw new Error(await r.text());
|
||||||
allJobs = await r.json();
|
allJobs = await r.json();
|
||||||
renderJobs();
|
renderJobs();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
document.getElementById("vj-tbody").innerHTML =
|
document.getElementById("vj-tbody").innerHTML =
|
||||||
`<tr><td colspan="7" style="color:red;">Error: ${e.message}</td></tr>`;
|
`<tr><td colspan="7" style="color:red;">Error: ${e.message}</td></tr>`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderJobs() {
|
function renderJobs() {
|
||||||
const statusFilter = document.getElementById("vj-status-filter").value;
|
const statusFilter = document.getElementById("vj-status-filter").value;
|
||||||
const sort = document.getElementById("vj-sort").value;
|
const sort = document.getElementById("vj-sort").value;
|
||||||
|
|
||||||
let jobs = statusFilter
|
let jobs = statusFilter
|
||||||
? allJobs.filter((j) => j.status === statusFilter)
|
? allJobs.filter((j) => j.status === statusFilter)
|
||||||
: [...allJobs];
|
: [...allJobs];
|
||||||
|
|
||||||
jobs.sort((a, b) => {
|
jobs.sort((a, b) => {
|
||||||
if (sort === "created_asc")
|
if (sort === "created_asc")
|
||||||
return new Date(a.created_at) - new Date(b.created_at);
|
return new Date(a.created_at) - new Date(b.created_at);
|
||||||
if (sort === "updated_desc")
|
if (sort === "updated_desc")
|
||||||
return new Date(b.updated_at) - new Date(a.updated_at);
|
return new Date(b.updated_at) - new Date(a.updated_at);
|
||||||
if (sort === "status_asc") return a.status.localeCompare(b.status);
|
if (sort === "status_asc") return a.status.localeCompare(b.status);
|
||||||
if (sort === "model_asc") return a.model_id.localeCompare(b.model_id);
|
if (sort === "model_asc") return a.model_id.localeCompare(b.model_id);
|
||||||
return new Date(b.created_at) - new Date(a.created_at); // created_desc default
|
return new Date(b.created_at) - new Date(a.created_at); // created_desc default
|
||||||
});
|
});
|
||||||
|
|
||||||
document.getElementById("vj-count").textContent =
|
document.getElementById("vj-count").textContent =
|
||||||
`${jobs.length} job${jobs.length !== 1 ? "s" : ""}`;
|
`${jobs.length} job${jobs.length !== 1 ? "s" : ""}`;
|
||||||
|
|
||||||
const tbody = document.getElementById("vj-tbody");
|
const tbody = document.getElementById("vj-tbody");
|
||||||
if (jobs.length === 0) {
|
if (jobs.length === 0) {
|
||||||
tbody.innerHTML =
|
tbody.innerHTML =
|
||||||
'<tr><td colspan="7" class="text-muted">No jobs found.</td></tr>';
|
'<tr><td colspan="7" class="text-muted">No jobs found.</td></tr>';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const statusColor = {
|
const statusColor = {
|
||||||
completed: "color:var(--success-color,#4caf50)",
|
completed: "color:var(--success-color,#4caf50)",
|
||||||
failed: "color:var(--danger-color,#e53935)",
|
failed: "color:var(--danger-color,#e53935)",
|
||||||
cancelled: "color:var(--danger-color,#e53935)",
|
cancelled: "color:var(--danger-color,#e53935)",
|
||||||
processing: "color:var(--warning-color,#fb8c00)",
|
processing: "color:var(--warning-color,#fb8c00)",
|
||||||
queued: "color:var(--warning-color,#fb8c00)",
|
queued: "color:var(--warning-color,#fb8c00)",
|
||||||
};
|
};
|
||||||
|
|
||||||
tbody.innerHTML = jobs
|
tbody.innerHTML = jobs
|
||||||
.map((job) => {
|
.map((job) => {
|
||||||
const sc = statusColor[job.status] || "";
|
const sc = statusColor[job.status] || "";
|
||||||
const canRetry =
|
const canRetry =
|
||||||
job.status === "failed" || job.status === "cancelled";
|
job.status === "failed" || job.status === "cancelled";
|
||||||
const canCancel =
|
const canCancel =
|
||||||
job.status === "queued" || job.status === "processing";
|
job.status === "queued" || job.status === "processing";
|
||||||
const actions = [
|
const actions = [
|
||||||
canRetry
|
canRetry
|
||||||
? `<button class="btn btn-sm vj-retry" data-id="${job.id}">Retry</button>`
|
? `<button class="btn btn-sm vj-retry" data-id="${job.id}">Retry</button>`
|
||||||
: "",
|
: "",
|
||||||
canCancel
|
canCancel
|
||||||
? `<button class="btn btn-sm vj-cancel" data-id="${job.id}">Cancel</button>`
|
? `<button class="btn btn-sm vj-cancel" data-id="${job.id}">Cancel</button>`
|
||||||
: "",
|
: "",
|
||||||
`<button class="btn btn-sm btn-danger vj-delete" data-id="${job.id}">Delete</button>`,
|
`<button class="btn btn-sm btn-danger vj-delete" data-id="${job.id}">Delete</button>`,
|
||||||
].join(" ");
|
].join(" ");
|
||||||
const prompt =
|
const prompt =
|
||||||
job.prompt.length > 60 ? job.prompt.slice(0, 57) + "…" : job.prompt;
|
job.prompt.length > 60 ? job.prompt.slice(0, 57) + "…" : job.prompt;
|
||||||
const created = job.created_at
|
const created = job.created_at
|
||||||
? new Date(job.created_at).toLocaleString()
|
? new Date(job.created_at).toLocaleString()
|
||||||
: "—";
|
: "—";
|
||||||
const updated = job.updated_at
|
const updated = job.updated_at
|
||||||
? new Date(job.updated_at).toLocaleString()
|
? new Date(job.updated_at).toLocaleString()
|
||||||
: "—";
|
: "—";
|
||||||
return `<tr>
|
return `<tr>
|
||||||
<td>${job.user_email || "—"}</td>
|
<td>${job.user_email || "—"}</td>
|
||||||
<td style="${sc};font-weight:600;">${job.status}</td>
|
<td style="${sc};font-weight:600;">${job.status}</td>
|
||||||
<td style="font-size:.85em;">${job.model_id}</td>
|
<td style="font-size:.85em;">${job.model_id}</td>
|
||||||
<td title="${job.prompt.replace(/"/g, """)}">${prompt}</td>
|
<td title="${job.prompt.replace(/"/g, """)}">${prompt}</td>
|
||||||
<td style="white-space:nowrap;">${created}</td>
|
<td style="white-space:nowrap;">${created}</td>
|
||||||
<td style="white-space:nowrap;">${updated}</td>
|
<td style="white-space:nowrap;">${updated}</td>
|
||||||
<td style="white-space:nowrap;">${actions}</td>
|
<td style="white-space:nowrap;">${actions}</td>
|
||||||
</tr>`;
|
</tr>`;
|
||||||
})
|
})
|
||||||
.join("");
|
.join("");
|
||||||
}
|
}
|
||||||
|
|
||||||
async function apiPost(path) {
|
async function apiPost(path) {
|
||||||
const r = await fetch(path, { method: "POST" });
|
const r = await fetch(path, { method: "POST" });
|
||||||
if (!r.ok) {
|
if (!r.ok) {
|
||||||
const d = await r.json().catch(() => ({}));
|
const d = await r.json().catch(() => ({}));
|
||||||
throw new Error(d.detail || r.statusText);
|
throw new Error(d.detail || r.statusText);
|
||||||
}
|
}
|
||||||
return r.json();
|
return r.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function apiDelete(path) {
|
async function apiDelete(path) {
|
||||||
const r = await fetch(path, { method: "DELETE" });
|
const r = await fetch(path, { method: "DELETE" });
|
||||||
if (!r.ok) {
|
if (!r.ok) {
|
||||||
const d = await r.json().catch(() => ({}));
|
const d = await r.json().catch(() => ({}));
|
||||||
throw new Error(d.detail || r.statusText);
|
throw new Error(d.detail || r.statusText);
|
||||||
}
|
}
|
||||||
return r.json();
|
return r.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
document
|
document
|
||||||
.getElementById("vj-tbody")
|
.getElementById("vj-tbody")
|
||||||
.addEventListener("click", async function (e) {
|
.addEventListener("click", async function (e) {
|
||||||
const btn = e.target.closest("button");
|
const btn = e.target.closest("button");
|
||||||
if (!btn) return;
|
if (!btn) return;
|
||||||
const id = btn.dataset.id;
|
const id = btn.dataset.id;
|
||||||
try {
|
try {
|
||||||
if (btn.classList.contains("vj-retry"))
|
if (btn.classList.contains("vj-retry"))
|
||||||
await apiPost(`/api/admin/videos/${id}/retry`);
|
await apiPost(`/api/admin/videos/${id}/retry`);
|
||||||
if (btn.classList.contains("vj-cancel"))
|
if (btn.classList.contains("vj-cancel"))
|
||||||
await apiPost(`/api/admin/videos/${id}/cancel`);
|
await apiPost(`/api/admin/videos/${id}/cancel`);
|
||||||
if (btn.classList.contains("vj-delete")) {
|
if (btn.classList.contains("vj-delete")) {
|
||||||
if (!confirm("Permanently delete this video job?")) return;
|
if (!confirm("Permanently delete this video job?")) return;
|
||||||
await apiDelete(`/api/admin/videos/${id}`);
|
await apiDelete(`/api/admin/videos/${id}`);
|
||||||
}
|
}
|
||||||
await loadJobs();
|
await loadJobs();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
alert("Error: " + err.message);
|
alert("Error: " + err.message);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
document
|
document
|
||||||
.getElementById("vj-status-filter")
|
.getElementById("vj-status-filter")
|
||||||
.addEventListener("change", renderJobs);
|
.addEventListener("change", renderJobs);
|
||||||
document.getElementById("vj-sort").addEventListener("change", renderJobs);
|
document.getElementById("vj-sort").addEventListener("change", renderJobs);
|
||||||
document.getElementById("vj-refresh").addEventListener("click", loadJobs);
|
document.getElementById("vj-refresh").addEventListener("click", loadJobs);
|
||||||
|
|
||||||
loadJobs();
|
loadJobs();
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
@@ -1,154 +1,154 @@
|
|||||||
{% extends "base.html" %} {% block title %}Admin - Model Management{% endblock
|
{% extends "base.html" %} {% block title %}Admin - Model Management{% endblock
|
||||||
%} {% block content %}
|
%} {% block content %}
|
||||||
<div class="container mx-auto px-4 py-8">
|
<div class="container mx-auto px-4 py-8">
|
||||||
<h1 class="text-3xl font-bold mb-6">Admin: Model Management</h1>
|
<h1 class="text-3xl font-bold mb-6">Admin: Model Management</h1>
|
||||||
|
|
||||||
<!-- Cache Status -->
|
<!-- Cache Status -->
|
||||||
<div class="bg-gray-800 p-4 rounded-lg shadow-md mb-6">
|
<div class="bg-gray-800 p-4 rounded-lg shadow-md mb-6">
|
||||||
<h2 class="text-xl font-semibold mb-2">Cache Status</h2>
|
<h2 class="text-xl font-semibold mb-2">Cache Status</h2>
|
||||||
<div id="cache-status" class="grid grid-cols-2 gap-4">
|
<div id="cache-status" class="grid grid-cols-2 gap-4">
|
||||||
<p>
|
<p>
|
||||||
<strong>Last Updated:</strong> <span id="last-updated">Loading...</span>
|
<strong>Last Updated:</strong> <span id="last-updated">Loading...</span>
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
<strong>Model Count:</strong> <span id="model-count">Loading...</span>
|
<strong>Model Count:</strong> <span id="model-count">Loading...</span>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
id="refresh-button"
|
id="refresh-button"
|
||||||
class="mt-4 bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded"
|
class="mt-4 bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded"
|
||||||
>
|
>
|
||||||
Refresh Cache
|
Refresh Cache
|
||||||
</button>
|
</button>
|
||||||
<p id="refresh-status" class="mt-2 text-sm"></p>
|
<p id="refresh-status" class="mt-2 text-sm"></p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Model List -->
|
<!-- Model List -->
|
||||||
<div class="bg-gray-800 p-4 rounded-lg shadow-md">
|
<div class="bg-gray-800 p-4 rounded-lg shadow-md">
|
||||||
<h2 class="text-xl font-semibold mb-2">Available Models</h2>
|
<h2 class="text-xl font-semibold mb-2">Available Models</h2>
|
||||||
<table id="models-table" class="min-w-full divide-y divide-gray-700">
|
<table id="models-table" class="min-w-full divide-y divide-gray-700">
|
||||||
<thead class="bg-gray-700">
|
<thead class="bg-gray-700">
|
||||||
<tr>
|
<tr>
|
||||||
<th
|
<th
|
||||||
scope="col"
|
scope="col"
|
||||||
class="px-6 py-3 text-left text-xs font-medium text-gray-300 uppercase tracking-wider"
|
class="px-6 py-3 text-left text-xs font-medium text-gray-300 uppercase tracking-wider"
|
||||||
>
|
>
|
||||||
Name
|
Name
|
||||||
</th>
|
</th>
|
||||||
<th
|
<th
|
||||||
scope="col"
|
scope="col"
|
||||||
class="px-6 py-3 text-left text-xs font-medium text-gray-300 uppercase tracking-wider"
|
class="px-6 py-3 text-left text-xs font-medium text-gray-300 uppercase tracking-wider"
|
||||||
>
|
>
|
||||||
ID
|
ID
|
||||||
</th>
|
</th>
|
||||||
<th
|
<th
|
||||||
scope="col"
|
scope="col"
|
||||||
class="px-6 py-3 text-left text-xs font-medium text-gray-300 uppercase tracking-wider"
|
class="px-6 py-3 text-left text-xs font-medium text-gray-300 uppercase tracking-wider"
|
||||||
>
|
>
|
||||||
Modality
|
Modality
|
||||||
</th>
|
</th>
|
||||||
<th
|
<th
|
||||||
scope="col"
|
scope="col"
|
||||||
class="px-6 py-3 text-left text-xs font-medium text-gray-300 uppercase tracking-wider"
|
class="px-6 py-3 text-left text-xs font-medium text-gray-300 uppercase tracking-wider"
|
||||||
>
|
>
|
||||||
Context Length
|
Context Length
|
||||||
</th>
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody
|
<tbody
|
||||||
id="models-table-body"
|
id="models-table-body"
|
||||||
class="bg-gray-800 divide-y divide-gray-700"
|
class="bg-gray-800 divide-y divide-gray-700"
|
||||||
>
|
>
|
||||||
<!-- Data will be populated by JavaScript -->
|
<!-- Data will be populated by JavaScript -->
|
||||||
<tr>
|
<tr>
|
||||||
<td colspan="4" class="text-center py-4">Loading models...</td>
|
<td colspan="4" class="text-center py-4">Loading models...</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
document.addEventListener("DOMContentLoaded", function () {
|
document.addEventListener("DOMContentLoaded", function () {
|
||||||
const lastUpdatedEl = document.getElementById("last-updated");
|
const lastUpdatedEl = document.getElementById("last-updated");
|
||||||
const modelCountEl = document.getElementById("model-count");
|
const modelCountEl = document.getElementById("model-count");
|
||||||
const modelsTableBody = document.getElementById("models-table-body");
|
const modelsTableBody = document.getElementById("models-table-body");
|
||||||
const refreshButton = document.getElementById("refresh-button");
|
const refreshButton = document.getElementById("refresh-button");
|
||||||
const refreshStatus = document.getElementById("refresh-status");
|
const refreshStatus = document.getElementById("refresh-status");
|
||||||
|
|
||||||
async function fetchCacheStatus() {
|
async function fetchCacheStatus() {
|
||||||
try {
|
try {
|
||||||
const response = await fetch("/api/v1/admin/models/status");
|
const response = await fetch("/api/v1/admin/models/status");
|
||||||
if (!response.ok) throw new Error("Failed to fetch status");
|
if (!response.ok) throw new Error("Failed to fetch status");
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
lastUpdatedEl.textContent = data.last_updated
|
lastUpdatedEl.textContent = data.last_updated
|
||||||
? new Date(data.last_updated).toLocaleString()
|
? new Date(data.last_updated).toLocaleString()
|
||||||
: "Never";
|
: "Never";
|
||||||
modelCountEl.textContent = data.model_count;
|
modelCountEl.textContent = data.model_count;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
lastUpdatedEl.textContent = "Error";
|
lastUpdatedEl.textContent = "Error";
|
||||||
modelCountEl.textContent = "Error";
|
modelCountEl.textContent = "Error";
|
||||||
console.error("Error fetching cache status:", error);
|
console.error("Error fetching cache status:", error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchModels() {
|
async function fetchModels() {
|
||||||
try {
|
try {
|
||||||
const response = await fetch("/api/v1/admin/models");
|
const response = await fetch("/api/v1/admin/models");
|
||||||
if (!response.ok) throw new Error("Failed to fetch models");
|
if (!response.ok) throw new Error("Failed to fetch models");
|
||||||
const models = await response.json();
|
const models = await response.json();
|
||||||
modelsTableBody.innerHTML = ""; // Clear loading message
|
modelsTableBody.innerHTML = ""; // Clear loading message
|
||||||
if (models.length === 0) {
|
if (models.length === 0) {
|
||||||
modelsTableBody.innerHTML =
|
modelsTableBody.innerHTML =
|
||||||
'<tr><td colspan="4" class="text-center py-4">No models found in cache.</td></tr>';
|
'<tr><td colspan="4" class="text-center py-4">No models found in cache.</td></tr>';
|
||||||
} else {
|
} else {
|
||||||
models.forEach((model) => {
|
models.forEach((model) => {
|
||||||
const row = `
|
const row = `
|
||||||
<tr>
|
<tr>
|
||||||
<td class="px-6 py-4 whitespace-nowrap">${model.name}</td>
|
<td class="px-6 py-4 whitespace-nowrap">${model.name}</td>
|
||||||
<td class="px-6 py-4 whitespace-nowrap font-mono text-sm">${model.id}</td>
|
<td class="px-6 py-4 whitespace-nowrap font-mono text-sm">${model.id}</td>
|
||||||
<td class="px-6 py-4 whitespace-nowrap">${model.modality}</td>
|
<td class="px-6 py-4 whitespace-nowrap">${model.modality}</td>
|
||||||
<td class="px-6 py-4 whitespace-nowrap">${model.context_length || "N/A"}</td>
|
<td class="px-6 py-4 whitespace-nowrap">${model.context_length || "N/A"}</td>
|
||||||
</tr>
|
</tr>
|
||||||
`;
|
`;
|
||||||
modelsTableBody.innerHTML += row;
|
modelsTableBody.innerHTML += row;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
modelsTableBody.innerHTML =
|
modelsTableBody.innerHTML =
|
||||||
'<tr><td colspan="4" class="text-center py-4 text-red-500">Error loading models.</td></tr>';
|
'<tr><td colspan="4" class="text-center py-4 text-red-500">Error loading models.</td></tr>';
|
||||||
console.error("Error fetching models:", error);
|
console.error("Error fetching models:", error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function refreshCache() {
|
async function refreshCache() {
|
||||||
refreshButton.disabled = true;
|
refreshButton.disabled = true;
|
||||||
refreshStatus.textContent = "Refreshing...";
|
refreshStatus.textContent = "Refreshing...";
|
||||||
refreshStatus.classList.remove("text-red-500", "text-green-500");
|
refreshStatus.classList.remove("text-red-500", "text-green-500");
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch("/api/v1/admin/models/refresh", {
|
const response = await fetch("/api/v1/admin/models/refresh", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
});
|
});
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(data.detail || "Failed to refresh cache");
|
throw new Error(data.detail || "Failed to refresh cache");
|
||||||
}
|
}
|
||||||
refreshStatus.textContent = `Successfully refreshed ${data.refreshed} models. Total: ${data.total_models}.`;
|
refreshStatus.textContent = `Successfully refreshed ${data.refreshed} models. Total: ${data.total_models}.`;
|
||||||
refreshStatus.classList.add("text-green-500");
|
refreshStatus.classList.add("text-green-500");
|
||||||
fetchCacheStatus();
|
fetchCacheStatus();
|
||||||
fetchModels();
|
fetchModels();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
refreshStatus.textContent = `Error: ${error.message}`;
|
refreshStatus.textContent = `Error: ${error.message}`;
|
||||||
refreshStatus.classList.add("text-red-500");
|
refreshStatus.classList.add("text-red-500");
|
||||||
} finally {
|
} finally {
|
||||||
refreshButton.disabled = false;
|
refreshButton.disabled = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fetchCacheStatus();
|
fetchCacheStatus();
|
||||||
fetchModels();
|
fetchModels();
|
||||||
refreshButton.addEventListener("click", refreshCache);
|
refreshButton.addEventListener("click", refreshCache);
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
@@ -1,182 +1,182 @@
|
|||||||
{% extends "base.html" %} {% block title %}Admin - Video Jobs{% endblock %} {%
|
{% extends "base.html" %} {% block title %}Admin - Video Jobs{% endblock %} {%
|
||||||
block content %}
|
block content %}
|
||||||
<div class="container mx-auto px-4 py-8">
|
<div class="container mx-auto px-4 py-8">
|
||||||
<h1 class="text-3xl font-bold mb-6">Admin: Video Jobs</h1>
|
<h1 class="text-3xl font-bold mb-6">Admin: Video Jobs</h1>
|
||||||
|
|
||||||
<!-- Purge Old Jobs -->
|
<!-- Purge Old Jobs -->
|
||||||
<div class="bg-gray-800 p-4 rounded-lg shadow-md mb-6">
|
<div class="bg-gray-800 p-4 rounded-lg shadow-md mb-6">
|
||||||
<h2 class="text-xl font-semibold mb-2">Maintenance</h2>
|
<h2 class="text-xl font-semibold mb-2">Maintenance</h2>
|
||||||
<p class="text-gray-400 mb-4">
|
<p class="text-gray-400 mb-4">
|
||||||
Delete all completed, failed, or cancelled jobs older than 30 days.
|
Delete all completed, failed, or cancelled jobs older than 30 days.
|
||||||
</p>
|
</p>
|
||||||
<button
|
<button
|
||||||
id="purge-button"
|
id="purge-button"
|
||||||
class="bg-red-500 hover:bg-red-700 text-white font-bold py-2 px-4 rounded"
|
class="bg-red-500 hover:bg-red-700 text-white font-bold py-2 px-4 rounded"
|
||||||
>
|
>
|
||||||
Purge Old Jobs
|
Purge Old Jobs
|
||||||
</button>
|
</button>
|
||||||
<p id="purge-status" class="mt-2 text-sm"></p>
|
<p id="purge-status" class="mt-2 text-sm"></p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Video Jobs Table -->
|
<!-- Video Jobs Table -->
|
||||||
<div class="bg-gray-800 p-4 rounded-lg shadow-md overflow-x-auto">
|
<div class="bg-gray-800 p-4 rounded-lg shadow-md overflow-x-auto">
|
||||||
<table class="min-w-full divide-y divide-gray-700">
|
<table class="min-w-full divide-y divide-gray-700">
|
||||||
<thead class="bg-gray-700">
|
<thead class="bg-gray-700">
|
||||||
<tr>
|
<tr>
|
||||||
<th
|
<th
|
||||||
scope="col"
|
scope="col"
|
||||||
class="px-4 py-3 text-left text-xs font-medium text-gray-300 uppercase tracking-wider"
|
class="px-4 py-3 text-left text-xs font-medium text-gray-300 uppercase tracking-wider"
|
||||||
>
|
>
|
||||||
User
|
User
|
||||||
</th>
|
</th>
|
||||||
<th
|
<th
|
||||||
scope="col"
|
scope="col"
|
||||||
class="px-4 py-3 text-left text-xs font-medium text-gray-300 uppercase tracking-wider"
|
class="px-4 py-3 text-left text-xs font-medium text-gray-300 uppercase tracking-wider"
|
||||||
>
|
>
|
||||||
Status
|
Status
|
||||||
</th>
|
</th>
|
||||||
<th
|
<th
|
||||||
scope="col"
|
scope="col"
|
||||||
class="px-4 py-3 text-left text-xs font-medium text-gray-300 uppercase tracking-wider"
|
class="px-4 py-3 text-left text-xs font-medium text-gray-300 uppercase tracking-wider"
|
||||||
>
|
>
|
||||||
Model
|
Model
|
||||||
</th>
|
</th>
|
||||||
<th
|
<th
|
||||||
scope="col"
|
scope="col"
|
||||||
class="px-4 py-3 text-left text-xs font-medium text-gray-300 uppercase tracking-wider"
|
class="px-4 py-3 text-left text-xs font-medium text-gray-300 uppercase tracking-wider"
|
||||||
>
|
>
|
||||||
Prompt
|
Prompt
|
||||||
</th>
|
</th>
|
||||||
<th
|
<th
|
||||||
scope="col"
|
scope="col"
|
||||||
class="px-4 py-3 text-left text-xs font-medium text-gray-300 uppercase tracking-wider"
|
class="px-4 py-3 text-left text-xs font-medium text-gray-300 uppercase tracking-wider"
|
||||||
>
|
>
|
||||||
Created
|
Created
|
||||||
</th>
|
</th>
|
||||||
<th
|
<th
|
||||||
scope="col"
|
scope="col"
|
||||||
class="px-4 py-3 text-left text-xs font-medium text-gray-300 uppercase tracking-wider"
|
class="px-4 py-3 text-left text-xs font-medium text-gray-300 uppercase tracking-wider"
|
||||||
>
|
>
|
||||||
Actions
|
Actions
|
||||||
</th>
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody id="jobs-table-body" class="bg-gray-800 divide-y divide-gray-700">
|
<tbody id="jobs-table-body" class="bg-gray-800 divide-y divide-gray-700">
|
||||||
<tr>
|
<tr>
|
||||||
<td colspan="6" class="text-center py-4">Loading jobs...</td>
|
<td colspan="6" class="text-center py-4">Loading jobs...</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
document.addEventListener("DOMContentLoaded", function () {
|
document.addEventListener("DOMContentLoaded", function () {
|
||||||
const jobsTableBody = document.getElementById("jobs-table-body");
|
const jobsTableBody = document.getElementById("jobs-table-body");
|
||||||
const purgeButton = document.getElementById("purge-button");
|
const purgeButton = document.getElementById("purge-button");
|
||||||
const purgeStatus = document.getElementById("purge-status");
|
const purgeStatus = document.getElementById("purge-status");
|
||||||
|
|
||||||
async function fetchJobs() {
|
async function fetchJobs() {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(
|
const response = await fetch(
|
||||||
"{{ config['BACKEND_URL'] }}/admin/videos",
|
"{{ config['BACKEND_URL'] }}/admin/videos",
|
||||||
{
|
{
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: "Bearer {{ session['access_token'] }}",
|
Authorization: "Bearer {{ session['access_token'] }}",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
if (!response.ok) throw new Error("Failed to fetch jobs");
|
if (!response.ok) throw new Error("Failed to fetch jobs");
|
||||||
const jobs = await response.json();
|
const jobs = await response.json();
|
||||||
jobsTableBody.innerHTML = "";
|
jobsTableBody.innerHTML = "";
|
||||||
if (jobs.length === 0) {
|
if (jobs.length === 0) {
|
||||||
jobsTableBody.innerHTML =
|
jobsTableBody.innerHTML =
|
||||||
'<tr><td colspan="6" class="text-center py-4">No video jobs found.</td></tr>';
|
'<tr><td colspan="6" class="text-center py-4">No video jobs found.</td></tr>';
|
||||||
} else {
|
} else {
|
||||||
jobs.forEach((job) => {
|
jobs.forEach((job) => {
|
||||||
const statusClass =
|
const statusClass =
|
||||||
job.status === "completed"
|
job.status === "completed"
|
||||||
? "text-green-400"
|
? "text-green-400"
|
||||||
: job.status === "failed" || job.status === "cancelled"
|
: job.status === "failed" || job.status === "cancelled"
|
||||||
? "text-red-400"
|
? "text-red-400"
|
||||||
: "text-yellow-400";
|
: "text-yellow-400";
|
||||||
const cancelBtn =
|
const cancelBtn =
|
||||||
job.status === "queued" || job.status === "processing"
|
job.status === "queued" || job.status === "processing"
|
||||||
? `<button class="cancel-btn text-red-400 hover:text-red-600 text-sm" data-job-id="${job.id}">Cancel</button>`
|
? `<button class="cancel-btn text-red-400 hover:text-red-600 text-sm" data-job-id="${job.id}">Cancel</button>`
|
||||||
: "";
|
: "";
|
||||||
const row = `
|
const row = `
|
||||||
<tr>
|
<tr>
|
||||||
<td class="px-4 py-3 whitespace-nowrap text-sm">${job.user_email || "Unknown"}</td>
|
<td class="px-4 py-3 whitespace-nowrap text-sm">${job.user_email || "Unknown"}</td>
|
||||||
<td class="px-4 py-3 whitespace-nowrap text-sm font-semibold ${statusClass}">${job.status}</td>
|
<td class="px-4 py-3 whitespace-nowrap text-sm font-semibold ${statusClass}">${job.status}</td>
|
||||||
<td class="px-4 py-3 whitespace-nowrap text-sm">${job.model_id}</td>
|
<td class="px-4 py-3 whitespace-nowrap text-sm">${job.model_id}</td>
|
||||||
<td class="px-4 py-3 text-sm truncate max-w-xs">${job.prompt}</td>
|
<td class="px-4 py-3 text-sm truncate max-w-xs">${job.prompt}</td>
|
||||||
<td class="px-4 py-3 whitespace-nowrap text-sm">${new Date(job.created_at).toLocaleString()}</td>
|
<td class="px-4 py-3 whitespace-nowrap text-sm">${new Date(job.created_at).toLocaleString()}</td>
|
||||||
<td class="px-4 py-3 whitespace-nowrap text-sm">${cancelBtn}</td>
|
<td class="px-4 py-3 whitespace-nowrap text-sm">${cancelBtn}</td>
|
||||||
</tr>
|
</tr>
|
||||||
`;
|
`;
|
||||||
jobsTableBody.innerHTML += row;
|
jobsTableBody.innerHTML += row;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
jobsTableBody.innerHTML =
|
jobsTableBody.innerHTML =
|
||||||
'<tr><td colspan="6" class="text-center py-4 text-red-500">Error loading jobs.</td></tr>';
|
'<tr><td colspan="6" class="text-center py-4 text-red-500">Error loading jobs.</td></tr>';
|
||||||
console.error("Error fetching jobs:", error);
|
console.error("Error fetching jobs:", error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function purgeJobs() {
|
async function purgeJobs() {
|
||||||
purgeButton.disabled = true;
|
purgeButton.disabled = true;
|
||||||
purgeStatus.textContent = "Purging...";
|
purgeStatus.textContent = "Purging...";
|
||||||
purgeStatus.classList.remove("text-red-500", "text-green-500");
|
purgeStatus.classList.remove("text-red-500", "text-green-500");
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(
|
const response = await fetch(
|
||||||
"{{ config['BACKEND_URL'] }}/admin/videos/purge",
|
"{{ config['BACKEND_URL'] }}/admin/videos/purge",
|
||||||
{
|
{
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: "Bearer {{ session['access_token'] }}",
|
Authorization: "Bearer {{ session['access_token'] }}",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
if (!response.ok)
|
if (!response.ok)
|
||||||
throw new Error(data.detail || "Failed to purge jobs");
|
throw new Error(data.detail || "Failed to purge jobs");
|
||||||
purgeStatus.textContent = `Purged ${data.deleted} jobs. ${data.remaining} remaining.`;
|
purgeStatus.textContent = `Purged ${data.deleted} jobs. ${data.remaining} remaining.`;
|
||||||
purgeStatus.classList.add("text-green-500");
|
purgeStatus.classList.add("text-green-500");
|
||||||
fetchJobs();
|
fetchJobs();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
purgeStatus.textContent = `Error: ${error.message}`;
|
purgeStatus.textContent = `Error: ${error.message}`;
|
||||||
purgeStatus.classList.add("text-red-500");
|
purgeStatus.classList.add("text-red-500");
|
||||||
} finally {
|
} finally {
|
||||||
purgeButton.disabled = false;
|
purgeButton.disabled = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cancel button event delegation
|
// Cancel button event delegation
|
||||||
jobsTableBody.addEventListener("click", async function (e) {
|
jobsTableBody.addEventListener("click", async function (e) {
|
||||||
if (e.target.classList.contains("cancel-btn")) {
|
if (e.target.classList.contains("cancel-btn")) {
|
||||||
const jobId = e.target.dataset.jobId;
|
const jobId = e.target.dataset.jobId;
|
||||||
try {
|
try {
|
||||||
const response = await fetch(
|
const response = await fetch(
|
||||||
`{{ config['BACKEND_URL'] }}/admin/videos/${jobId}/cancel`,
|
`{{ config['BACKEND_URL'] }}/admin/videos/${jobId}/cancel`,
|
||||||
{
|
{
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: "Bearer {{ session['access_token'] }}",
|
Authorization: "Bearer {{ session['access_token'] }}",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
if (!response.ok) throw new Error("Failed to cancel job");
|
if (!response.ok) throw new Error("Failed to cancel job");
|
||||||
fetchJobs();
|
fetchJobs();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
alert(`Error: ${error.message}`);
|
alert(`Error: ${error.message}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
purgeButton.addEventListener("click", purgeJobs);
|
purgeButton.addEventListener("click", purgeJobs);
|
||||||
fetchJobs();
|
fetchJobs();
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
@@ -1,60 +1,60 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>{% block title %}All You Can GET AI{% endblock %}</title>
|
<title>{% block title %}All You Can GET AI{% endblock %}</title>
|
||||||
<link
|
<link
|
||||||
rel="stylesheet"
|
rel="stylesheet"
|
||||||
href="{{ url_for('static', filename='style.css') }}"
|
href="{{ url_for('static', filename='style.css') }}"
|
||||||
/>
|
/>
|
||||||
<script src="https://cdn.tailwindcss.com"></script>
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<header>
|
<header>
|
||||||
<nav>
|
<nav>
|
||||||
<a href="{{ url_for('auth.index') }}" class="brand"
|
<a href="{{ url_for('auth.index') }}" class="brand"
|
||||||
>All You Can GET AI</a
|
>All You Can GET AI</a
|
||||||
>
|
>
|
||||||
|
|
||||||
<button class="hamburger" aria-label="Open menu">
|
<button class="hamburger" aria-label="Open menu">
|
||||||
<span></span><span></span><span></span>
|
<span></span><span></span><span></span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div class="nav-links">
|
<div class="nav-links">
|
||||||
{% if session.get('access_token') %}
|
{% if session.get('access_token') %}
|
||||||
<a href="{{ url_for('dashboard.index') }}">Dashboard</a>
|
<a href="{{ url_for('dashboard.index') }}">Dashboard</a>
|
||||||
<a href="{{ url_for('gallery.index') }}">Gallery</a>
|
<a href="{{ url_for('gallery.index') }}">Gallery</a>
|
||||||
|
|
||||||
<a href="{{ url_for('generate.text') }}">Generate Text</a>
|
<a href="{{ url_for('generate.text') }}">Generate Text</a>
|
||||||
<a href="{{ url_for('generate.image') }}">Generate Image</a>
|
<a href="{{ url_for('generate.image') }}">Generate Image</a>
|
||||||
<a href="{{ url_for('generate.video') }}">Generate Video</a>
|
<a href="{{ url_for('generate.video') }}">Generate Video</a>
|
||||||
|
|
||||||
<a href="{{ url_for('profile.index') }}">Profile</a>
|
<a href="{{ url_for('profile.index') }}">Profile</a>
|
||||||
{% if session.get('user_role') == 'admin' %}
|
{% if session.get('user_role') == 'admin' %}
|
||||||
<a href="{{ url_for('admin.index') }}">Admin</a>
|
<a href="{{ url_for('admin.index') }}">Admin</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<a href="{{ url_for('auth.logout') }}">Log out</a>
|
<a href="{{ url_for('auth.logout') }}">Log out</a>
|
||||||
{% else %}
|
{% else %}
|
||||||
<a href="{{ url_for('auth.login') }}">Log in</a>
|
<a href="{{ url_for('auth.login') }}">Log in</a>
|
||||||
<a href="{{ url_for('auth.register') }}">Register</a>
|
<a href="{{ url_for('auth.register') }}">Register</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div id="loading-overlay">
|
<div id="loading-overlay">
|
||||||
<div class="spinner"></div>
|
<div class="spinner"></div>
|
||||||
<span class="spinner-label">Working…</span>
|
<span class="spinner-label">Working…</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<main>
|
<main>
|
||||||
{% with messages = get_flashed_messages(with_categories=true) %} {% for
|
{% with messages = get_flashed_messages(with_categories=true) %} {% for
|
||||||
category, message in messages %}
|
category, message in messages %}
|
||||||
<div class="alert alert-{{ category }}">{{ message }}</div>
|
<div class="alert alert-{{ category }}">{{ message }}</div>
|
||||||
{% endfor %} {% endwith %} {% block content %}{% endblock %}
|
{% endfor %} {% endwith %} {% block content %}{% endblock %}
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<script src="{{ url_for('static', filename='app.js') }}"></script>
|
<script src="{{ url_for('static', filename='app.js') }}"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
@@ -1,116 +1,116 @@
|
|||||||
{% extends "base.html" %} {% block title %}Dashboard — All You Can GET AI{%
|
{% extends "base.html" %} {% block title %}Dashboard — All You Can GET AI{%
|
||||||
endblock %} {% block content %}
|
endblock %} {% block content %}
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h1>Welcome{% if user.get('email') %}, {{ user.email }}{% endif %}</h1>
|
<h1>Welcome{% if user.get('email') %}, {{ user.email }}{% endif %}</h1>
|
||||||
<p>Role: <strong>{{ user.get('role', 'user') }}</strong></p>
|
<p>Role: <strong>{{ user.get('role', 'user') }}</strong></p>
|
||||||
<a href="{{ url_for('generate.index') }}" class="btn">Start generating</a>
|
<a href="{{ url_for('generate.index') }}" class="btn">Start generating</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% if pending_videos %}
|
{% if pending_videos %}
|
||||||
<div class="card mt-2">
|
<div class="card mt-2">
|
||||||
<h2>Pending Video Jobs</h2>
|
<h2>Pending Video Jobs</h2>
|
||||||
<div class="image-grid">
|
<div class="image-grid">
|
||||||
{% for vid in pending_videos %}
|
{% for vid in pending_videos %}
|
||||||
<a
|
<a
|
||||||
href="{{ url_for('gallery.video_detail', video_id=vid.id) }}"
|
href="{{ url_for('gallery.video_detail', video_id=vid.id) }}"
|
||||||
class="image-grid-item"
|
class="image-grid-item"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
style="
|
style="
|
||||||
background: #1a1a1a;
|
background: #1a1a1a;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
padding: 2rem;
|
padding: 2rem;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
"
|
"
|
||||||
>
|
>
|
||||||
<span class="text-muted">{{ vid.status | capitalize }} …</span>
|
<span class="text-muted">{{ vid.status | capitalize }} …</span>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-muted" style="font-size: 0.75rem; margin-top: 0.25rem">
|
<p class="text-muted" style="font-size: 0.75rem; margin-top: 0.25rem">
|
||||||
<strong>{{ vid.model_id }}</strong><br />{{ vid.prompt[:80] }}{% if
|
<strong>{{ vid.model_id }}</strong><br />{{ vid.prompt[:80] }}{% if
|
||||||
vid.prompt|length > 80 %}…{% endif %}
|
vid.prompt|length > 80 %}…{% endif %}
|
||||||
</p>
|
</p>
|
||||||
</a>
|
</a>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endif %} {% if generated_images %}
|
{% endif %} {% if generated_images %}
|
||||||
<div class="card mt-2">
|
<div class="card mt-2">
|
||||||
<h2>Generated images</h2>
|
<h2>Generated images</h2>
|
||||||
<div class="image-grid">
|
<div class="image-grid">
|
||||||
{% for img in generated_images %}
|
{% for img in generated_images %}
|
||||||
<a
|
<a
|
||||||
href="{{ url_for('gallery.image_detail', image_id=img.id) }}"
|
href="{{ url_for('gallery.image_detail', image_id=img.id) }}"
|
||||||
class="image-grid-item"
|
class="image-grid-item"
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
src="{{ img.image_data }}"
|
src="{{ img.image_data }}"
|
||||||
alt="{{ img.prompt }}"
|
alt="{{ img.prompt }}"
|
||||||
class="generated-image"
|
class="generated-image"
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
/>
|
/>
|
||||||
<p class="text-muted" style="font-size: 0.75rem; margin-top: 0.25rem">
|
<p class="text-muted" style="font-size: 0.75rem; margin-top: 0.25rem">
|
||||||
<strong>{{ img.model_id }}</strong><br />{{ img.prompt[:80] }}{% if
|
<strong>{{ img.model_id }}</strong><br />{{ img.prompt[:80] }}{% if
|
||||||
img.prompt|length > 80 %}…{% endif %}
|
img.prompt|length > 80 %}…{% endif %}
|
||||||
</p>
|
</p>
|
||||||
</a>
|
</a>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endif %} {% if completed_videos %}
|
{% endif %} {% if completed_videos %}
|
||||||
<div class="card mt-2">
|
<div class="card mt-2">
|
||||||
<h2>Generated videos</h2>
|
<h2>Generated videos</h2>
|
||||||
<div class="image-grid">
|
<div class="image-grid">
|
||||||
{% for vid in completed_videos %}
|
{% for vid in completed_videos %}
|
||||||
<a
|
<a
|
||||||
href="{{ url_for('gallery.video_detail', video_id=vid.id) }}"
|
href="{{ url_for('gallery.video_detail', video_id=vid.id) }}"
|
||||||
class="image-grid-item"
|
class="image-grid-item"
|
||||||
>
|
>
|
||||||
{% if vid.video_url %}
|
{% if vid.video_url %}
|
||||||
<video controls style="max-width: 100%; border-radius: 6px">
|
<video controls style="max-width: 100%; border-radius: 6px">
|
||||||
<source src="{{ vid.video_url }}" />
|
<source src="{{ vid.video_url }}" />
|
||||||
Your browser does not support the video tag.
|
Your browser does not support the video tag.
|
||||||
</video>
|
</video>
|
||||||
{% else %}
|
{% else %}
|
||||||
<div
|
<div
|
||||||
style="
|
style="
|
||||||
background: #1a1a1a;
|
background: #1a1a1a;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
padding: 2rem;
|
padding: 2rem;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
"
|
"
|
||||||
>
|
>
|
||||||
<span class="text-muted">{{ vid.status | capitalize }} …</span>
|
<span class="text-muted">{{ vid.status | capitalize }} …</span>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<p class="text-muted" style="font-size: 0.75rem; margin-top: 0.25rem">
|
<p class="text-muted" style="font-size: 0.75rem; margin-top: 0.25rem">
|
||||||
<strong>{{ vid.model_id }}</strong><br />{{ vid.prompt[:80] }}{% if
|
<strong>{{ vid.model_id }}</strong><br />{{ vid.prompt[:80] }}{% if
|
||||||
vid.prompt|length > 80 %}…{% endif %}<br />
|
vid.prompt|length > 80 %}…{% endif %}<br />
|
||||||
<em>{{ vid.status }}</em>
|
<em>{{ vid.status }}</em>
|
||||||
</p>
|
</p>
|
||||||
</a>
|
</a>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endif %} {% if images %}
|
{% endif %} {% if images %}
|
||||||
<div class="card mt-2">
|
<div class="card mt-2">
|
||||||
<h2>Uploaded reference images</h2>
|
<h2>Uploaded reference images</h2>
|
||||||
<div class="image-grid">
|
<div class="image-grid">
|
||||||
{% for img in images %}
|
{% for img in images %}
|
||||||
<a
|
<a
|
||||||
href="{{ url_for('gallery.upload_detail', image_id=img.id) }}"
|
href="{{ url_for('gallery.upload_detail', image_id=img.id) }}"
|
||||||
class="image-grid-item"
|
class="image-grid-item"
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
src="{{ url_for('gallery.serve_uploaded_image', image_id=img.id) }}"
|
src="{{ url_for('gallery.serve_uploaded_image', image_id=img.id) }}"
|
||||||
alt="{{ img.filename }}"
|
alt="{{ img.filename }}"
|
||||||
class="generated-image"
|
class="generated-image"
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
/>
|
/>
|
||||||
<p class="text-muted" style="font-size: 0.75rem; margin-top: 0.25rem">
|
<p class="text-muted" style="font-size: 0.75rem; margin-top: 0.25rem">
|
||||||
{{ img.filename }} — {{ (img.size_bytes / 1024) | round(1) }} KB
|
{{ img.filename }} — {{ (img.size_bytes / 1024) | round(1) }} KB
|
||||||
</p>
|
</p>
|
||||||
</a>
|
</a>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endif %} {% endblock %}
|
{% endif %} {% endblock %}
|
||||||
+300
-300
@@ -1,300 +1,300 @@
|
|||||||
{% extends "base.html" %} {% block title %}My Gallery{% endblock %} {% block
|
{% extends "base.html" %} {% block title %}My Gallery{% endblock %} {% block
|
||||||
content %}
|
content %}
|
||||||
<div
|
<div
|
||||||
class="container mx-auto px-4 py-8"
|
class="container mx-auto px-4 py-8"
|
||||||
data-current-page="1"
|
data-current-page="1"
|
||||||
data-per-page="12"
|
data-per-page="12"
|
||||||
>
|
>
|
||||||
<div class="container mx-auto px-4 py-8">
|
<div class="container mx-auto px-4 py-8">
|
||||||
<h1 class="text-3xl font-bold mb-6">My Gallery</h1>
|
<h1 class="text-3xl font-bold mb-6">My Gallery</h1>
|
||||||
|
|
||||||
<!-- Pending Creations -->
|
<!-- Pending Creations -->
|
||||||
{% if pending_videos %}
|
{% if pending_videos %}
|
||||||
<div class="mb-12">
|
<div class="mb-12">
|
||||||
<h2 class="text-2xl font-semibold mb-4 border-b border-gray-700 pb-2">
|
<h2 class="text-2xl font-semibold mb-4 border-b border-gray-700 pb-2">
|
||||||
Pending Creations
|
Pending Creations
|
||||||
</h2>
|
</h2>
|
||||||
<div
|
<div
|
||||||
class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6"
|
class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6"
|
||||||
>
|
>
|
||||||
{% for video in pending_videos %}
|
{% for video in pending_videos %}
|
||||||
<div
|
<div
|
||||||
class="block bg-gray-800 rounded-lg shadow-lg overflow-hidden hover:shadow-2xl transition-shadow duration-300 relative"
|
class="block bg-gray-800 rounded-lg shadow-lg overflow-hidden hover:shadow-2xl transition-shadow duration-300 relative"
|
||||||
data-pending-video-id="{{ video.id }}"
|
data-pending-video-id="{{ video.id }}"
|
||||||
>
|
>
|
||||||
<a href="{{ url_for('gallery.video_detail', video_id=video.id) }}">
|
<a href="{{ url_for('gallery.video_detail', video_id=video.id) }}">
|
||||||
<div class="p-4">
|
<div class="p-4">
|
||||||
<p class="font-bold text-lg truncate">{{ video.prompt }}</p>
|
<p class="font-bold text-lg truncate">{{ video.prompt }}</p>
|
||||||
<p class="text-sm text-gray-400">
|
<p class="text-sm text-gray-400">
|
||||||
Video Job Status:
|
Video Job Status:
|
||||||
<span class="font-semibold text-yellow-400"
|
<span class="font-semibold text-yellow-400"
|
||||||
>{{ video.status }}</span
|
>{{ video.status }}</span
|
||||||
>
|
>
|
||||||
</p>
|
</p>
|
||||||
<p class="text-xs text-gray-500 mt-2">
|
<p class="text-xs text-gray-500 mt-2">
|
||||||
Started: {{ video.created_at | fromisoformat | humantime }}
|
Started: {{ video.created_at | fromisoformat | humantime }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
<div class="px-4 pb-4">
|
<div class="px-4 pb-4">
|
||||||
<button
|
<button
|
||||||
class="cancel-pending-btn px-3 py-1 bg-red-600 hover:bg-red-700 text-white rounded text-xs"
|
class="cancel-pending-btn px-3 py-1 bg-red-600 hover:bg-red-700 text-white rounded text-xs"
|
||||||
data-video-id="{{ video.id }}"
|
data-video-id="{{ video.id }}"
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
<span class="cancel-pending-msg text-xs ml-2 hidden"></span>
|
<span class="cancel-pending-msg text-xs ml-2 hidden"></span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<!-- Generated Images -->
|
<!-- Generated Images -->
|
||||||
<div class="mb-12">
|
<div class="mb-12">
|
||||||
<h2 class="text-2xl font-semibold mb-4 border-b border-gray-700 pb-2">
|
<h2 class="text-2xl font-semibold mb-4 border-b border-gray-700 pb-2">
|
||||||
Generated Images
|
Generated Images
|
||||||
</h2>
|
</h2>
|
||||||
{% if generated_images %}
|
{% if generated_images %}
|
||||||
<div
|
<div
|
||||||
class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6"
|
class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6"
|
||||||
>
|
>
|
||||||
{% for image in generated_images %}
|
{% for image in generated_images %}
|
||||||
<a
|
<a
|
||||||
href="{{ url_for('gallery.image_detail', image_id=image.id) }}"
|
href="{{ url_for('gallery.image_detail', image_id=image.id) }}"
|
||||||
class="block bg-gray-800 rounded-lg shadow-lg overflow-hidden hover:shadow-2xl transition-shadow duration-300"
|
class="block bg-gray-800 rounded-lg shadow-lg overflow-hidden hover:shadow-2xl transition-shadow duration-300"
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
src="{{ image.image_data }}"
|
src="{{ image.image_data }}"
|
||||||
alt="{{ image.prompt }}"
|
alt="{{ image.prompt }}"
|
||||||
class="w-full h-48 object-cover"
|
class="w-full h-48 object-cover"
|
||||||
/>
|
/>
|
||||||
<div class="p-4">
|
<div class="p-4">
|
||||||
<p class="font-bold text-sm truncate">{{ image.prompt }}</p>
|
<p class="font-bold text-sm truncate">{{ image.prompt }}</p>
|
||||||
<p class="text-xs text-gray-400 mt-1">
|
<p class="text-xs text-gray-400 mt-1">
|
||||||
Image ID: {{ image.id[:8] }}...
|
Image ID: {{ image.id[:8] }}...
|
||||||
</p>
|
</p>
|
||||||
<p class="text-xs text-gray-500 mt-1">
|
<p class="text-xs text-gray-500 mt-1">
|
||||||
{{ image.created_at | fromisoformat | humantime }}
|
{{ image.created_at | fromisoformat | humantime }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
<p class="text-gray-400">
|
<p class="text-gray-400">
|
||||||
You haven't generated any images yet.
|
You haven't generated any images yet.
|
||||||
<a
|
<a
|
||||||
href="{{ url_for('generate.image') }}"
|
href="{{ url_for('generate.image') }}"
|
||||||
class="text-blue-400 hover:underline"
|
class="text-blue-400 hover:underline"
|
||||||
>Generate one now</a
|
>Generate one now</a
|
||||||
>.
|
>.
|
||||||
</p>
|
</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Generated Videos -->
|
<!-- Generated Videos -->
|
||||||
<div class="mb-12">
|
<div class="mb-12">
|
||||||
<h2 class="text-2xl font-semibold mb-4 border-b border-gray-700 pb-2">
|
<h2 class="text-2xl font-semibold mb-4 border-b border-gray-700 pb-2">
|
||||||
Generated Videos
|
Generated Videos
|
||||||
</h2>
|
</h2>
|
||||||
{% if completed_videos %}
|
{% if completed_videos %}
|
||||||
<div
|
<div
|
||||||
class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6"
|
class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6"
|
||||||
>
|
>
|
||||||
{% for video in completed_videos %}
|
{% for video in completed_videos %}
|
||||||
<a
|
<a
|
||||||
href="{{ url_for('gallery.video_detail', video_id=video.id) }}"
|
href="{{ url_for('gallery.video_detail', video_id=video.id) }}"
|
||||||
class="block bg-gray-800 rounded-lg shadow-lg overflow-hidden hover:shadow-2xl transition-shadow duration-300"
|
class="block bg-gray-800 rounded-lg shadow-lg overflow-hidden hover:shadow-2xl transition-shadow duration-300"
|
||||||
>
|
>
|
||||||
{% if video.video_url %}
|
{% if video.video_url %}
|
||||||
<img
|
<img
|
||||||
src="{{ video.video_url }}#t=0.1"
|
src="{{ video.video_url }}#t=0.1"
|
||||||
alt="{{ video.prompt }}"
|
alt="{{ video.prompt }}"
|
||||||
class="w-full h-48 object-cover"
|
class="w-full h-48 object-cover"
|
||||||
/>
|
/>
|
||||||
{% else %}
|
{% else %}
|
||||||
<div class="w-full h-48 bg-black flex items-center justify-center">
|
<div class="w-full h-48 bg-black flex items-center justify-center">
|
||||||
<svg
|
<svg
|
||||||
class="w-12 h-12 text-gray-500"
|
class="w-12 h-12 text-gray-500"
|
||||||
fill="none"
|
fill="none"
|
||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
>
|
>
|
||||||
<path
|
<path
|
||||||
stroke-linecap="round"
|
stroke-linecap="round"
|
||||||
stroke-linejoin="round"
|
stroke-linejoin="round"
|
||||||
stroke-width="2"
|
stroke-width="2"
|
||||||
d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z"
|
d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z"
|
||||||
></path>
|
></path>
|
||||||
<path
|
<path
|
||||||
stroke-linecap="round"
|
stroke-linecap="round"
|
||||||
stroke-linejoin="round"
|
stroke-linejoin="round"
|
||||||
stroke-width="2"
|
stroke-width="2"
|
||||||
d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||||
></path>
|
></path>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<div class="p-4">
|
<div class="p-4">
|
||||||
<p class="font-bold text-sm truncate">{{ video.prompt }}</p>
|
<p class="font-bold text-sm truncate">{{ video.prompt }}</p>
|
||||||
<p class="text-xs text-gray-400 mt-1">
|
<p class="text-xs text-gray-400 mt-1">
|
||||||
Video ID: {{ video.id[:8] }}...
|
Video ID: {{ video.id[:8] }}...
|
||||||
</p>
|
</p>
|
||||||
<p class="text-xs text-gray-500 mt-1">
|
<p class="text-xs text-gray-500 mt-1">
|
||||||
{{ video.created_at | fromisoformat | humantime }}
|
{{ video.created_at | fromisoformat | humantime }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
<p class="text-gray-400">
|
<p class="text-gray-400">
|
||||||
You haven't generated any videos yet.
|
You haven't generated any videos yet.
|
||||||
<a
|
<a
|
||||||
href="{{ url_for('generate.video') }}"
|
href="{{ url_for('generate.video') }}"
|
||||||
class="text-blue-400 hover:underline"
|
class="text-blue-400 hover:underline"
|
||||||
>Generate one now</a
|
>Generate one now</a
|
||||||
>.
|
>.
|
||||||
</p>
|
</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Uploaded Images -->
|
<!-- Uploaded Images -->
|
||||||
<div>
|
<div>
|
||||||
<h2 class="text-2xl font-semibold mb-4 border-b border-gray-700 pb-2">
|
<h2 class="text-2xl font-semibold mb-4 border-b border-gray-700 pb-2">
|
||||||
My Uploads
|
My Uploads
|
||||||
</h2>
|
</h2>
|
||||||
{% if uploads %}
|
{% if uploads %}
|
||||||
<div
|
<div
|
||||||
class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6"
|
class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6"
|
||||||
>
|
>
|
||||||
{% for image in uploads %}
|
{% for image in uploads %}
|
||||||
<a
|
<a
|
||||||
href="{{ url_for('gallery.upload_detail', image_id=image.id) }}"
|
href="{{ url_for('gallery.upload_detail', image_id=image.id) }}"
|
||||||
class="block bg-gray-800 rounded-lg shadow-lg overflow-hidden hover:shadow-2xl transition-shadow duration-300"
|
class="block bg-gray-800 rounded-lg shadow-lg overflow-hidden hover:shadow-2xl transition-shadow duration-300"
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
src="{{ url_for('gallery.serve_uploaded_image', image_id=image.id) }}"
|
src="{{ url_for('gallery.serve_uploaded_image', image_id=image.id) }}"
|
||||||
alt="{{ image.filename }}"
|
alt="{{ image.filename }}"
|
||||||
class="w-full h-48 object-cover"
|
class="w-full h-48 object-cover"
|
||||||
/>
|
/>
|
||||||
<div class="p-4">
|
<div class="p-4">
|
||||||
<p class="font-bold text-sm truncate">{{ image.filename }}</p>
|
<p class="font-bold text-sm truncate">{{ image.filename }}</p>
|
||||||
<p class="text-xs text-gray-400 mt-1">
|
<p class="text-xs text-gray-400 mt-1">
|
||||||
Upload ID: {{ image.id[:8] }}...
|
Upload ID: {{ image.id[:8] }}...
|
||||||
</p>
|
</p>
|
||||||
<p class="text-xs text-gray-500 mt-1">
|
<p class="text-xs text-gray-500 mt-1">
|
||||||
{{ image.uploaded_at | fromisoformat | humantime }}
|
{{ image.uploaded_at | fromisoformat | humantime }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
<p class="text-gray-400">You haven't uploaded any images.</p>
|
<p class="text-gray-400">You haven't uploaded any images.</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Infinite Scroll Loading Indicator -->
|
<!-- Infinite Scroll Loading Indicator -->
|
||||||
<div id="loading-indicator" class="flex justify-center py-8 hidden">
|
<div id="loading-indicator" class="flex justify-center py-8 hidden">
|
||||||
<div class="spinner"></div>
|
<div class="spinner"></div>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %} {% block scripts %}
|
{% endblock %} {% block scripts %}
|
||||||
<script>
|
<script>
|
||||||
document.addEventListener("DOMContentLoaded", function () {
|
document.addEventListener("DOMContentLoaded", function () {
|
||||||
const galleryContainers = document.querySelectorAll(".grid[data-grid]");
|
const galleryContainers = document.querySelectorAll(".grid[data-grid]");
|
||||||
const loadingIndicator = document.getElementById("loading-indicator");
|
const loadingIndicator = document.getElementById("loading-indicator");
|
||||||
const container = document.querySelector(".container[data-current-page]");
|
const container = document.querySelector(".container[data-current-page]");
|
||||||
const currentPage = parseInt(container.dataset.currentPage);
|
const currentPage = parseInt(container.dataset.currentPage);
|
||||||
const perPage = parseInt(container.dataset.perPage);
|
const perPage = parseInt(container.dataset.perPage);
|
||||||
let isLoading = false;
|
let isLoading = false;
|
||||||
let hasMore = true;
|
let hasMore = true;
|
||||||
|
|
||||||
// Add data-grid attribute to all gallery grids
|
// Add data-grid attribute to all gallery grids
|
||||||
document
|
document
|
||||||
.querySelectorAll(".grid")
|
.querySelectorAll(".grid")
|
||||||
.forEach((grid) => grid.setAttribute("data-grid", ""));
|
.forEach((grid) => grid.setAttribute("data-grid", ""));
|
||||||
|
|
||||||
// Infinite scroll handler
|
// Infinite scroll handler
|
||||||
window.addEventListener("scroll", async function () {
|
window.addEventListener("scroll", async function () {
|
||||||
if (!hasMore || isLoading) return;
|
if (!hasMore || isLoading) return;
|
||||||
|
|
||||||
const scrollPosition = window.innerHeight + window.scrollY;
|
const scrollPosition = window.innerHeight + window.scrollY;
|
||||||
const bottomThreshold = document.body.offsetHeight - 1000;
|
const bottomThreshold = document.body.offsetHeight - 1000;
|
||||||
|
|
||||||
if (scrollPosition >= bottomThreshold) {
|
if (scrollPosition >= bottomThreshold) {
|
||||||
isLoading = true;
|
isLoading = true;
|
||||||
loadingIndicator.classList.remove("hidden");
|
loadingIndicator.classList.remove("hidden");
|
||||||
// TODO: Implement actual fetching of next page of results and appending to the correct grid(s)
|
// TODO: Implement actual fetching of next page of results and appending to the correct grid(s)
|
||||||
// For demo purposes, we'll just simulate a delay and then hide the loading indicator
|
// For demo purposes, we'll just simulate a delay and then hide the loading indicator
|
||||||
// Simulate API call for next page
|
// Simulate API call for next page
|
||||||
// In real implementation, replace with actual backend fetch
|
// In real implementation, replace with actual backend fetch
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
isLoading = false;
|
isLoading = false;
|
||||||
loadingIndicator.classList.add("hidden");
|
loadingIndicator.classList.add("hidden");
|
||||||
// Real app would fetch /generate/images?page=${currentPage +1}&limit=${perPage}
|
// Real app would fetch /generate/images?page=${currentPage +1}&limit=${perPage}
|
||||||
// and /generate/videos similarly
|
// and /generate/videos similarly
|
||||||
}, 1500);
|
}, 1500);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
// Cancel pending video buttons
|
// Cancel pending video buttons
|
||||||
document.querySelectorAll(".cancel-pending-btn").forEach((btn) => {
|
document.querySelectorAll(".cancel-pending-btn").forEach((btn) => {
|
||||||
btn.addEventListener("click", async (e) => {
|
btn.addEventListener("click", async (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
const videoId = btn.dataset.videoId;
|
const videoId = btn.dataset.videoId;
|
||||||
const msgEl = btn.parentElement.querySelector(".cancel-pending-msg");
|
const msgEl = btn.parentElement.querySelector(".cancel-pending-msg");
|
||||||
btn.disabled = true;
|
btn.disabled = true;
|
||||||
btn.textContent = "Cancelling…";
|
btn.textContent = "Cancelling…";
|
||||||
try {
|
try {
|
||||||
const resp = await fetch(
|
const resp = await fetch(
|
||||||
"/generate/video/" + encodeURIComponent(videoId) + "/cancel",
|
"/generate/video/" + encodeURIComponent(videoId) + "/cancel",
|
||||||
{ method: "POST" },
|
{ method: "POST" },
|
||||||
);
|
);
|
||||||
if (resp.ok) {
|
if (resp.ok) {
|
||||||
btn.classList.add("hidden");
|
btn.classList.add("hidden");
|
||||||
if (msgEl) {
|
if (msgEl) {
|
||||||
msgEl.textContent = "Cancelled";
|
msgEl.textContent = "Cancelled";
|
||||||
msgEl.classList.remove("hidden", "text-red-500");
|
msgEl.classList.remove("hidden", "text-red-500");
|
||||||
msgEl.classList.add("text-gray-300");
|
msgEl.classList.add("text-gray-300");
|
||||||
}
|
}
|
||||||
const card = document.querySelector(
|
const card = document.querySelector(
|
||||||
'[data-pending-video-id="' + videoId + '"]',
|
'[data-pending-video-id="' + videoId + '"]',
|
||||||
);
|
);
|
||||||
if (card) {
|
if (card) {
|
||||||
const statusSpan = card.querySelector(".text-yellow-400");
|
const statusSpan = card.querySelector(".text-yellow-400");
|
||||||
if (statusSpan) {
|
if (statusSpan) {
|
||||||
statusSpan.textContent = "cancelled";
|
statusSpan.textContent = "cancelled";
|
||||||
statusSpan.classList.remove("text-yellow-400");
|
statusSpan.classList.remove("text-yellow-400");
|
||||||
statusSpan.classList.add("text-gray-400");
|
statusSpan.classList.add("text-gray-400");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const data = await resp.json().catch(() => ({}));
|
const data = await resp.json().catch(() => ({}));
|
||||||
btn.disabled = false;
|
btn.disabled = false;
|
||||||
btn.textContent = "Cancel";
|
btn.textContent = "Cancel";
|
||||||
if (msgEl) {
|
if (msgEl) {
|
||||||
msgEl.textContent = data.detail || "Failed";
|
msgEl.textContent = data.detail || "Failed";
|
||||||
msgEl.classList.remove("hidden");
|
msgEl.classList.remove("hidden");
|
||||||
msgEl.classList.add("text-red-500");
|
msgEl.classList.add("text-red-500");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
btn.disabled = false;
|
btn.disabled = false;
|
||||||
btn.textContent = "Cancel";
|
btn.textContent = "Cancel";
|
||||||
if (msgEl) {
|
if (msgEl) {
|
||||||
msgEl.textContent = "Error";
|
msgEl.textContent = "Error";
|
||||||
msgEl.classList.remove("hidden");
|
msgEl.classList.remove("hidden");
|
||||||
msgEl.classList.add("text-red-500");
|
msgEl.classList.add("text-red-500");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
</div>
|
</div>
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
{% extends "base.html" %} {% block title %}Generate — All You Can GET AI{%
|
{% extends "base.html" %} {% block title %}Generate — All You Can GET AI{%
|
||||||
endblock %} {% block content %}
|
endblock %} {% block content %}
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h1>Generate</h1>
|
<h1>Generate</h1>
|
||||||
<p class="text-muted">
|
<p class="text-muted">
|
||||||
Choose a generation type from the Generate menu above.
|
Choose a generation type from the Generate menu above.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
@@ -1,82 +1,82 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
{% block title %}Image Generation — All You Can GET AI{% endblock %}
|
{% block title %}Image Generation — All You Can GET AI{% endblock %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h1>Image Generation</h1>
|
<h1>Image Generation</h1>
|
||||||
<form method="post" enctype="multipart/form-data">
|
<form method="post" enctype="multipart/form-data">
|
||||||
<label for="model">Model</label>
|
<label for="model">Model</label>
|
||||||
{% if models %}
|
{% if models %}
|
||||||
<select id="model" name="model" required>
|
<select id="model" name="model" required>
|
||||||
{% for m in models %}
|
{% for m in models %}
|
||||||
<option value="{{ m.id }}" {{ "selected" if request.form.get('model', '') == m.id else "" }}>{{ m.name }}</option>
|
<option value="{{ m.id }}" {{ "selected" if request.form.get('model', '') == m.id else "" }}>{{ m.name }}</option>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</select>
|
</select>
|
||||||
{% else %}
|
{% else %}
|
||||||
<input id="model" name="model" type="text" required
|
<input id="model" name="model" type="text" required
|
||||||
placeholder="e.g. google/gemini-2.5-flash-image"
|
placeholder="e.g. google/gemini-2.5-flash-image"
|
||||||
value="{{ request.form.get('model', '') }}">
|
value="{{ request.form.get('model', '') }}">
|
||||||
<p class="text-muted mt-1">No models available</p>
|
<p class="text-muted mt-1">No models available</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<label for="prompt">Prompt</label>
|
<label for="prompt">Prompt</label>
|
||||||
<textarea id="prompt" name="prompt" rows="4" required
|
<textarea id="prompt" name="prompt" rows="4" required
|
||||||
placeholder="Describe the image you want…">{{ request.form.get('prompt', '') }}</textarea>
|
placeholder="Describe the image you want…">{{ request.form.get('prompt', '') }}</textarea>
|
||||||
|
|
||||||
<label for="aspect_ratio">Aspect ratio</label>
|
<label for="aspect_ratio">Aspect ratio</label>
|
||||||
<select id="aspect_ratio" name="aspect_ratio">
|
<select id="aspect_ratio" name="aspect_ratio">
|
||||||
<option value="">Auto (default 1:1)</option>
|
<option value="">Auto (default 1:1)</option>
|
||||||
<option value="1:1" {{ "selected" if request.form.get('aspect_ratio')=='1:1' else "" }}>1:1 (square)</option>
|
<option value="1:1" {{ "selected" if request.form.get('aspect_ratio')=='1:1' else "" }}>1:1 (square)</option>
|
||||||
<option value="16:9" {{ "selected" if request.form.get('aspect_ratio')=='16:9' else "" }}>16:9 (landscape)</option>
|
<option value="16:9" {{ "selected" if request.form.get('aspect_ratio')=='16:9' else "" }}>16:9 (landscape)</option>
|
||||||
<option value="9:16" {{ "selected" if request.form.get('aspect_ratio')=='9:16' else "" }}>9:16 (portrait)</option>
|
<option value="9:16" {{ "selected" if request.form.get('aspect_ratio')=='9:16' else "" }}>9:16 (portrait)</option>
|
||||||
<option value="4:3" {{ "selected" if request.form.get('aspect_ratio')=='4:3' else "" }}>4:3</option>
|
<option value="4:3" {{ "selected" if request.form.get('aspect_ratio')=='4:3' else "" }}>4:3</option>
|
||||||
<option value="3:4" {{ "selected" if request.form.get('aspect_ratio')=='3:4' else "" }}>3:4</option>
|
<option value="3:4" {{ "selected" if request.form.get('aspect_ratio')=='3:4' else "" }}>3:4</option>
|
||||||
<option value="3:2" {{ "selected" if request.form.get('aspect_ratio')=='3:2' else "" }}>3:2</option>
|
<option value="3:2" {{ "selected" if request.form.get('aspect_ratio')=='3:2' else "" }}>3:2</option>
|
||||||
<option value="2:3" {{ "selected" if request.form.get('aspect_ratio')=='2:3' else "" }}>2:3</option>
|
<option value="2:3" {{ "selected" if request.form.get('aspect_ratio')=='2:3' else "" }}>2:3</option>
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
<label for="image_size">Resolution</label>
|
<label for="image_size">Resolution</label>
|
||||||
<select id="image_size" name="image_size">
|
<select id="image_size" name="image_size">
|
||||||
<option value="">Auto (default 1K)</option>
|
<option value="">Auto (default 1K)</option>
|
||||||
<option value="0.5K" {{ "selected" if request.form.get('image_size')=='0.5K' else "" }}>0.5K (low)</option>
|
<option value="0.5K" {{ "selected" if request.form.get('image_size')=='0.5K' else "" }}>0.5K (low)</option>
|
||||||
<option value="1K" {{ "selected" if request.form.get('image_size')=='1K' else "" }}>1K (standard)</option>
|
<option value="1K" {{ "selected" if request.form.get('image_size')=='1K' else "" }}>1K (standard)</option>
|
||||||
<option value="2K" {{ "selected" if request.form.get('image_size')=='2K' else "" }}>2K (high)</option>
|
<option value="2K" {{ "selected" if request.form.get('image_size')=='2K' else "" }}>2K (high)</option>
|
||||||
<option value="4K" {{ "selected" if request.form.get('image_size')=='4K' else "" }}>4K (ultra)</option>
|
<option value="4K" {{ "selected" if request.form.get('image_size')=='4K' else "" }}>4K (ultra)</option>
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
<label for="reference_image">Reference image (optional)</label>
|
<label for="reference_image">Reference image (optional)</label>
|
||||||
<input
|
<input
|
||||||
id="reference_image"
|
id="reference_image"
|
||||||
name="reference_image"
|
name="reference_image"
|
||||||
type="file"
|
type="file"
|
||||||
accept="image/png,image/jpeg,image/webp,image/gif"
|
accept="image/png,image/jpeg,image/webp,image/gif"
|
||||||
>
|
>
|
||||||
<p class="text-muted mt-1" id="reference-image-help">
|
<p class="text-muted mt-1" id="reference-image-help">
|
||||||
Upload an image to use as visual reference (image-to-image).
|
Upload an image to use as visual reference (image-to-image).
|
||||||
</p>
|
</p>
|
||||||
<div class="image-upload-preview" id="image-upload-preview" hidden>
|
<div class="image-upload-preview" id="image-upload-preview" hidden>
|
||||||
<p class="text-muted" id="image-upload-filename"></p>
|
<p class="text-muted" id="image-upload-filename"></p>
|
||||||
<img id="image-upload-preview-img" alt="Uploaded reference image preview" class="generated-image">
|
<img id="image-upload-preview-img" alt="Uploaded reference image preview" class="generated-image">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button type="submit">Generate image</button>
|
<button type="submit">Generate image</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
{% if error %}
|
{% if error %}
|
||||||
<div class="alert alert-error mt-2">{{ error }}</div>
|
<div class="alert alert-error mt-2">{{ error }}</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if result %}
|
{% if result %}
|
||||||
<div class="result">
|
<div class="result">
|
||||||
<h2>Generated image{{ 's' if result.images|length > 1 }}</h2>
|
<h2>Generated image{{ 's' if result.images|length > 1 }}</h2>
|
||||||
{% for img in result.images %}
|
{% for img in result.images %}
|
||||||
{% if img.url %}
|
{% if img.url %}
|
||||||
<img src="{{ img.url }}" alt="Generated image" class="generated-image">
|
<img src="{{ img.url }}" alt="Generated image" class="generated-image">
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if img.revised_prompt %}
|
{% if img.revised_prompt %}
|
||||||
<p class="text-muted mt-1" style="font-size:0.8rem;">{{ img.revised_prompt }}</p>
|
<p class="text-muted mt-1" style="font-size:0.8rem;">{{ img.revised_prompt }}</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
@@ -1,93 +1,93 @@
|
|||||||
{% extends "base.html" %} {% block title %}Text Generation — All You Can GET
|
{% extends "base.html" %} {% block title %}Text Generation — All You Can GET
|
||||||
AI{% endblock %} {% block content %}
|
AI{% endblock %} {% block content %}
|
||||||
<div class="card chat-page">
|
<div class="card chat-page">
|
||||||
<div class="chat-header">
|
<div class="chat-header">
|
||||||
<h1>Text Chat</h1>
|
<h1>Text Chat</h1>
|
||||||
<form method="post" style="display: inline">
|
<form method="post" style="display: inline">
|
||||||
<input type="hidden" name="action" value="clear" />
|
<input type="hidden" name="action" value="clear" />
|
||||||
<button type="submit" class="btn-secondary btn-sm">New Chat</button>
|
<button type="submit" class="btn-secondary btn-sm">New Chat</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Config row -->
|
<!-- Config row -->
|
||||||
<details class="chat-config" {% if not chat_history %}open{% endif %}>
|
<details class="chat-config" {% if not chat_history %}open{% endif %}>
|
||||||
<summary>Model & System Prompt</summary>
|
<summary>Model & System Prompt</summary>
|
||||||
<div class="chat-config-body">
|
<div class="chat-config-body">
|
||||||
<label for="cfg-model">Model</label>
|
<label for="cfg-model">Model</label>
|
||||||
{% if models %}
|
{% if models %}
|
||||||
<select id="cfg-model" form="chat-form" name="model" required>
|
<select id="cfg-model" form="chat-form" name="model" required>
|
||||||
{% for m in models %}
|
{% for m in models %}
|
||||||
<option value="{{ m.id }}" {{ "selected" if current_model == m.id else "" }}>{{ m.name }}</option>
|
<option value="{{ m.id }}" {{ "selected" if current_model == m.id else "" }}>{{ m.name }}</option>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</select>
|
</select>
|
||||||
{% else %}
|
{% else %}
|
||||||
<input
|
<input
|
||||||
id="cfg-model"
|
id="cfg-model"
|
||||||
form="chat-form"
|
form="chat-form"
|
||||||
name="model"
|
name="model"
|
||||||
type="text"
|
type="text"
|
||||||
required
|
required
|
||||||
placeholder="e.g. openai/gpt-4o"
|
placeholder="e.g. openai/gpt-4o"
|
||||||
value="{{ current_model }}"
|
value="{{ current_model }}"
|
||||||
/>
|
/>
|
||||||
<p class="text-muted mt-1">No models available</p>
|
<p class="text-muted mt-1">No models available</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<label for="cfg-sys">System prompt (optional)</label>
|
<label for="cfg-sys">System prompt (optional)</label>
|
||||||
<textarea
|
<textarea
|
||||||
id="cfg-sys"
|
id="cfg-sys"
|
||||||
form="chat-form"
|
form="chat-form"
|
||||||
name="system_prompt"
|
name="system_prompt"
|
||||||
rows="2"
|
rows="2"
|
||||||
placeholder="Set behavior/instructions for assistant…"
|
placeholder="Set behavior/instructions for assistant…"
|
||||||
>
|
>
|
||||||
{{ system_prompt }}</textarea
|
{{ system_prompt }}</textarea
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
<!-- Chat history -->
|
<!-- Chat history -->
|
||||||
<div class="chat-history" id="chat-history">
|
<div class="chat-history" id="chat-history">
|
||||||
{% if not chat_history %}
|
{% if not chat_history %}
|
||||||
<p class="chat-empty">No messages yet. Start the conversation below.</p>
|
<p class="chat-empty">No messages yet. Start the conversation below.</p>
|
||||||
{% endif %} {% for msg in chat_history %} {% if msg.role == "user" %}
|
{% endif %} {% for msg in chat_history %} {% if msg.role == "user" %}
|
||||||
<div class="chat-bubble chat-bubble--user">
|
<div class="chat-bubble chat-bubble--user">
|
||||||
<span class="bubble-role">You</span>
|
<span class="bubble-role">You</span>
|
||||||
<div class="bubble-content">{{ msg.content }}</div>
|
<div class="bubble-content">{{ msg.content }}</div>
|
||||||
</div>
|
</div>
|
||||||
{% elif msg.role == "assistant" %}
|
{% elif msg.role == "assistant" %}
|
||||||
<div class="chat-bubble chat-bubble--assistant">
|
<div class="chat-bubble chat-bubble--assistant">
|
||||||
<span class="bubble-role">Assistant</span>
|
<span class="bubble-role">Assistant</span>
|
||||||
<div class="bubble-content">{{ msg.content }}</div>
|
<div class="bubble-content">{{ msg.content }}</div>
|
||||||
{% if msg.usage %}
|
{% if msg.usage %}
|
||||||
<span class="bubble-meta"
|
<span class="bubble-meta"
|
||||||
>{{ msg.usage.get('total_tokens', '') }} tokens</span
|
>{{ msg.usage.get('total_tokens', '') }} tokens</span
|
||||||
>
|
>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
{% endif %} {% endfor %} {% if error %}
|
{% endif %} {% endfor %} {% if error %}
|
||||||
<div class="alert alert-error">{{ error }}</div>
|
<div class="alert alert-error">{{ error }}</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Input -->
|
<!-- Input -->
|
||||||
<form id="chat-form" method="post" class="chat-input-row">
|
<form id="chat-form" method="post" class="chat-input-row">
|
||||||
<input type="hidden" name="action" value="send" />
|
<input type="hidden" name="action" value="send" />
|
||||||
<textarea
|
<textarea
|
||||||
name="prompt"
|
name="prompt"
|
||||||
id="prompt"
|
id="prompt"
|
||||||
rows="2"
|
rows="2"
|
||||||
required
|
required
|
||||||
placeholder="Type a message…"
|
placeholder="Type a message…"
|
||||||
class="chat-input-textarea"
|
class="chat-input-textarea"
|
||||||
></textarea>
|
></textarea>
|
||||||
<button type="submit" class="btn-primary">Send</button>
|
<button type="submit" class="btn-primary">Send</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
// Auto-scroll chat to bottom
|
// Auto-scroll chat to bottom
|
||||||
const hist = document.getElementById("chat-history");
|
const hist = document.getElementById("chat-history");
|
||||||
if (hist) hist.scrollTop = hist.scrollHeight;
|
if (hist) hist.scrollTop = hist.scrollHeight;
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
@@ -1,192 +1,192 @@
|
|||||||
{% extends "base.html" %} {% block title %}Video Generation — All You Can GET
|
{% extends "base.html" %} {% block title %}Video Generation — All You Can GET
|
||||||
AI{% endblock %} {% block content %}
|
AI{% endblock %} {% block content %}
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h1>Video Generation</h1>
|
<h1>Video Generation</h1>
|
||||||
|
|
||||||
<div class="tabs-container">
|
<div class="tabs-container">
|
||||||
<div class="tabs">
|
<div class="tabs">
|
||||||
<button class="tab-btn active" data-tab="text-to-video" type="button">
|
<button class="tab-btn active" data-tab="text-to-video" type="button">
|
||||||
Text to video
|
Text to video
|
||||||
</button>
|
</button>
|
||||||
<button class="tab-btn" data-tab="image-to-video" type="button">
|
<button class="tab-btn" data-tab="image-to-video" type="button">
|
||||||
Image to video
|
Image to video
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Text-to-video -->
|
<!-- Text-to-video -->
|
||||||
<div class="tab-panel active" id="tab-text-to-video">
|
<div class="tab-panel active" id="tab-text-to-video">
|
||||||
<form method="post">
|
<form method="post">
|
||||||
<input type="hidden" name="mode" value="text" />
|
<input type="hidden" name="mode" value="text" />
|
||||||
|
|
||||||
<label for="model-t">Model</label>
|
<label for="model-t">Model</label>
|
||||||
{% if models %}
|
{% if models %}
|
||||||
<select id="model-t" name="model" required>
|
<select id="model-t" name="model" required>
|
||||||
{% for m in models %}
|
{% for m in models %}
|
||||||
<option value="{{ m.id }}" {% if request.form.get('model', '') == m.id and request.form.get('mode','text')=='text' %}selected{% endif %}>{{ m.name }}</option>
|
<option value="{{ m.id }}" {% if request.form.get('model', '') == m.id and request.form.get('mode','text')=='text' %}selected{% endif %}>{{ m.name }}</option>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</select>
|
</select>
|
||||||
{% else %}
|
{% else %}
|
||||||
<input
|
<input
|
||||||
id="model-t"
|
id="model-t"
|
||||||
name="model"
|
name="model"
|
||||||
type="text"
|
type="text"
|
||||||
required
|
required
|
||||||
placeholder="e.g. openai/sora-2-pro"
|
placeholder="e.g. openai/sora-2-pro"
|
||||||
value="{{ request.form.get('model', '') if request.form.get('mode','text')=='text' else '' }}"
|
value="{{ request.form.get('model', '') if request.form.get('mode','text')=='text' else '' }}"
|
||||||
/>
|
/>
|
||||||
<p class="text-muted mt-1">No models available</p>
|
<p class="text-muted mt-1">No models available</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<label for="prompt-t">Prompt</label>
|
<label for="prompt-t">Prompt</label>
|
||||||
<textarea
|
<textarea
|
||||||
id="prompt-t"
|
id="prompt-t"
|
||||||
name="prompt"
|
name="prompt"
|
||||||
rows="4"
|
rows="4"
|
||||||
required
|
required
|
||||||
placeholder="Describe the video you want…"
|
placeholder="Describe the video you want…"
|
||||||
>
|
>
|
||||||
{{ request.form.get('prompt', '') if request.form.get('mode','text')=='text' else '' }}</textarea
|
{{ request.form.get('prompt', '') if request.form.get('mode','text')=='text' else '' }}</textarea
|
||||||
>
|
>
|
||||||
|
|
||||||
<label for="aspect-t">Aspect ratio</label>
|
<label for="aspect-t">Aspect ratio</label>
|
||||||
<select id="aspect-t" name="aspect_ratio">
|
<select id="aspect-t" name="aspect_ratio">
|
||||||
<option value="16:9">16:9 (landscape)</option>
|
<option value="16:9">16:9 (landscape)</option>
|
||||||
<option value="9:16">9:16 (portrait)</option>
|
<option value="9:16">9:16 (portrait)</option>
|
||||||
<option value="1:1">1:1 (square)</option>
|
<option value="1:1">1:1 (square)</option>
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
<label for="res-t">Resolution</label>
|
<label for="res-t">Resolution</label>
|
||||||
<select id="res-t" name="resolution">
|
<select id="res-t" name="resolution">
|
||||||
<option value="">Auto (default)</option>
|
<option value="">Auto (default)</option>
|
||||||
<option value="480p">480p</option>
|
<option value="480p">480p</option>
|
||||||
<option value="720p">720p</option>
|
<option value="720p">720p</option>
|
||||||
<option value="1080p">1080p</option>
|
<option value="1080p">1080p</option>
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
<label for="duration-t">Duration (seconds)</label>
|
<label for="duration-t">Duration (seconds)</label>
|
||||||
<select id="duration-t" name="duration_seconds">
|
<select id="duration-t" name="duration_seconds">
|
||||||
<option value="4">4s</option>
|
<option value="4">4s</option>
|
||||||
<option value="8">8s</option>
|
<option value="8">8s</option>
|
||||||
<option value="12" selected>12s</option>
|
<option value="12" selected>12s</option>
|
||||||
<option value="16">16s</option>
|
<option value="16">16s</option>
|
||||||
<option value="20">20s</option>
|
<option value="20">20s</option>
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
<button type="submit">Generate video</button>
|
<button type="submit">Generate video</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Image-to-video -->
|
<!-- Image-to-video -->
|
||||||
<div class="tab-panel" id="tab-image-to-video">
|
<div class="tab-panel" id="tab-image-to-video">
|
||||||
<form method="post">
|
<form method="post">
|
||||||
<input type="hidden" name="mode" value="image" />
|
<input type="hidden" name="mode" value="image" />
|
||||||
|
|
||||||
<label for="model-i">Model</label>
|
<label for="model-i">Model</label>
|
||||||
{% if models %}
|
{% if models %}
|
||||||
<select id="model-i" name="model" required>
|
<select id="model-i" name="model" required>
|
||||||
{% for m in models %}
|
{% for m in models %}
|
||||||
<option value="{{ m.id }}" {% if request.form.get('model', '') == m.id and request.form.get('mode')=='image' %}selected{% endif %}>{{ m.name }}</option>
|
<option value="{{ m.id }}" {% if request.form.get('model', '') == m.id and request.form.get('mode')=='image' %}selected{% endif %}>{{ m.name }}</option>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</select>
|
</select>
|
||||||
{% else %}
|
{% else %}
|
||||||
<input
|
<input
|
||||||
id="model-i"
|
id="model-i"
|
||||||
name="model"
|
name="model"
|
||||||
type="text"
|
type="text"
|
||||||
required
|
required
|
||||||
placeholder="e.g. openai/sora-2-pro"
|
placeholder="e.g. openai/sora-2-pro"
|
||||||
value="{{ request.form.get('model', '') if request.form.get('mode')=='image' else '' }}"
|
value="{{ request.form.get('model', '') if request.form.get('mode')=='image' else '' }}"
|
||||||
/>
|
/>
|
||||||
<p class="text-muted mt-1">No models available</p>
|
<p class="text-muted mt-1">No models available</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<label for="image_url">Source image URL</label>
|
<label for="image_url">Source image URL</label>
|
||||||
<input
|
<input
|
||||||
id="image_url"
|
id="image_url"
|
||||||
name="image_url"
|
name="image_url"
|
||||||
type="url"
|
type="url"
|
||||||
required
|
required
|
||||||
placeholder="https://example.com/photo.jpg"
|
placeholder="https://example.com/photo.jpg"
|
||||||
value="{{ request.form.get('image_url', '') }}"
|
value="{{ request.form.get('image_url', '') }}"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<label for="prompt-i">Motion prompt</label>
|
<label for="prompt-i">Motion prompt</label>
|
||||||
<textarea
|
<textarea
|
||||||
id="prompt-i"
|
id="prompt-i"
|
||||||
name="prompt"
|
name="prompt"
|
||||||
rows="3"
|
rows="3"
|
||||||
required
|
required
|
||||||
placeholder="Describe the motion or transformation…"
|
placeholder="Describe the motion or transformation…"
|
||||||
>
|
>
|
||||||
{{ request.form.get('prompt', '') if request.form.get('mode')=='image' else '' }}</textarea
|
{{ request.form.get('prompt', '') if request.form.get('mode')=='image' else '' }}</textarea
|
||||||
>
|
>
|
||||||
|
|
||||||
<label for="aspect-i">Aspect ratio</label>
|
<label for="aspect-i">Aspect ratio</label>
|
||||||
<select id="aspect-i" name="aspect_ratio">
|
<select id="aspect-i" name="aspect_ratio">
|
||||||
<option value="16:9">16:9 (landscape)</option>
|
<option value="16:9">16:9 (landscape)</option>
|
||||||
<option value="9:16">9:16 (portrait)</option>
|
<option value="9:16">9:16 (portrait)</option>
|
||||||
<option value="1:1">1:1 (square)</option>
|
<option value="1:1">1:1 (square)</option>
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
<label for="res-i">Resolution</label>
|
<label for="res-i">Resolution</label>
|
||||||
<select id="res-i" name="resolution">
|
<select id="res-i" name="resolution">
|
||||||
<option value="">Auto (default)</option>
|
<option value="">Auto (default)</option>
|
||||||
<option value="480p">480p</option>
|
<option value="480p">480p</option>
|
||||||
<option value="720p">720p</option>
|
<option value="720p">720p</option>
|
||||||
<option value="1080p">1080p</option>
|
<option value="1080p">1080p</option>
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
<label for="duration-i">Duration (seconds)</label>
|
<label for="duration-i">Duration (seconds)</label>
|
||||||
<select id="duration-i" name="duration_seconds">
|
<select id="duration-i" name="duration_seconds">
|
||||||
<option value="4">4s</option>
|
<option value="4">4s</option>
|
||||||
<option value="8">8s</option>
|
<option value="8">8s</option>
|
||||||
<option value="12" selected>12s</option>
|
<option value="12" selected>12s</option>
|
||||||
<option value="16">16s</option>
|
<option value="16">16s</option>
|
||||||
<option value="20">20s</option>
|
<option value="20">20s</option>
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
<button type="submit">Generate video from image</button>
|
<button type="submit">Generate video from image</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% if error %}
|
{% if error %}
|
||||||
<div class="alert alert-error mt-2">{{ error }}</div>
|
<div class="alert alert-error mt-2">{{ error }}</div>
|
||||||
{% endif %} {% if result %}
|
{% endif %} {% if result %}
|
||||||
<div class="result">
|
<div class="result">
|
||||||
<h2>Video job</h2>
|
<h2>Video job</h2>
|
||||||
<p>Job ID: <code>{{ result.db_id or result.id }}</code></p>
|
<p>Job ID: <code>{{ result.db_id or result.id }}</code></p>
|
||||||
{% if result.status in ('queued', 'processing') and result.db_id %}
|
{% if result.status in ('queued', 'processing') and result.db_id %}
|
||||||
<div id="video-poll-status" data-video-id="{{ result.db_id }}">
|
<div id="video-poll-status" data-video-id="{{ result.db_id }}">
|
||||||
<p>
|
<p>
|
||||||
<span id="poll-status-text"
|
<span id="poll-status-text"
|
||||||
>Status: <strong>{{ result.status }}</strong></span
|
>Status: <strong>{{ result.status }}</strong></span
|
||||||
>
|
>
|
||||||
— checking for updates every 5 s…
|
— checking for updates every 5 s…
|
||||||
</p>
|
</p>
|
||||||
<div id="poll-video-container"></div>
|
<div id="poll-video-container"></div>
|
||||||
<button
|
<button
|
||||||
id="cancel-video-btn"
|
id="cancel-video-btn"
|
||||||
class="mt-2 px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded-md text-sm"
|
class="mt-2 px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded-md text-sm"
|
||||||
>
|
>
|
||||||
Cancel Job
|
Cancel Job
|
||||||
</button>
|
</button>
|
||||||
<p id="cancel-msg" class="text-sm mt-2 hidden"></p>
|
<p id="cancel-msg" class="text-sm mt-2 hidden"></p>
|
||||||
</div>
|
</div>
|
||||||
{% elif result.video_url %}
|
{% elif result.video_url %}
|
||||||
<video
|
<video
|
||||||
src="{{ result.video_url }}"
|
src="{{ result.video_url }}"
|
||||||
controls
|
controls
|
||||||
class="generated-video"
|
class="generated-video"
|
||||||
></video>
|
></video>
|
||||||
{% elif result.status == 'failed' %}
|
{% elif result.status == 'failed' %}
|
||||||
<div class="alert alert-error">
|
<div class="alert alert-error">
|
||||||
Generation failed: {{ result.error or 'Unknown error' }}
|
Generation failed: {{ result.error or 'Unknown error' }}
|
||||||
</div>
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
<p>Status: <strong>{{ result.status }}</strong></p>
|
<p>Status: <strong>{{ result.status }}</strong></p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
@@ -1,35 +1,35 @@
|
|||||||
{% extends "base.html" %} {% block title %}Generated Image{% endblock %} {%
|
{% extends "base.html" %} {% block title %}Generated Image{% endblock %} {%
|
||||||
block content %}
|
block content %}
|
||||||
<div class="container mx-auto px-4 py-8">
|
<div class="container mx-auto px-4 py-8">
|
||||||
<a
|
<a
|
||||||
href="{{ url_for('gallery.index') }}"
|
href="{{ url_for('gallery.index') }}"
|
||||||
class="text-blue-400 hover:underline mb-4 inline-block"
|
class="text-blue-400 hover:underline mb-4 inline-block"
|
||||||
>← Back to Gallery</a
|
>← Back to Gallery</a
|
||||||
>
|
>
|
||||||
|
|
||||||
{% if image %}
|
{% if image %}
|
||||||
<h1 class="text-2xl font-bold mb-4">Generated Image</h1>
|
<h1 class="text-2xl font-bold mb-4">Generated Image</h1>
|
||||||
<div class="bg-gray-800 rounded-lg shadow-lg overflow-hidden">
|
<div class="bg-gray-800 rounded-lg shadow-lg overflow-hidden">
|
||||||
<img
|
<img
|
||||||
src="{{ image.image_data }}"
|
src="{{ image.image_data }}"
|
||||||
alt="{{ image.prompt }}"
|
alt="{{ image.prompt }}"
|
||||||
class="w-full object-contain"
|
class="w-full object-contain"
|
||||||
/>
|
/>
|
||||||
<div class="p-6">
|
<div class="p-6">
|
||||||
<h2 class="text-xl font-semibold mb-2">Prompt</h2>
|
<h2 class="text-xl font-semibold mb-2">Prompt</h2>
|
||||||
<p class="text-gray-300 bg-gray-900 p-3 rounded-md">{{ image.prompt }}</p>
|
<p class="text-gray-300 bg-gray-900 p-3 rounded-md">{{ image.prompt }}</p>
|
||||||
<div class="mt-4 text-sm text-gray-400">
|
<div class="mt-4 text-sm text-gray-400">
|
||||||
<p><strong>Model:</strong> {{ image.model_id }}</p>
|
<p><strong>Model:</strong> {{ image.model_id }}</p>
|
||||||
<p>
|
<p>
|
||||||
<strong>Created:</strong> {{ image.created_at | fromisoformat |
|
<strong>Created:</strong> {{ image.created_at | fromisoformat |
|
||||||
humantime }}
|
humantime }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
<h1 class="text-2xl font-bold">Image not found</h1>
|
<h1 class="text-2xl font-bold">Image not found</h1>
|
||||||
<p class="text-gray-400 mt-2">Could not find details for this image.</p>
|
<p class="text-gray-400 mt-2">Could not find details for this image.</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
@@ -1,16 +1,16 @@
|
|||||||
{% extends "base.html" %} {% block title %}Log in — All You Can GET AI{%
|
{% extends "base.html" %} {% block title %}Log in — All You Can GET AI{%
|
||||||
endblock %} {% block content %}
|
endblock %} {% block content %}
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h1>Log in</h1>
|
<h1>Log in</h1>
|
||||||
<form method="post">
|
<form method="post">
|
||||||
<label for="email">Email</label>
|
<label for="email">Email</label>
|
||||||
<input id="email" name="email" type="email" required autofocus />
|
<input id="email" name="email" type="email" required autofocus />
|
||||||
|
|
||||||
<label for="password">Password</label>
|
<label for="password">Password</label>
|
||||||
<input id="password" name="password" type="password" required />
|
<input id="password" name="password" type="password" required />
|
||||||
|
|
||||||
<button type="submit">Log in</button>
|
<button type="submit">Log in</button>
|
||||||
</form>
|
</form>
|
||||||
<p>No account? <a href="{{ url_for('auth.register') }}">Register</a></p>
|
<p>No account? <a href="{{ url_for('auth.register') }}">Register</a></p>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
@@ -1,43 +1,43 @@
|
|||||||
{% extends "base.html" %} {% block title %}Profile — All You Can GET AI{%
|
{% extends "base.html" %} {% block title %}Profile — All You Can GET AI{%
|
||||||
endblock %} {% block content %}
|
endblock %} {% block content %}
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h1>Your Profile</h1>
|
<h1>Your Profile</h1>
|
||||||
|
|
||||||
<h2 class="section-title" style="margin-top: 0">Account details</h2>
|
<h2 class="section-title" style="margin-top: 0">Account details</h2>
|
||||||
<p class="text-muted" style="font-size: 0.875rem; margin-bottom: 1.5rem">
|
<p class="text-muted" style="font-size: 0.875rem; margin-bottom: 1.5rem">
|
||||||
Current email:
|
Current email:
|
||||||
<strong style="color: var(--text)">{{ user.get('email', '') }}</strong>
|
<strong style="color: var(--text)">{{ user.get('email', '') }}</strong>
|
||||||
· Role:
|
· Role:
|
||||||
<span class="role-badge role-{{ user.get('role','user') }}"
|
<span class="role-badge role-{{ user.get('role','user') }}"
|
||||||
>{{ user.get('role', 'user') }}</span
|
>{{ user.get('role', 'user') }}</span
|
||||||
>
|
>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<h2 class="section-title">Update email</h2>
|
<h2 class="section-title">Update email</h2>
|
||||||
<form method="post">
|
<form method="post">
|
||||||
<label for="email">New email</label>
|
<label for="email">New email</label>
|
||||||
<input
|
<input
|
||||||
id="email"
|
id="email"
|
||||||
name="email"
|
name="email"
|
||||||
type="email"
|
type="email"
|
||||||
placeholder="{{ user.get('email', '') }}"
|
placeholder="{{ user.get('email', '') }}"
|
||||||
/>
|
/>
|
||||||
<input type="hidden" name="password" value="" />
|
<input type="hidden" name="password" value="" />
|
||||||
<button type="submit">Save email</button>
|
<button type="submit">Save email</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<h2 class="section-title" style="margin-top: 2rem">Change password</h2>
|
<h2 class="section-title" style="margin-top: 2rem">Change password</h2>
|
||||||
<form method="post">
|
<form method="post">
|
||||||
<label for="password">New password</label>
|
<label for="password">New password</label>
|
||||||
<input
|
<input
|
||||||
id="password"
|
id="password"
|
||||||
name="password"
|
name="password"
|
||||||
type="password"
|
type="password"
|
||||||
placeholder="Enter new password"
|
placeholder="Enter new password"
|
||||||
minlength="8"
|
minlength="8"
|
||||||
/>
|
/>
|
||||||
<input type="hidden" name="email" value="" />
|
<input type="hidden" name="email" value="" />
|
||||||
<button type="submit">Save password</button>
|
<button type="submit">Save password</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
@@ -1,24 +1,24 @@
|
|||||||
{% extends "base.html" %} {% block title %}Register — All You Can GET AI{%
|
{% extends "base.html" %} {% block title %}Register — All You Can GET AI{%
|
||||||
endblock %} {% block content %}
|
endblock %} {% block content %}
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h1>Create account</h1>
|
<h1>Create account</h1>
|
||||||
<form method="post">
|
<form method="post">
|
||||||
<label for="email">Email</label>
|
<label for="email">Email</label>
|
||||||
<input id="email" name="email" type="email" required autofocus />
|
<input id="email" name="email" type="email" required autofocus />
|
||||||
|
|
||||||
<label for="password">Password</label>
|
<label for="password">Password</label>
|
||||||
<input
|
<input
|
||||||
id="password"
|
id="password"
|
||||||
name="password"
|
name="password"
|
||||||
type="password"
|
type="password"
|
||||||
required
|
required
|
||||||
minlength="8"
|
minlength="8"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<button type="submit">Register</button>
|
<button type="submit">Register</button>
|
||||||
</form>
|
</form>
|
||||||
<p>
|
<p>
|
||||||
Already have an account? <a href="{{ url_for('auth.login') }}">Log in</a>
|
Already have an account? <a href="{{ url_for('auth.login') }}">Log in</a>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
@@ -1,40 +1,40 @@
|
|||||||
{% extends "base.html" %} {% block title %}Uploaded Image{% endblock %} {% block
|
{% extends "base.html" %} {% block title %}Uploaded Image{% endblock %} {% block
|
||||||
content %}
|
content %}
|
||||||
<div class="container mx-auto px-4 py-8">
|
<div class="container mx-auto px-4 py-8">
|
||||||
<a
|
<a
|
||||||
href="{{ url_for('gallery.index') }}"
|
href="{{ url_for('gallery.index') }}"
|
||||||
class="text-blue-400 hover:underline mb-4 inline-block"
|
class="text-blue-400 hover:underline mb-4 inline-block"
|
||||||
>← Back to Gallery</a
|
>← Back to Gallery</a
|
||||||
>
|
>
|
||||||
|
|
||||||
{% if image %}
|
{% if image %}
|
||||||
<h1 class="text-2xl font-bold mb-4">Uploaded Image</h1>
|
<h1 class="text-2xl font-bold mb-4">Uploaded Image</h1>
|
||||||
<div class="bg-gray-800 rounded-lg shadow-lg overflow-hidden">
|
<div class="bg-gray-800 rounded-lg shadow-lg overflow-hidden">
|
||||||
<img
|
<img
|
||||||
src="{{ url_for('gallery.serve_uploaded_image', image_id=image.id) }}"
|
src="{{ url_for('gallery.serve_uploaded_image', image_id=image.id) }}"
|
||||||
alt="{{ image.filename }}"
|
alt="{{ image.filename }}"
|
||||||
class="w-full object-contain"
|
class="w-full object-contain"
|
||||||
/>
|
/>
|
||||||
<div class="p-6">
|
<div class="p-6">
|
||||||
<h2 class="text-xl font-semibold mb-2">Details</h2>
|
<h2 class="text-xl font-semibold mb-2">Details</h2>
|
||||||
<div class="mt-4 text-sm text-gray-400">
|
<div class="mt-4 text-sm text-gray-400">
|
||||||
<p><strong>Filename:</strong> {{ image.filename }}</p>
|
<p><strong>Filename:</strong> {{ image.filename }}</p>
|
||||||
<p><strong>Content Type:</strong> {{ image.content_type }}</p>
|
<p><strong>Content Type:</strong> {{ image.content_type }}</p>
|
||||||
<p>
|
<p>
|
||||||
<strong>Size:</strong> {{ (image.size_bytes / 1024) | round(2) }} KB
|
<strong>Size:</strong> {{ (image.size_bytes / 1024) | round(2) }} KB
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
<strong>Uploaded:</strong> {{ image.created_at | fromisoformat |
|
<strong>Uploaded:</strong> {{ image.created_at | fromisoformat |
|
||||||
humantime }}
|
humantime }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
<h1 class="text-2xl font-bold">Image not found</h1>
|
<h1 class="text-2xl font-bold">Image not found</h1>
|
||||||
<p class="text-gray-400 mt-2">
|
<p class="text-gray-400 mt-2">
|
||||||
Could not find details for this uploaded image.
|
Could not find details for this uploaded image.
|
||||||
</p>
|
</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
@@ -1,77 +1,77 @@
|
|||||||
{% extends "base.html" %} {% block title %}Generated Video{% endblock %} {%
|
{% extends "base.html" %} {% block title %}Generated Video{% endblock %} {%
|
||||||
block content %}
|
block content %}
|
||||||
<div class="container mx-auto px-4 py-8">
|
<div class="container mx-auto px-4 py-8">
|
||||||
<a
|
<a
|
||||||
href="{{ url_for('gallery.index') }}"
|
href="{{ url_for('gallery.index') }}"
|
||||||
class="text-blue-400 hover:underline mb-4 inline-block"
|
class="text-blue-400 hover:underline mb-4 inline-block"
|
||||||
>← Back to Gallery</a
|
>← Back to Gallery</a
|
||||||
>
|
>
|
||||||
|
|
||||||
{% if video %}
|
{% if video %}
|
||||||
<h1 class="text-2xl font-bold mb-4">Video Generation Job</h1>
|
<h1 class="text-2xl font-bold mb-4">Video Generation Job</h1>
|
||||||
<div class="bg-gray-800 rounded-lg shadow-lg overflow-hidden">
|
<div class="bg-gray-800 rounded-lg shadow-lg overflow-hidden">
|
||||||
{% if video.status == 'completed' and video.video_url %}
|
{% if video.status == 'completed' and video.video_url %}
|
||||||
<video src="{{ video.video_url }}" controls class="w-full"></video>
|
<video src="{{ video.video_url }}" controls class="w-full"></video>
|
||||||
{% elif video.status in ('queued', 'processing') %}
|
{% elif video.status in ('queued', 'processing') %}
|
||||||
<div
|
<div
|
||||||
class="w-full bg-black aspect-video flex flex-col items-center justify-center p-6 text-center"
|
class="w-full bg-black aspect-video flex flex-col items-center justify-center p-6 text-center"
|
||||||
id="video-poll-status"
|
id="video-poll-status"
|
||||||
data-video-id="{{ video.id }}"
|
data-video-id="{{ video.id }}"
|
||||||
>
|
>
|
||||||
<p class="text-xl font-semibold">
|
<p class="text-xl font-semibold">
|
||||||
Status: <strong id="poll-status-text">{{ video.status }}</strong>
|
Status: <strong id="poll-status-text">{{ video.status }}</strong>
|
||||||
</p>
|
</p>
|
||||||
<p class="text-gray-400 mt-2">
|
<p class="text-gray-400 mt-2">
|
||||||
Your video is being processed. This page will update automatically when
|
Your video is being processed. This page will update automatically when
|
||||||
it's ready.
|
it's ready.
|
||||||
</p>
|
</p>
|
||||||
<div class="spinner mt-4"></div>
|
<div class="spinner mt-4"></div>
|
||||||
<button
|
<button
|
||||||
id="cancel-video-btn"
|
id="cancel-video-btn"
|
||||||
class="mt-4 px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded-md text-sm"
|
class="mt-4 px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded-md text-sm"
|
||||||
>
|
>
|
||||||
Cancel Job
|
Cancel Job
|
||||||
</button>
|
</button>
|
||||||
<p id="cancel-msg" class="text-sm mt-2 hidden"></p>
|
<p id="cancel-msg" class="text-sm mt-2 hidden"></p>
|
||||||
</div>
|
</div>
|
||||||
{% elif video.status == 'failed' %}
|
{% elif video.status == 'failed' %}
|
||||||
<div
|
<div
|
||||||
class="w-full bg-black aspect-video flex flex-col items-center justify-center p-6 text-center"
|
class="w-full bg-black aspect-video flex flex-col items-center justify-center p-6 text-center"
|
||||||
>
|
>
|
||||||
<p class="text-xl font-semibold text-red-500">Generation Failed</p>
|
<p class="text-xl font-semibold text-red-500">Generation Failed</p>
|
||||||
<p class="text-gray-400 mt-2">
|
<p class="text-gray-400 mt-2">
|
||||||
{{ video.error or 'An unknown error occurred.' }}
|
{{ video.error or 'An unknown error occurred.' }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
<div
|
<div
|
||||||
class="w-full bg-black aspect-video flex flex-col items-center justify-center p-6 text-center"
|
class="w-full bg-black aspect-video flex flex-col items-center justify-center p-6 text-center"
|
||||||
>
|
>
|
||||||
<p class="text-xl font-semibold">Video Not Available</p>
|
<p class="text-xl font-semibold">Video Not Available</p>
|
||||||
<p class="text-gray-400 mt-2">Status: {{ video.status }}</p>
|
<p class="text-gray-400 mt-2">Status: {{ video.status }}</p>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<div class="p-6">
|
<div class="p-6">
|
||||||
<h2 class="text-xl font-semibold mb-2">Prompt</h2>
|
<h2 class="text-xl font-semibold mb-2">Prompt</h2>
|
||||||
<p class="text-gray-300 bg-gray-900 p-3 rounded-md">{{ video.prompt }}</p>
|
<p class="text-gray-300 bg-gray-900 p-3 rounded-md">{{ video.prompt }}</p>
|
||||||
<div class="mt-4 text-sm text-gray-400">
|
<div class="mt-4 text-sm text-gray-400">
|
||||||
<p><strong>Model:</strong> {{ video.model_id }}</p>
|
<p><strong>Model:</strong> {{ video.model_id }}</p>
|
||||||
<p><strong>Job ID:</strong> <code>{{ video.job_id }}</code></p>
|
<p><strong>Job ID:</strong> <code>{{ video.job_id }}</code></p>
|
||||||
<p>
|
<p>
|
||||||
<strong>Created:</strong> {{ video.created_at | fromisoformat |
|
<strong>Created:</strong> {{ video.created_at | fromisoformat |
|
||||||
humantime }}
|
humantime }}
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
<strong>Last Update:</strong> {{ video.updated_at | fromisoformat |
|
<strong>Last Update:</strong> {{ video.updated_at | fromisoformat |
|
||||||
humantime }}
|
humantime }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
<h1 class="text-2xl font-bold">Video job not found</h1>
|
<h1 class="text-2xl font-bold">Video job not found</h1>
|
||||||
<p class="text-gray-400 mt-2">Could not find details for this video job.</p>
|
<p class="text-gray-400 mt-2">Could not find details for this video job.</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
@@ -1,2 +1,2 @@
|
|||||||
pytest
|
pytest
|
||||||
pytest-mock
|
pytest-mock
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
Flask
|
Flask
|
||||||
gunicorn
|
gunicorn
|
||||||
httpx
|
httpx
|
||||||
itsdangerous
|
itsdangerous
|
||||||
Jinja2
|
Jinja2
|
||||||
MarkupSafe
|
MarkupSafe
|
||||||
Werkzeug
|
Werkzeug
|
||||||
+673
-644
File diff suppressed because it is too large.
Load diff
+73
-73
@@ -1,73 +1,73 @@
|
|||||||
# Nginx reverse proxy configuration for Coolify deployment
|
# Nginx reverse proxy configuration for Coolify deployment
|
||||||
# Place this in /etc/nginx/conf.d/ai.allucanget.biz.conf or use Coolify's built-in proxy
|
# Place this in /etc/nginx/conf.d/ai.allucanget.biz.conf or use Coolify's built-in proxy
|
||||||
|
|
||||||
# Backend API proxy
|
# Backend API proxy
|
||||||
upstream backend {
|
upstream backend {
|
||||||
server 127.0.0.1:12015;
|
server 127.0.0.1:12015;
|
||||||
}
|
}
|
||||||
|
|
||||||
# Frontend proxy
|
# Frontend proxy
|
||||||
upstream frontend {
|
upstream frontend {
|
||||||
server 127.0.0.1:12016;
|
server 127.0.0.1:12016;
|
||||||
}
|
}
|
||||||
|
|
||||||
server {
|
server {
|
||||||
listen 80;
|
listen 80;
|
||||||
server_name ai.allucanget.biz www.ai.allucanget.biz;
|
server_name ai.allucanget.biz www.ai.allucanget.biz;
|
||||||
|
|
||||||
# Redirect HTTP to HTTPS
|
# Redirect HTTP to HTTPS
|
||||||
return 301 https://$host$request_uri;
|
return 301 https://$host$request_uri;
|
||||||
}
|
}
|
||||||
|
|
||||||
server {
|
server {
|
||||||
listen 443 ssl http2;
|
listen 443 ssl http2;
|
||||||
server_name ai.allucanget.biz www.ai.allucanget.biz;
|
server_name ai.allucanget.biz www.ai.allucanget.biz;
|
||||||
|
|
||||||
# SSL configuration (managed by Let's Encrypt / Certbot)
|
# SSL configuration (managed by Let's Encrypt / Certbot)
|
||||||
ssl_certificate /etc/letsencrypt/live/ai.allucanget.biz/fullchain.pem;
|
ssl_certificate /etc/letsencrypt/live/ai.allucanget.biz/fullchain.pem;
|
||||||
ssl_certificate_key /etc/letsencrypt/live/ai.allucanget.biz/privkey.pem;
|
ssl_certificate_key /etc/letsencrypt/live/ai.allucanget.biz/privkey.pem;
|
||||||
ssl_protocols TLSv1.2 TLSv1.3;
|
ssl_protocols TLSv1.2 TLSv1.3;
|
||||||
ssl_ciphers HIGH:!aNULL:!MD5;
|
ssl_ciphers HIGH:!aNULL:!MD5;
|
||||||
|
|
||||||
# Security headers
|
# Security headers
|
||||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||||
add_header X-Content-Type-Options "nosniff" always;
|
add_header X-Content-Type-Options "nosniff" always;
|
||||||
add_header X-XSS-Protection "1; mode=block" always;
|
add_header X-XSS-Protection "1; mode=block" always;
|
||||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||||
|
|
||||||
# Backend API proxy
|
# Backend API proxy
|
||||||
location /api/ {
|
location /api/ {
|
||||||
proxy_pass http://backend;
|
proxy_pass http://backend;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
|
||||||
# WebSocket support (if needed in future)
|
# WebSocket support (if needed in future)
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
proxy_set_header Upgrade $http_upgrade;
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
proxy_set_header Connection "upgrade";
|
proxy_set_header Connection "upgrade";
|
||||||
}
|
}
|
||||||
|
|
||||||
# Frontend proxy
|
# Frontend proxy
|
||||||
location / {
|
location / {
|
||||||
proxy_pass http://frontend;
|
proxy_pass http://frontend;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
|
||||||
# Static files caching
|
# Static files caching
|
||||||
location ~* \.(css|js|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
|
location ~* \.(css|js|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
|
||||||
proxy_pass http://frontend;
|
proxy_pass http://frontend;
|
||||||
expires 30d;
|
expires 30d;
|
||||||
add_header Cache-Control "public, immutable";
|
add_header Cache-Control "public, immutable";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
# Health check endpoint
|
# Health check endpoint
|
||||||
location /health {
|
location /health {
|
||||||
proxy_pass http://backend;
|
proxy_pass http://backend;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+3
-3
@@ -1,3 +1,3 @@
|
|||||||
[tool.pytest.ini_options]
|
[tool.pytest.ini_options]
|
||||||
asyncio_mode = "auto"
|
asyncio_mode = "auto"
|
||||||
testpaths = ["backend/tests", "frontend/tests"]
|
testpaths = ["backend/tests", "frontend/tests"]
|
||||||
+41
-41
@@ -1,41 +1,41 @@
|
|||||||
anyio
|
anyio
|
||||||
bcrypt==4.0.1
|
bcrypt==4.0.1
|
||||||
blinker
|
blinker
|
||||||
certifi
|
certifi
|
||||||
cffi
|
cffi
|
||||||
cryptography
|
cryptography
|
||||||
dnspython
|
dnspython
|
||||||
duckdb
|
duckdb
|
||||||
ecdsa
|
ecdsa
|
||||||
email-validator
|
email-validator
|
||||||
exceptiongroup
|
exceptiongroup
|
||||||
fastapi
|
fastapi
|
||||||
Flask
|
Flask
|
||||||
h11
|
h11
|
||||||
httpcore
|
httpcore
|
||||||
httpx
|
httpx
|
||||||
idna
|
idna
|
||||||
iniconfig
|
iniconfig
|
||||||
itsdangerous
|
itsdangerous
|
||||||
Jinja2
|
Jinja2
|
||||||
MarkupSafe
|
MarkupSafe
|
||||||
packaging
|
packaging
|
||||||
passlib==1.7.4
|
passlib==1.7.4
|
||||||
pluggy
|
pluggy
|
||||||
pyasn1
|
pyasn1
|
||||||
pycparser
|
pycparser
|
||||||
pydantic
|
pydantic
|
||||||
pydantic_core
|
pydantic_core
|
||||||
Pygments
|
Pygments
|
||||||
pytest
|
pytest
|
||||||
pytest-asyncio
|
pytest-asyncio
|
||||||
python-dotenv
|
python-dotenv
|
||||||
python-jose
|
python-jose
|
||||||
rsa
|
rsa
|
||||||
six
|
six
|
||||||
starlette
|
starlette
|
||||||
tomli
|
tomli
|
||||||
typing-inspection
|
typing-inspection
|
||||||
typing_extensions
|
typing_extensions
|
||||||
uvicorn
|
uvicorn
|
||||||
Werkzeug
|
Werkzeug
|
||||||
Reference in new issue
Block a user