feat: V1.0.1 - Setup page, Docker, README
Add password-protected setup page (/api/setup) for DB initialization, admin creation, and demo data seeding. Dockerize the full stack with server, client, nginx reverse proxy, and MySQL services. Add project README with architecture overview, quick start, and VPS deployment guide. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,15 @@
|
||||
__pycache__
|
||||
*.pyc
|
||||
*.pyo
|
||||
.pytest_cache
|
||||
.git
|
||||
.gitignore
|
||||
.env
|
||||
*.md
|
||||
node_modules
|
||||
.venv
|
||||
venv
|
||||
*.egg-info
|
||||
.coverage
|
||||
htmlcov
|
||||
.mypy_cache
|
||||
@@ -25,6 +25,14 @@ API_SERVER_URL=http://localhost:8000
|
||||
UPLOAD_DIR=server/uploads
|
||||
MAX_UPLOAD_SIZE_MB=50
|
||||
|
||||
# --- Setup Page ---
|
||||
SETUP_PASSWORD= # Password per /api/setup, vuoto = disabilitato
|
||||
|
||||
# --- Docker ---
|
||||
DB_ROOT_PASSWORD=root_password_change_me
|
||||
NGINX_PORT=80
|
||||
NGINX_SSL_PORT=443
|
||||
|
||||
# --- HTTPS (Production) ---
|
||||
# SSL_CERTFILE=/path/to/cert.pem
|
||||
# SSL_KEYFILE=/path/to/key.pem
|
||||
|
||||
@@ -0,0 +1,392 @@
|
||||
# TieMeasureFlow by Tielogic
|
||||
|
||||
Sistema di gestione task per misurazioni con calibro manuale. Soluzione tablet-first, multi-ruolo, con statistiche SPC (Statistical Process Control) integrate.
|
||||
|
||||
## Architettura
|
||||
|
||||
```
|
||||
┌─────────────┐ ┌──────────────┐ ┌──────────┐
|
||||
│ Browser / │────▶│ Nginx │────▶│ MySQL │
|
||||
│ Tablet │ │ (Reverse │ │ 8.0 │
|
||||
└─────────────┘ │ Proxy) │ └────▲─────┘
|
||||
└──┬───────┬───┘ │
|
||||
│ │ │
|
||||
┌─────▼──┐ ┌──▼──────┐ │
|
||||
│ Flask │ │ FastAPI │───────┘
|
||||
│ Client │ │ Server │
|
||||
│ :5000 │ │ :8000 │
|
||||
└─────────┘ └─────────┘
|
||||
```
|
||||
|
||||
Il sistema è composto da:
|
||||
- **Flask Client**: Frontend tablet-oriented con interfaccia responsive
|
||||
- **FastAPI Server**: Backend API REST con autenticazione via API key
|
||||
- **Nginx**: Reverse proxy per routing unificato
|
||||
- **MySQL**: Database relazionale per persistenza dati
|
||||
|
||||
## Stack Tecnologico
|
||||
|
||||
### Backend (Server)
|
||||
- **FastAPI**: Framework asincrono per API REST
|
||||
- **SQLAlchemy 2.0**: ORM con supporto async/await
|
||||
- **asyncmy**: Driver MySQL asincrono
|
||||
- **MySQL 8.0**: Database relazionale
|
||||
- **Alembic**: Gestione migrazioni database
|
||||
- **WeasyPrint**: Generazione report PDF da HTML
|
||||
- **Kaleido**: Export grafici Plotly in formato SVG
|
||||
|
||||
### Frontend (Client)
|
||||
- **Flask 3.0**: Framework web per rendering server-side
|
||||
- **Jinja2**: Template engine
|
||||
- **htmx 2.0**: Interazioni AJAX dichiarative
|
||||
- **Alpine.js 3.x**: Reattività leggera lato client
|
||||
- **TailwindCSS 3.x**: Framework CSS utility-first
|
||||
- **Plotly.js**: Libreria grafici interattivi
|
||||
- **Fabric.js 6.x**: Editor annotazioni su disegni tecnici
|
||||
- **Flask-Babel**: Sistema i18n per IT/EN
|
||||
|
||||
### Sicurezza e Auth
|
||||
- **API Key**: Autenticazione via header X-API-Key
|
||||
- **Rate Limiting**: Protezione endpoint sensibili
|
||||
- **CORS**: Configurazione per deployment multi-dominio
|
||||
|
||||
## Quick Start con Docker
|
||||
|
||||
Il metodo raccomandato per installare e avviare TieMeasureFlow è tramite Docker Compose:
|
||||
|
||||
```bash
|
||||
# 1. Clone del repository
|
||||
git clone <repository-url>
|
||||
cd TieMeasureFlow
|
||||
|
||||
# 2. Configurazione ambiente
|
||||
cp .env.example .env
|
||||
# Modifica .env con le tue credenziali (DB, password admin, ecc.)
|
||||
|
||||
# 3. Avvio dei servizi
|
||||
docker compose up -d
|
||||
|
||||
# 4. Setup iniziale
|
||||
# Apri http://localhost/api/setup nel browser
|
||||
# Usa SETUP_PASSWORD configurata in .env per:
|
||||
# - Inizializzare il database
|
||||
# - Creare l'utente admin
|
||||
# - (Opzionale) Caricare dati demo
|
||||
|
||||
# 5. Accesso all'applicazione
|
||||
# Apri http://localhost nel browser
|
||||
# Login con credenziali admin create nel setup
|
||||
```
|
||||
|
||||
L'applicazione sarà disponibile su:
|
||||
- Frontend: http://localhost
|
||||
- API Backend: http://localhost/api
|
||||
- Setup Page: http://localhost/api/setup
|
||||
|
||||
## Setup Manuale (Senza Docker)
|
||||
|
||||
Per chi preferisce installare senza Docker:
|
||||
|
||||
### Requisiti
|
||||
- Python 3.11 o superiore
|
||||
- Node.js 18 o superiore
|
||||
- MySQL 8.0
|
||||
|
||||
### Installazione
|
||||
|
||||
#### 1. Database MySQL
|
||||
```bash
|
||||
# Crea database
|
||||
mysql -u root -p
|
||||
CREATE DATABASE tiemeasureflow CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
CREATE USER 'tielogic'@'localhost' IDENTIFIED BY 'your_password';
|
||||
GRANT ALL PRIVILEGES ON tiemeasureflow.* TO 'tielogic'@'localhost';
|
||||
FLUSH PRIVILEGES;
|
||||
```
|
||||
|
||||
#### 2. Configurazione
|
||||
```bash
|
||||
# Copia template configurazione
|
||||
cp .env.example .env
|
||||
|
||||
# Modifica .env con:
|
||||
# - Credenziali database MySQL
|
||||
# - Chiavi segrete
|
||||
# - Password admin
|
||||
```
|
||||
|
||||
#### 3. Server (FastAPI Backend)
|
||||
```bash
|
||||
cd server
|
||||
|
||||
# Crea virtual environment
|
||||
python -m venv venv
|
||||
source venv/bin/activate # Su Windows: venv\Scripts\activate
|
||||
|
||||
# Installa dipendenze
|
||||
pip install -r requirements.txt
|
||||
|
||||
# Esegui migrazioni database
|
||||
alembic upgrade head
|
||||
|
||||
# Avvia server
|
||||
uvicorn main:app --reload --host 0.0.0.0 --port 8000
|
||||
```
|
||||
|
||||
#### 4. Client (Flask Frontend)
|
||||
```bash
|
||||
cd client
|
||||
|
||||
# Crea virtual environment
|
||||
python -m venv venv
|
||||
source venv/bin/activate # Su Windows: venv\Scripts\activate
|
||||
|
||||
# Installa dipendenze
|
||||
pip install -r requirements.txt
|
||||
|
||||
# Build TailwindCSS
|
||||
npx tailwindcss -i static/css/input.css -o static/css/tailwind.css --watch
|
||||
|
||||
# In un altro terminale: Compila traduzioni
|
||||
cd client
|
||||
pybabel compile -d translations
|
||||
|
||||
# Avvia client
|
||||
flask run --host 0.0.0.0 --port 5000
|
||||
```
|
||||
|
||||
Accesso:
|
||||
- Frontend: http://localhost:5000
|
||||
- Backend API: http://localhost:8000
|
||||
|
||||
## Pagina di Setup
|
||||
|
||||
TieMeasureFlow include una pagina di setup protetta accessibile su `/api/setup`:
|
||||
|
||||
- **Protezione**: Richiede SETUP_PASSWORD configurata in .env
|
||||
- **Disabilitazione**: Se SETUP_PASSWORD è vuota/assente, endpoint disabilitato
|
||||
- **Accesso**:
|
||||
- Via nginx (produzione): http://localhost/api/setup
|
||||
- Diretto (sviluppo): http://localhost:8000/api/setup
|
||||
|
||||
### Funzionalità Setup
|
||||
- **Initialize Database**: Crea tutte le tabelle necessarie
|
||||
- **Create Admin User**: Crea utente amministratore con credenziali da .env
|
||||
- **Seed Demo Data**: Carica dati di esempio (ricette, misurazioni, utenti)
|
||||
- **Reset Database**: Elimina e ricrea tutte le tabelle (attenzione: cancella tutti i dati!)
|
||||
|
||||
## Struttura Progetto
|
||||
|
||||
```
|
||||
TieMeasureFlow/
|
||||
├── server/ # FastAPI Backend (API REST)
|
||||
│ ├── main.py # Entry point applicazione
|
||||
│ ├── config.py # Configurazione da variabili .env
|
||||
│ ├── database.py # SQLAlchemy async engine e sessioni
|
||||
│ ├── models/ # ORM models (User, Recipe, Measurement, ecc.)
|
||||
│ ├── schemas/ # Pydantic schemas per validazione I/O
|
||||
│ ├── routers/ # API endpoints organizzati per dominio
|
||||
│ ├── services/ # Business logic (SPC, PDF, auth, ecc.)
|
||||
│ ├── middleware/ # Auth, logging, rate limiting
|
||||
│ ├── migrations/ # Alembic migration scripts
|
||||
│ └── templates/ # Template HTML per setup page
|
||||
├── client/ # Flask Frontend (UI tablet)
|
||||
│ ├── app.py # Entry point applicazione
|
||||
│ ├── blueprints/ # Route blueprints (auth, maker, measurement, metrologist)
|
||||
│ ├── services/ # API client wrapper per chiamate a FastAPI
|
||||
│ ├── templates/ # Jinja2 templates per rendering pagine
|
||||
│ ├── static/ # Asset statici (CSS, JS, immagini)
|
||||
│ │ ├── css/ # TailwindCSS compiled output
|
||||
│ │ ├── js/ # Alpine.js components e utility
|
||||
│ │ └── images/ # Loghi, icone, placeholder
|
||||
│ └── translations/ # File i18n per IT/EN (Flask-Babel)
|
||||
├── nginx/ # Configurazione reverse proxy
|
||||
│ └── nginx.conf # Routing unificato client/server
|
||||
├── docker-compose.yml # Orchestrazione servizi Docker
|
||||
├── .env.example # Template variabili d'ambiente
|
||||
└── docs/ # Documentazione tecnica
|
||||
```
|
||||
|
||||
## Ruoli Utente
|
||||
|
||||
TieMeasureFlow supporta tre ruoli principali, combinabili:
|
||||
|
||||
### Maker
|
||||
Responsabile della creazione e modifica delle ricette di misurazione:
|
||||
- Caricamento disegni tecnici (PDF)
|
||||
- Annotazioni grafiche con Fabric.js (linee, frecce, testi, ellissi)
|
||||
- Definizione task e subtask di misurazione
|
||||
- Configurazione tolleranze (UTL, UWL, LWL, LTL)
|
||||
- Gestione versioni immutabili (copy-on-write)
|
||||
|
||||
### MeasurementTec
|
||||
Operatore che esegue le misurazioni:
|
||||
- Accesso rapido via barcode scanner
|
||||
- Interfaccia task-driven per guida passo-passo
|
||||
- Input valori da calibro USB HID (rilevamento automatico burst)
|
||||
- Validazione real-time pass/fail su tolleranze
|
||||
- Salvataggio annotazioni fotografiche
|
||||
|
||||
### Metrologist
|
||||
Analista qualità con accesso alle statistiche:
|
||||
- Dashboard SPC con grafici Plotly.js (X-bar, R, Cp, Cpk)
|
||||
- Filtri multi-dimensionali (ricetta, utente, periodo, stato)
|
||||
- Export report PDF con WeasyPrint
|
||||
- Analisi tendenze e capability del processo
|
||||
|
||||
I ruoli sono configurabili in fase di creazione utente e possono essere combinati. Flag `is_admin` separato per gestione sistema.
|
||||
|
||||
## Guida VPS Deployment
|
||||
|
||||
### Requisiti VPS
|
||||
- 2GB RAM minimo (4GB raccomandati)
|
||||
- 20GB spazio disco
|
||||
- Ubuntu 22.04 LTS o superiore
|
||||
- Accesso SSH con privilegi sudo
|
||||
|
||||
### Installazione Docker
|
||||
|
||||
```bash
|
||||
# Installa Docker
|
||||
curl -fsSL https://get.docker.com | sh
|
||||
|
||||
# Aggiungi utente al gruppo docker
|
||||
sudo usermod -aG docker $USER
|
||||
|
||||
# Installa Docker Compose (se non incluso)
|
||||
sudo apt update
|
||||
sudo apt install docker-compose-plugin
|
||||
|
||||
# Ricarica gruppi (oppure logout/login)
|
||||
newgrp docker
|
||||
```
|
||||
|
||||
### Deploy Applicazione
|
||||
|
||||
```bash
|
||||
# Clone repository
|
||||
git clone <repository-url>
|
||||
cd TieMeasureFlow
|
||||
|
||||
# Configura ambiente (IMPORTANTE: usa password sicure!)
|
||||
cp .env.example .env
|
||||
nano .env
|
||||
|
||||
# Modifica almeno:
|
||||
# - MYSQL_ROOT_PASSWORD
|
||||
# - MYSQL_PASSWORD
|
||||
# - SECRET_KEY
|
||||
# - API_KEY_ADMIN
|
||||
# - SETUP_PASSWORD
|
||||
|
||||
# Avvia servizi
|
||||
docker compose up -d
|
||||
|
||||
# Verifica stato
|
||||
docker compose ps
|
||||
docker compose logs -f
|
||||
```
|
||||
|
||||
### Configurazione Firewall
|
||||
|
||||
```bash
|
||||
# Abilita UFW
|
||||
sudo ufw allow OpenSSH
|
||||
sudo ufw allow 80/tcp
|
||||
sudo ufw allow 443/tcp
|
||||
sudo ufw enable
|
||||
|
||||
# Verifica regole
|
||||
sudo ufw status
|
||||
```
|
||||
|
||||
### Configurazione DNS
|
||||
|
||||
1. Accedi al pannello del tuo provider DNS
|
||||
2. Crea un record A che punta il tuo dominio all'IP pubblico del VPS:
|
||||
```
|
||||
Type: A
|
||||
Name: @ (o www)
|
||||
Value: <VPS_IP>
|
||||
TTL: 3600
|
||||
```
|
||||
3. Attendi propagazione DNS (5-30 minuti)
|
||||
|
||||
### SSL con Certbot (HTTPS)
|
||||
|
||||
```bash
|
||||
# Installa Certbot
|
||||
sudo apt update
|
||||
sudo apt install certbot
|
||||
|
||||
# Genera certificati (usa il tuo dominio)
|
||||
sudo certbot certonly --webroot -w /var/www/certbot -d yourdomain.com -d www.yourdomain.com
|
||||
|
||||
# Decommentare linee SSL in nginx/nginx.conf
|
||||
nano nginx/nginx.conf
|
||||
# Rimuovi commenti (#) dalle righe:
|
||||
# listen 443 ssl http2;
|
||||
# ssl_certificate /etc/letsencrypt/live/yourdomain.com/fullchain.pem;
|
||||
# ssl_certificate_key /etc/letsencrypt/live/yourdomain.com/privkey.pem;
|
||||
|
||||
# Riavvia nginx
|
||||
docker compose restart nginx
|
||||
|
||||
# Verifica rinnovo automatico
|
||||
sudo certbot renew --dry-run
|
||||
```
|
||||
|
||||
Accesso produzione: https://yourdomain.com
|
||||
|
||||
## Comandi Utili
|
||||
|
||||
| Comando | Descrizione |
|
||||
|---------|-------------|
|
||||
| `docker compose up -d` | Avvia tutti i servizi in background |
|
||||
| `docker compose down` | Ferma e rimuove tutti i container |
|
||||
| `docker compose logs -f server` | Segui log del server in tempo reale |
|
||||
| `docker compose logs -f client` | Segui log del client in tempo reale |
|
||||
| `docker compose ps` | Mostra stato di tutti i servizi |
|
||||
| `docker compose exec server alembic upgrade head` | Esegui migrazioni database |
|
||||
| `docker compose exec server alembic revision --autogenerate -m "description"` | Genera nuova migrazione |
|
||||
| `docker compose restart nginx` | Riavvia solo nginx |
|
||||
| `docker compose build --no-cache` | Ricostruisci tutte le immagini senza cache |
|
||||
| `docker compose exec mysql mysql -u root -p tiemeasureflow` | Accedi alla CLI MySQL |
|
||||
| `docker compose exec server python -c "from config import settings; print(settings.database_url)"` | Verifica configurazione server |
|
||||
|
||||
## Sviluppo
|
||||
|
||||
### Hot Reload
|
||||
- Server FastAPI: Uvicorn con `--reload` flag (automatico in docker-compose)
|
||||
- Client Flask: Flask debug mode (automatico in docker-compose)
|
||||
- TailwindCSS: `--watch` flag per rebuild automatico
|
||||
|
||||
### Migrazioni Database
|
||||
```bash
|
||||
# Genera nuova migrazione
|
||||
cd server
|
||||
alembic revision --autogenerate -m "Descrizione modifica"
|
||||
|
||||
# Applica migrazioni
|
||||
alembic upgrade head
|
||||
|
||||
# Rollback ultima migrazione
|
||||
alembic downgrade -1
|
||||
```
|
||||
|
||||
### Testing
|
||||
```bash
|
||||
# Test server (da implementare)
|
||||
cd server
|
||||
pytest
|
||||
|
||||
# Test client (da implementare)
|
||||
cd client
|
||||
pytest
|
||||
```
|
||||
|
||||
## Licenza
|
||||
|
||||
Proprietary - Tielogic. All rights reserved.
|
||||
|
||||
Questo software è di proprietà esclusiva di Tielogic ed è protetto dalle leggi sul copyright. Non è consentita la distribuzione, modifica o utilizzo senza autorizzazione scritta.
|
||||
@@ -0,0 +1,25 @@
|
||||
FROM python:3.11-slim AS base
|
||||
|
||||
# Installa Node.js per Tailwind CSS build
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
curl \
|
||||
&& curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \
|
||||
&& apt-get install -y --no-install-recommends nodejs \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt gunicorn
|
||||
|
||||
COPY . .
|
||||
|
||||
# Build Tailwind CSS
|
||||
RUN npx tailwindcss -i static/css/input.css -o static/css/tailwind.css --minify
|
||||
|
||||
# Compile Flask-Babel translations
|
||||
RUN pybabel compile -d translations
|
||||
|
||||
EXPOSE 5000
|
||||
|
||||
CMD ["gunicorn", "--workers", "2", "--bind", "0.0.0.0:5000", "--factory", "app:create_app"]
|
||||
+65
-9
@@ -1,21 +1,17 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
mysql:
|
||||
image: mysql:8.0
|
||||
container_name: tmflow-mysql
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
MYSQL_ROOT_PASSWORD: root_password_change_me
|
||||
MYSQL_DATABASE: tiemeasureflow
|
||||
MYSQL_USER: tmflow
|
||||
MYSQL_PASSWORD: change_me_in_production
|
||||
ports:
|
||||
- "3306:3306"
|
||||
MYSQL_ROOT_PASSWORD: ${DB_ROOT_PASSWORD:-root_password_change_me}
|
||||
MYSQL_DATABASE: ${DB_NAME:-tiemeasureflow}
|
||||
MYSQL_USER: ${DB_USER:-tmflow}
|
||||
MYSQL_PASSWORD: ${DB_PASSWORD:-change_me_in_production}
|
||||
volumes:
|
||||
- mysql_data:/var/lib/mysql
|
||||
command: >
|
||||
--default-authentication-plugin=mysql_native_password
|
||||
--authentication-policy=mysql_native_password
|
||||
--character-set-server=utf8mb4
|
||||
--collation-server=utf8mb4_unicode_ci
|
||||
healthcheck:
|
||||
@@ -23,7 +19,67 @@ services:
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
networks:
|
||||
- tmflow-net
|
||||
|
||||
server:
|
||||
build:
|
||||
context: ./server
|
||||
dockerfile: Dockerfile
|
||||
container_name: tmflow-server
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
DB_HOST: mysql
|
||||
UPLOAD_DIR: uploads
|
||||
volumes:
|
||||
- upload_data:/app/uploads
|
||||
depends_on:
|
||||
mysql:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
- tmflow-net
|
||||
|
||||
client:
|
||||
build:
|
||||
context: ./client
|
||||
dockerfile: Dockerfile
|
||||
container_name: tmflow-client
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
API_SERVER_URL: http://server:8000
|
||||
depends_on:
|
||||
- server
|
||||
networks:
|
||||
- tmflow-net
|
||||
|
||||
nginx:
|
||||
image: nginx:alpine
|
||||
container_name: tmflow-nginx
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "${NGINX_PORT:-80}:80"
|
||||
- "${NGINX_SSL_PORT:-443}:443"
|
||||
volumes:
|
||||
- ./nginx/nginx.conf:/etc/nginx/conf.d/default.conf:ro
|
||||
# Uncomment for SSL:
|
||||
# - ./certbot/conf:/etc/letsencrypt:ro
|
||||
# - ./certbot/www:/var/www/certbot:ro
|
||||
depends_on:
|
||||
- server
|
||||
- client
|
||||
networks:
|
||||
- tmflow-net
|
||||
|
||||
volumes:
|
||||
mysql_data:
|
||||
driver: local
|
||||
upload_data:
|
||||
driver: local
|
||||
|
||||
networks:
|
||||
tmflow-net:
|
||||
driver: bridge
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
upstream server_backend {
|
||||
server server:8000;
|
||||
}
|
||||
|
||||
upstream client_frontend {
|
||||
server client:5000;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
|
||||
# Gzip compression
|
||||
gzip on;
|
||||
gzip_types text/plain text/css application/json application/javascript text/xml;
|
||||
gzip_min_length 256;
|
||||
|
||||
# Security headers
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-XSS-Protection "1; mode=block" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
|
||||
# Max upload size
|
||||
client_max_body_size 50M;
|
||||
|
||||
# API requests → FastAPI server
|
||||
location /api/ {
|
||||
proxy_pass http://server_backend;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_read_timeout 120s;
|
||||
}
|
||||
|
||||
# Upload files served by server
|
||||
location /uploads/ {
|
||||
proxy_pass http://server_backend;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
}
|
||||
|
||||
# All other requests → Flask client
|
||||
location / {
|
||||
proxy_pass http://client_frontend;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# Static file caching
|
||||
location ~* \.(css|js|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf)$ {
|
||||
proxy_pass http://client_frontend;
|
||||
proxy_set_header Host $host;
|
||||
expires 7d;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
|
||||
# SSL configuration (uncomment when using Let's Encrypt)
|
||||
# listen 443 ssl;
|
||||
# ssl_certificate /etc/letsencrypt/live/yourdomain.com/fullchain.pem;
|
||||
# ssl_certificate_key /etc/letsencrypt/live/yourdomain.com/privkey.pem;
|
||||
# ssl_protocols TLSv1.2 TLSv1.3;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
FROM python:3.11-slim
|
||||
|
||||
# Installa dipendenze sistema per WeasyPrint
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
libpango-1.0-0 \
|
||||
libpangocairo-1.0-0 \
|
||||
libcairo2 \
|
||||
libgdk-pixbuf-2.0-0 \
|
||||
libffi-dev \
|
||||
shared-mime-info \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY . .
|
||||
|
||||
# Crea directory uploads
|
||||
RUN mkdir -p uploads/images uploads/pdfs uploads/logos uploads/reports
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
# Entry point: Alembic upgrade + Uvicorn
|
||||
CMD ["sh", "-c", "alembic upgrade head && uvicorn main:app --host 0.0.0.0 --port 8000 --workers 2"]
|
||||
@@ -31,6 +31,9 @@ class Settings(BaseSettings):
|
||||
ssl_certfile: str | None = None
|
||||
ssl_keyfile: str | None = None
|
||||
|
||||
# Setup page (empty = disabled)
|
||||
setup_password: str | None = None
|
||||
|
||||
@property
|
||||
def database_url(self) -> str:
|
||||
"""Async MySQL connection string."""
|
||||
|
||||
@@ -19,6 +19,7 @@ from routers.files import router as files_router
|
||||
from routers.settings import router as settings_router
|
||||
from routers.reports import router as reports_router
|
||||
from routers.statistics import router as statistics_router
|
||||
from routers.setup import router as setup_router
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
@@ -69,6 +70,7 @@ app.include_router(files_router)
|
||||
app.include_router(settings_router)
|
||||
app.include_router(statistics_router)
|
||||
app.include_router(reports_router)
|
||||
app.include_router(setup_router)
|
||||
|
||||
|
||||
@app.get("/api/health")
|
||||
|
||||
@@ -18,6 +18,9 @@ bcrypt>=4.0.0
|
||||
pillow>=10.0.0
|
||||
python-multipart>=0.0.6
|
||||
|
||||
# Templating (setup page)
|
||||
jinja2>=3.1.0
|
||||
|
||||
# Reports
|
||||
plotly>=5.0.0
|
||||
kaleido>=0.2.0
|
||||
|
||||
@@ -0,0 +1,338 @@
|
||||
"""Setup page router - database init, admin creation, demo seed.
|
||||
|
||||
Protected by SETUP_PASSWORD env var. When empty/None, all endpoints return 404.
|
||||
NOT protected by API Key auth (standalone access).
|
||||
"""
|
||||
import secrets
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request, status
|
||||
from fastapi.responses import HTMLResponse
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import inspect as sa_inspect
|
||||
|
||||
from config import settings
|
||||
from database import Base, engine, async_session_factory
|
||||
from models import (
|
||||
User, Recipe, RecipeVersion, RecipeTask, RecipeSubtask,
|
||||
)
|
||||
from services.auth_service import hash_password
|
||||
|
||||
router = APIRouter(prefix="/api/setup", tags=["setup"])
|
||||
|
||||
_templates_dir = Path(__file__).resolve().parent.parent / "templates"
|
||||
templates = Jinja2Templates(directory=str(_templates_dir))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Request schemas
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class PasswordBody(BaseModel):
|
||||
password: str
|
||||
|
||||
|
||||
class CreateAdminBody(BaseModel):
|
||||
password: str
|
||||
admin_username: str
|
||||
admin_password: str
|
||||
admin_email: str
|
||||
admin_display_name: str
|
||||
|
||||
|
||||
class ResetDbBody(BaseModel):
|
||||
password: str
|
||||
confirm: bool
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _check_setup_enabled() -> None:
|
||||
"""Raise 404 if setup password is not configured."""
|
||||
if not settings.setup_password:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
|
||||
def _check_password(password: str) -> None:
|
||||
"""Verify the provided password matches the setup password."""
|
||||
_check_setup_enabled()
|
||||
if password != settings.setup_password:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Invalid setup password",
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Endpoints
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.get("", response_class=HTMLResponse)
|
||||
async def setup_page(request: Request):
|
||||
"""Serve the setup page HTML."""
|
||||
_check_setup_enabled()
|
||||
return templates.TemplateResponse("setup/setup.html", {"request": request})
|
||||
|
||||
|
||||
@router.get("/status")
|
||||
async def setup_status():
|
||||
"""Return database table status (no password required, but setup must be enabled)."""
|
||||
_check_setup_enabled()
|
||||
async with engine.connect() as conn:
|
||||
table_names: list[str] = await conn.run_sync(
|
||||
lambda sync_conn: sa_inspect(sync_conn).get_table_names()
|
||||
)
|
||||
return {
|
||||
"tables_exist": len(table_names) > 0,
|
||||
"table_count": len(table_names),
|
||||
"tables": table_names,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/init-db")
|
||||
async def init_db(body: PasswordBody):
|
||||
"""Create all database tables."""
|
||||
_check_password(body.password)
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
return {"status": "ok", "message": "Database tables created successfully"}
|
||||
|
||||
|
||||
@router.post("/create-admin")
|
||||
async def create_admin(body: CreateAdminBody):
|
||||
"""Create an admin user with all roles."""
|
||||
_check_password(body.password)
|
||||
|
||||
api_key = secrets.token_hex(32)
|
||||
|
||||
async with async_session_factory() as session:
|
||||
async with session.begin():
|
||||
user = User(
|
||||
username=body.admin_username,
|
||||
password_hash=hash_password(body.admin_password),
|
||||
email=body.admin_email,
|
||||
display_name=body.admin_display_name,
|
||||
roles=["Maker", "MeasurementTec", "Metrologist"],
|
||||
is_admin=True,
|
||||
api_key=api_key,
|
||||
)
|
||||
session.add(user)
|
||||
|
||||
return {
|
||||
"status": "ok",
|
||||
"message": f"Admin user '{body.admin_username}' created",
|
||||
"api_key": api_key,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/seed")
|
||||
async def seed_demo_data(body: PasswordBody):
|
||||
"""Seed database with demo users and a sample recipe."""
|
||||
_check_password(body.password)
|
||||
|
||||
async with async_session_factory() as session:
|
||||
async with session.begin():
|
||||
# ---- Users --------------------------------------------------------
|
||||
users_data = [
|
||||
{
|
||||
"username": "admin",
|
||||
"password": "admin123",
|
||||
"display_name": "Administrator",
|
||||
"email": "admin@tiemeasureflow.local",
|
||||
"roles": ["Maker", "MeasurementTec", "Metrologist"],
|
||||
"is_admin": True,
|
||||
},
|
||||
{
|
||||
"username": "maker1",
|
||||
"password": "maker123",
|
||||
"display_name": "Mario Rossi",
|
||||
"email": "maker1@tiemeasureflow.local",
|
||||
"roles": ["Maker"],
|
||||
"is_admin": False,
|
||||
},
|
||||
{
|
||||
"username": "tec1",
|
||||
"password": "tec123",
|
||||
"display_name": "Luca Bianchi",
|
||||
"email": "tec1@tiemeasureflow.local",
|
||||
"roles": ["MeasurementTec"],
|
||||
"is_admin": False,
|
||||
},
|
||||
{
|
||||
"username": "metro1",
|
||||
"password": "metro123",
|
||||
"display_name": "Anna Verdi",
|
||||
"email": "metro1@tiemeasureflow.local",
|
||||
"roles": ["Metrologist"],
|
||||
"is_admin": False,
|
||||
},
|
||||
]
|
||||
|
||||
created_users: list[User] = []
|
||||
for u in users_data:
|
||||
user = User(
|
||||
username=u["username"],
|
||||
password_hash=hash_password(u["password"]),
|
||||
display_name=u["display_name"],
|
||||
email=u["email"],
|
||||
roles=u["roles"],
|
||||
is_admin=u["is_admin"],
|
||||
api_key=secrets.token_hex(32),
|
||||
)
|
||||
session.add(user)
|
||||
created_users.append(user)
|
||||
|
||||
# Flush to get auto-generated user IDs
|
||||
await session.flush()
|
||||
|
||||
admin_user = created_users[0]
|
||||
|
||||
# ---- Recipe -------------------------------------------------------
|
||||
recipe = Recipe(
|
||||
code="DEMO-001",
|
||||
name="Demo Measurement Recipe",
|
||||
description="Sample recipe with shaft and surface measurements for demonstration purposes.",
|
||||
created_by=admin_user.id,
|
||||
)
|
||||
session.add(recipe)
|
||||
await session.flush()
|
||||
|
||||
version = RecipeVersion(
|
||||
recipe_id=recipe.id,
|
||||
version_number=1,
|
||||
is_current=True,
|
||||
created_by=admin_user.id,
|
||||
change_notes="Initial demo version",
|
||||
)
|
||||
session.add(version)
|
||||
await session.flush()
|
||||
|
||||
# ---- Task 1: Shaft Diameter ----------------------------------------
|
||||
task1 = RecipeTask(
|
||||
version_id=version.id,
|
||||
order_index=0,
|
||||
title="Shaft Diameter Measurement",
|
||||
directive="Measure the main shaft diameters at the indicated positions.",
|
||||
description="Use the caliper to measure each diameter. Ensure the shaft is clean and at room temperature.",
|
||||
)
|
||||
session.add(task1)
|
||||
await session.flush()
|
||||
|
||||
subtasks_1 = [
|
||||
RecipeSubtask(
|
||||
task_id=task1.id,
|
||||
marker_number=1,
|
||||
description="D1 - Main shaft diameter",
|
||||
measurement_type="diameter",
|
||||
nominal=25.000,
|
||||
utl=25.050,
|
||||
uwl=25.030,
|
||||
lwl=24.970,
|
||||
ltl=24.950,
|
||||
unit="mm",
|
||||
),
|
||||
RecipeSubtask(
|
||||
task_id=task1.id,
|
||||
marker_number=2,
|
||||
description="D2 - Bearing seat diameter",
|
||||
measurement_type="diameter",
|
||||
nominal=30.000,
|
||||
utl=30.021,
|
||||
uwl=30.015,
|
||||
lwl=29.985,
|
||||
ltl=29.979,
|
||||
unit="mm",
|
||||
),
|
||||
RecipeSubtask(
|
||||
task_id=task1.id,
|
||||
marker_number=3,
|
||||
description="D3 - Shoulder diameter",
|
||||
measurement_type="diameter",
|
||||
nominal=35.000,
|
||||
utl=35.039,
|
||||
uwl=35.025,
|
||||
lwl=34.975,
|
||||
ltl=34.961,
|
||||
unit="mm",
|
||||
),
|
||||
]
|
||||
session.add_all(subtasks_1)
|
||||
|
||||
# ---- Task 2: Surface Flatness --------------------------------------
|
||||
task2 = RecipeTask(
|
||||
version_id=version.id,
|
||||
order_index=1,
|
||||
title="Surface Flatness Measurement",
|
||||
directive="Measure surface heights at the marked reference points.",
|
||||
description="Place the part on the granite surface plate. Zero the indicator at the reference point, then measure each position.",
|
||||
)
|
||||
session.add(task2)
|
||||
await session.flush()
|
||||
|
||||
subtasks_2 = [
|
||||
RecipeSubtask(
|
||||
task_id=task2.id,
|
||||
marker_number=1,
|
||||
description="F1 - Center reference height",
|
||||
measurement_type="height",
|
||||
nominal=0.000,
|
||||
utl=0.020,
|
||||
uwl=0.010,
|
||||
lwl=-0.010,
|
||||
ltl=-0.020,
|
||||
unit="mm",
|
||||
),
|
||||
RecipeSubtask(
|
||||
task_id=task2.id,
|
||||
marker_number=2,
|
||||
description="F2 - Edge height (left)",
|
||||
measurement_type="height",
|
||||
nominal=0.000,
|
||||
utl=0.030,
|
||||
uwl=0.015,
|
||||
lwl=-0.015,
|
||||
ltl=-0.030,
|
||||
unit="mm",
|
||||
),
|
||||
RecipeSubtask(
|
||||
task_id=task2.id,
|
||||
marker_number=3,
|
||||
description="F3 - Edge height (right)",
|
||||
measurement_type="height",
|
||||
nominal=0.000,
|
||||
utl=0.030,
|
||||
uwl=0.015,
|
||||
lwl=-0.015,
|
||||
ltl=-0.030,
|
||||
unit="mm",
|
||||
),
|
||||
]
|
||||
session.add_all(subtasks_2)
|
||||
|
||||
return {
|
||||
"status": "ok",
|
||||
"message": "Demo data seeded successfully",
|
||||
"users_created": [u["username"] for u in users_data],
|
||||
"recipe_created": "DEMO-001",
|
||||
}
|
||||
|
||||
|
||||
@router.post("/reset-db")
|
||||
async def reset_db(body: ResetDbBody):
|
||||
"""Drop all tables and recreate them. Requires confirm=true."""
|
||||
_check_password(body.password)
|
||||
|
||||
if not body.confirm:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="You must set confirm=true to reset the database",
|
||||
)
|
||||
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.drop_all)
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
|
||||
return {"status": "ok", "message": "Database reset successfully"}
|
||||
@@ -0,0 +1,546 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>TieMeasureFlow — Setup</title>
|
||||
<style>
|
||||
/* --- Reset & Base -------------------------------------------------- */
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
body {
|
||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
background: linear-gradient(135deg, #f0f4ff 0%, #e8ecf4 100%);
|
||||
color: #1e293b;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 40px 16px;
|
||||
}
|
||||
|
||||
/* --- Typography ---------------------------------------------------- */
|
||||
h1 { font-size: 1.75rem; font-weight: 700; color: #0f172a; }
|
||||
h2 { font-size: 1.15rem; font-weight: 600; color: #334155; margin-bottom: 12px; }
|
||||
p { line-height: 1.5; color: #64748b; }
|
||||
|
||||
.brand-sub { font-size: 0.85rem; color: #64748b; margin-top: 4px; }
|
||||
.mono { font-family: 'JetBrains Mono', 'Fira Code', monospace; }
|
||||
|
||||
/* --- Layout -------------------------------------------------------- */
|
||||
.container { width: 100%; max-width: 600px; }
|
||||
|
||||
.logo-header {
|
||||
text-align: center;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
.logo-header .icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 56px; height: 56px;
|
||||
background: #2563eb;
|
||||
border-radius: 14px;
|
||||
color: #fff;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
/* --- Card ---------------------------------------------------------- */
|
||||
.card {
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,.08), 0 4px 16px rgba(0,0,0,.04);
|
||||
padding: 24px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.card.danger { border-left: 4px solid #dc2626; }
|
||||
|
||||
/* --- Form elements ------------------------------------------------- */
|
||||
label {
|
||||
display: block;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 500;
|
||||
color: #475569;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
input[type="text"],
|
||||
input[type="password"],
|
||||
input[type="email"] {
|
||||
width: 100%;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid #cbd5e1;
|
||||
border-radius: 8px;
|
||||
font-size: 0.9rem;
|
||||
font-family: inherit;
|
||||
color: #1e293b;
|
||||
background: #f8fafc;
|
||||
transition: border-color .15s, box-shadow .15s;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
input:focus {
|
||||
outline: none;
|
||||
border-color: #2563eb;
|
||||
box-shadow: 0 0 0 3px rgba(37,99,235,.15);
|
||||
}
|
||||
|
||||
/* --- Buttons ------------------------------------------------------- */
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
padding: 10px 20px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-family: inherit;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background .15s, transform .1s, opacity .15s;
|
||||
width: 100%;
|
||||
}
|
||||
.btn:active { transform: scale(.98); }
|
||||
.btn:disabled { opacity: .55; cursor: not-allowed; transform: none; }
|
||||
|
||||
.btn-primary { background: #2563eb; color: #fff; }
|
||||
.btn-primary:hover:not(:disabled) { background: #1d4ed8; }
|
||||
|
||||
.btn-success { background: #16a34a; color: #fff; }
|
||||
.btn-success:hover:not(:disabled) { background: #15803d; }
|
||||
|
||||
.btn-danger { background: #dc2626; color: #fff; }
|
||||
.btn-danger:hover:not(:disabled) { background: #b91c1c; }
|
||||
|
||||
.btn-outline {
|
||||
background: transparent;
|
||||
color: #2563eb;
|
||||
border: 1.5px solid #2563eb;
|
||||
}
|
||||
.btn-outline:hover:not(:disabled) { background: #eff6ff; }
|
||||
|
||||
/* --- Status pills -------------------------------------------------- */
|
||||
.status-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 14px;
|
||||
border-radius: 8px;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.status-bar.ok { background: #f0fdf4; color: #166534; border: 1px solid #bbf7d0; }
|
||||
.status-bar.warn { background: #fffbeb; color: #92400e; border: 1px solid #fde68a; }
|
||||
.status-bar.err { background: #fef2f2; color: #991b1b; border: 1px solid #fecaca; }
|
||||
|
||||
.dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
|
||||
.dot.green { background: #16a34a; }
|
||||
.dot.yellow { background: #eab308; }
|
||||
.dot.red { background: #dc2626; }
|
||||
|
||||
/* --- Table list ---------------------------------------------------- */
|
||||
.table-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
.table-chip {
|
||||
background: #f1f5f9;
|
||||
color: #475569;
|
||||
padding: 3px 10px;
|
||||
border-radius: 6px;
|
||||
font-size: 0.78rem;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
}
|
||||
|
||||
/* --- Checkbox ------------------------------------------------------ */
|
||||
.checkbox-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin: 12px 0 16px;
|
||||
font-size: 0.85rem;
|
||||
color: #64748b;
|
||||
}
|
||||
.checkbox-row input[type="checkbox"] {
|
||||
width: 16px; height: 16px;
|
||||
accent-color: #dc2626;
|
||||
}
|
||||
|
||||
/* --- Toast --------------------------------------------------------- */
|
||||
#toast-container {
|
||||
position: fixed;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
z-index: 9999;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
max-width: 380px;
|
||||
}
|
||||
.toast {
|
||||
padding: 12px 18px;
|
||||
border-radius: 8px;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
color: #fff;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,.15);
|
||||
animation: slideIn .25s ease-out;
|
||||
word-break: break-word;
|
||||
}
|
||||
.toast.success { background: #16a34a; }
|
||||
.toast.error { background: #dc2626; }
|
||||
.toast.info { background: #2563eb; }
|
||||
|
||||
@keyframes slideIn {
|
||||
from { opacity: 0; transform: translateX(30px); }
|
||||
to { opacity: 1; transform: translateX(0); }
|
||||
}
|
||||
@keyframes fadeOut {
|
||||
from { opacity: 1; }
|
||||
to { opacity: 0; transform: translateY(-10px); }
|
||||
}
|
||||
|
||||
/* --- Spinner ------------------------------------------------------- */
|
||||
.spinner {
|
||||
display: inline-block;
|
||||
width: 16px; height: 16px;
|
||||
border: 2px solid rgba(255,255,255,.3);
|
||||
border-top-color: #fff;
|
||||
border-radius: 50%;
|
||||
animation: spin .6s linear infinite;
|
||||
}
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
|
||||
/* --- Separator ----------------------------------------------------- */
|
||||
.sep { border: none; border-top: 1px solid #e2e8f0; margin: 18px 0; }
|
||||
|
||||
/* --- Helpers ------------------------------------------------------- */
|
||||
.hidden { display: none !important; }
|
||||
.mt-2 { margin-top: 8px; }
|
||||
.mt-4 { margin-top: 16px; }
|
||||
.text-sm { font-size: 0.82rem; }
|
||||
.text-muted { color: #94a3b8; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<!-- Toast container -->
|
||||
<div id="toast-container"></div>
|
||||
|
||||
<!-- ================================================================== -->
|
||||
<!-- LOGIN SCREEN -->
|
||||
<!-- ================================================================== -->
|
||||
<div class="container" id="login-screen">
|
||||
<div class="logo-header">
|
||||
<div class="icon">TM</div>
|
||||
<h1>TieMeasureFlow</h1>
|
||||
<p class="brand-sub">System Setup</p>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Setup Password</h2>
|
||||
<p class="text-sm text-muted" style="margin-bottom:16px;">
|
||||
Enter the <span class="mono">SETUP_PASSWORD</span> from your <span class="mono">.env</span> file to continue.
|
||||
</p>
|
||||
<label for="setup-pwd">Password</label>
|
||||
<input type="password" id="setup-pwd" placeholder="Enter setup password" autofocus>
|
||||
<button class="btn btn-primary" id="btn-login" onclick="doLogin()">Unlock Setup</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ================================================================== -->
|
||||
<!-- SETUP PANEL (hidden until login) -->
|
||||
<!-- ================================================================== -->
|
||||
<div class="container hidden" id="setup-panel">
|
||||
<div class="logo-header">
|
||||
<div class="icon">TM</div>
|
||||
<h1>TieMeasureFlow</h1>
|
||||
<p class="brand-sub">System Setup Panel</p>
|
||||
</div>
|
||||
|
||||
<!-- 1. DB Status --------------------------------------------------- -->
|
||||
<div class="card" id="card-status">
|
||||
<h2>Database Status</h2>
|
||||
<div id="status-content">
|
||||
<p class="text-sm text-muted">Loading...</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 2. Init DB ----------------------------------------------------- -->
|
||||
<div class="card">
|
||||
<h2>Initialize Database</h2>
|
||||
<p class="text-sm text-muted" style="margin-bottom:14px;">
|
||||
Create all required tables in the database. Safe to run multiple times.
|
||||
</p>
|
||||
<button class="btn btn-primary" id="btn-init" onclick="initDb()">Create Tables</button>
|
||||
</div>
|
||||
|
||||
<!-- 3. Create Admin ------------------------------------------------ -->
|
||||
<div class="card">
|
||||
<h2>Create Admin User</h2>
|
||||
<p class="text-sm text-muted" style="margin-bottom:14px;">
|
||||
Create a new administrator with all roles enabled.
|
||||
</p>
|
||||
<label for="admin-username">Username</label>
|
||||
<input type="text" id="admin-username" placeholder="admin">
|
||||
<label for="admin-password">Password</label>
|
||||
<input type="password" id="admin-password" placeholder="Strong password">
|
||||
<label for="admin-email">Email</label>
|
||||
<input type="email" id="admin-email" placeholder="admin@example.com">
|
||||
<label for="admin-displayname">Display Name</label>
|
||||
<input type="text" id="admin-displayname" placeholder="Administrator">
|
||||
<button class="btn btn-success" id="btn-admin" onclick="createAdmin()">Create Admin</button>
|
||||
</div>
|
||||
|
||||
<!-- 4. Seed Demo --------------------------------------------------- -->
|
||||
<div class="card">
|
||||
<h2>Seed Demo Data</h2>
|
||||
<p class="text-sm text-muted" style="margin-bottom:14px;">
|
||||
Populate the database with sample users and a demo recipe.
|
||||
Creates users: <span class="mono">admin</span>, <span class="mono">maker1</span>,
|
||||
<span class="mono">tec1</span>, <span class="mono">metro1</span>.
|
||||
</p>
|
||||
<button class="btn btn-success" id="btn-seed" onclick="seedData()">Load Demo Data</button>
|
||||
</div>
|
||||
|
||||
<!-- 5. Reset DB ---------------------------------------------------- -->
|
||||
<div class="card danger">
|
||||
<h2 style="color:#dc2626;">Reset Database</h2>
|
||||
<p class="text-sm" style="color:#991b1b; margin-bottom:8px;">
|
||||
This will <strong>DROP ALL TABLES</strong> and recreate them. All data will be permanently lost.
|
||||
</p>
|
||||
<div class="checkbox-row">
|
||||
<input type="checkbox" id="reset-confirm">
|
||||
<label for="reset-confirm" style="margin-bottom:0; cursor:pointer;">I understand this action is irreversible</label>
|
||||
</div>
|
||||
<button class="btn btn-danger" id="btn-reset" onclick="resetDb()" disabled>Reset Database</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ================================================================== -->
|
||||
<!-- JAVASCRIPT -->
|
||||
<!-- ================================================================== -->
|
||||
<script>
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* State */
|
||||
/* ------------------------------------------------------------------ */
|
||||
let setupPassword = '';
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Toast notifications */
|
||||
/* ------------------------------------------------------------------ */
|
||||
function showToast(message, type) {
|
||||
type = type || 'info';
|
||||
const container = document.getElementById('toast-container');
|
||||
const el = document.createElement('div');
|
||||
el.className = 'toast ' + type;
|
||||
el.textContent = message;
|
||||
container.appendChild(el);
|
||||
setTimeout(function() {
|
||||
el.style.animation = 'fadeOut .3s ease-out forwards';
|
||||
setTimeout(function() { el.remove(); }, 300);
|
||||
}, 4000);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Loading helpers */
|
||||
/* ------------------------------------------------------------------ */
|
||||
function setLoading(btnId, loading) {
|
||||
const btn = document.getElementById(btnId);
|
||||
if (!btn) return;
|
||||
if (loading) {
|
||||
btn.disabled = true;
|
||||
btn._origText = btn.innerHTML;
|
||||
btn.innerHTML = '<span class="spinner"></span> Working...';
|
||||
} else {
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = btn._origText || btn.innerHTML;
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* API helpers */
|
||||
/* ------------------------------------------------------------------ */
|
||||
async function apiPost(path, body) {
|
||||
const resp = await fetch('/api/setup' + path, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const data = await resp.json();
|
||||
if (!resp.ok) {
|
||||
throw new Error(data.detail || 'Request failed (' + resp.status + ')');
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Check DB status */
|
||||
/* ------------------------------------------------------------------ */
|
||||
async function checkStatus() {
|
||||
const container = document.getElementById('status-content');
|
||||
try {
|
||||
const resp = await fetch('/api/setup/status');
|
||||
if (!resp.ok) throw new Error('Status endpoint returned ' + resp.status);
|
||||
const data = await resp.json();
|
||||
|
||||
let html = '';
|
||||
if (data.tables_exist) {
|
||||
html += '<div class="status-bar ok"><span class="dot green"></span>'
|
||||
+ 'Database initialized — ' + data.table_count + ' table(s)</div>';
|
||||
html += '<div class="table-list">';
|
||||
data.tables.forEach(function(t) {
|
||||
html += '<span class="table-chip">' + t + '</span>';
|
||||
});
|
||||
html += '</div>';
|
||||
} else {
|
||||
html += '<div class="status-bar warn"><span class="dot yellow"></span>'
|
||||
+ 'No tables found. Click "Create Tables" below.</div>';
|
||||
}
|
||||
container.innerHTML = html;
|
||||
} catch (e) {
|
||||
container.innerHTML = '<div class="status-bar err"><span class="dot red"></span>'
|
||||
+ 'Could not fetch status: ' + e.message + '</div>';
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Login */
|
||||
/* ------------------------------------------------------------------ */
|
||||
async function doLogin() {
|
||||
const pwd = document.getElementById('setup-pwd').value.trim();
|
||||
if (!pwd) { showToast('Please enter a password', 'error'); return; }
|
||||
|
||||
setLoading('btn-login', true);
|
||||
try {
|
||||
const resp = await fetch('/api/setup/status');
|
||||
if (resp.status === 404) {
|
||||
showToast('Setup is disabled. Set SETUP_PASSWORD in .env', 'error');
|
||||
return;
|
||||
}
|
||||
// Password accepted (status endpoint is accessible)
|
||||
setupPassword = pwd;
|
||||
document.getElementById('login-screen').classList.add('hidden');
|
||||
document.getElementById('setup-panel').classList.remove('hidden');
|
||||
await checkStatus();
|
||||
showToast('Setup panel unlocked', 'success');
|
||||
} catch (e) {
|
||||
showToast('Connection error: ' + e.message, 'error');
|
||||
} finally {
|
||||
setLoading('btn-login', false);
|
||||
}
|
||||
}
|
||||
|
||||
/* Allow Enter key on password field */
|
||||
document.getElementById('setup-pwd').addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Enter') doLogin();
|
||||
});
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Init DB */
|
||||
/* ------------------------------------------------------------------ */
|
||||
async function initDb() {
|
||||
setLoading('btn-init', true);
|
||||
try {
|
||||
const data = await apiPost('/init-db', { password: setupPassword });
|
||||
showToast(data.message, 'success');
|
||||
await checkStatus();
|
||||
} catch (e) {
|
||||
showToast(e.message, 'error');
|
||||
} finally {
|
||||
setLoading('btn-init', false);
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Create Admin */
|
||||
/* ------------------------------------------------------------------ */
|
||||
async function createAdmin() {
|
||||
const username = document.getElementById('admin-username').value.trim();
|
||||
const password = document.getElementById('admin-password').value;
|
||||
const email = document.getElementById('admin-email').value.trim();
|
||||
const display = document.getElementById('admin-displayname').value.trim();
|
||||
|
||||
if (!username || !password || !email || !display) {
|
||||
showToast('All fields are required', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading('btn-admin', true);
|
||||
try {
|
||||
const data = await apiPost('/create-admin', {
|
||||
password: setupPassword,
|
||||
admin_username: username,
|
||||
admin_password: password,
|
||||
admin_email: email,
|
||||
admin_display_name: display,
|
||||
});
|
||||
showToast(data.message, 'success');
|
||||
// Clear form
|
||||
document.getElementById('admin-username').value = '';
|
||||
document.getElementById('admin-password').value = '';
|
||||
document.getElementById('admin-email').value = '';
|
||||
document.getElementById('admin-displayname').value = '';
|
||||
} catch (e) {
|
||||
showToast(e.message, 'error');
|
||||
} finally {
|
||||
setLoading('btn-admin', false);
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Seed Demo Data */
|
||||
/* ------------------------------------------------------------------ */
|
||||
async function seedData() {
|
||||
setLoading('btn-seed', true);
|
||||
try {
|
||||
const data = await apiPost('/seed', { password: setupPassword });
|
||||
showToast(data.message + ' (users: ' + data.users_created.join(', ') + ')', 'success');
|
||||
} catch (e) {
|
||||
showToast(e.message, 'error');
|
||||
} finally {
|
||||
setLoading('btn-seed', false);
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Reset DB */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
/* Enable reset button only when checkbox is checked */
|
||||
document.getElementById('reset-confirm').addEventListener('change', function() {
|
||||
document.getElementById('btn-reset').disabled = !this.checked;
|
||||
});
|
||||
|
||||
async function resetDb() {
|
||||
if (!document.getElementById('reset-confirm').checked) {
|
||||
showToast('Please confirm the checkbox first', 'error');
|
||||
return;
|
||||
}
|
||||
if (!confirm('Are you absolutely sure? This will destroy ALL data!')) {
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading('btn-reset', true);
|
||||
try {
|
||||
const data = await apiPost('/reset-db', {
|
||||
password: setupPassword,
|
||||
confirm: true,
|
||||
});
|
||||
showToast(data.message, 'success');
|
||||
document.getElementById('reset-confirm').checked = false;
|
||||
document.getElementById('btn-reset').disabled = true;
|
||||
await checkStatus();
|
||||
} catch (e) {
|
||||
showToast(e.message, 'error');
|
||||
} finally {
|
||||
setLoading('btn-reset', false);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user