Gestione Uffici
+Lista Uffici
+ +| Nome | +Quota Posti | +Prefisso | +Utenti | +Azioni | +
|---|
diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..a2e058f --- /dev/null +++ b/.dockerignore @@ -0,0 +1,20 @@ +__pycache__ +*.pyc +*.pyo +*.pyd +.Python +env/ +venv/ +.venv/ +pip-log.txt +pip-delete-this-directory.txt +.env +.env.example +.git/ +.gitignore +data/ +.vscode/ +.idea/ +coverage.xml +*.coverage +.pytest_cache/ diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index 65d9a80..0000000 --- a/CLAUDE.md +++ /dev/null @@ -1,253 +0,0 @@ -# CLAUDE.md - Project Intelligence - -## Project Overview - -**Org-Parking** is a manager-centric parking spot management system for organizations. It features fair parking assignment based on presence/parking ratio, supporting both standalone JWT authentication and Authelia/LLDAP SSO integration. - -### Technology Stack -- **Backend:** FastAPI + SQLAlchemy + SQLite -- **Frontend:** Vanilla JavaScript (no frameworks) -- **Auth:** JWT tokens + Authelia SSO support -- **Containerization:** Docker + Docker Compose -- **Rate Limiting:** slowapi - -### Architecture -``` -app/ -├── config.py → Configuration with logging and validation -└── routes/ → API endpoints (auth, users, managers, presence, parking) -services/ → Business logic (parking algorithm, auth, notifications) -database/ → SQLAlchemy models and connection -frontend/ → Static HTML pages + JS modules -utils/ -├── auth_middleware.py → JWT/Authelia authentication -└── helpers.py → Shared utility functions -``` - -## Build & Run Commands - -```bash -# Development -SECRET_KEY=dev-secret-key python -m uvicorn main:app --reload --host 0.0.0.0 --port 8000 - -# Docker -docker compose up -d - -# Dependencies -pip install -r requirements.txt - -# Initialize test database -python create_test_db.py -``` - -## Code Style & Conventions - -### Python -- FastAPI async patterns with `Depends()` for dependency injection -- Pydantic models for request/response validation -- SQLAlchemy ORM (no raw SQL) -- Use `generate_uuid()` from `utils.helpers` for UUIDs -- Use `config.logger` for logging (not print statements) -- Dates stored as TEXT in "YYYY-MM-DD" format - -### JavaScript -- ES6 modules with centralized API client (`/js/api.js`) -- Token stored in localStorage, auto-included in requests -- Utility functions in `/js/utils.js` -- Role-based navigation in `/js/nav.js` - -### Authentication -- Dual mode: JWT tokens (standalone) or Authelia headers (SSO) -- LDAP users have `password_hash = None` -- Use helper: `is_ldap_user(user)` from `utils.helpers` - -### Utility Functions (`utils/helpers.py`) -```python -from utils.helpers import ( - generate_uuid, # Use instead of str(uuid.uuid4()) - is_ldap_user, # Check if user is LDAP-managed - is_ldap_admin, # Check if user is LDAP admin - validate_password, # Returns list of validation errors - format_password_errors, # Format errors into user message - get_notification_default # Get setting value with default -) -``` - ---- - -## Configuration (`app/config.py`) - -Configuration is environment-based with required validation: - -### Required -- `SECRET_KEY` - **MUST** be set (app exits if missing) - -### Security -- `RATE_LIMIT_REQUESTS` - Requests per window (default: 5) -- `RATE_LIMIT_WINDOW` - Window in seconds (default: 60) - -### Email (org-stack pattern) -- `SMTP_ENABLED` - Set to `true` to enable SMTP sending -- When disabled, emails are logged to `EMAIL_LOG_FILE` -- Follows org-stack pattern: direct send with file fallback - -### Logging -- `LOG_LEVEL` - DEBUG, INFO, WARNING, ERROR (default: INFO) -- Use `config.logger` for all logging - ---- - -## Notifications (`services/notifications.py`) - -Simplified notification service following org-stack pattern: - -```python -from services.notifications import ( - send_email, # Direct send or file fallback - notify_parking_assigned, # When spot assigned - notify_parking_released, # When spot released - notify_parking_reassigned, # When spot reassigned - send_presence_reminder, # Weekly presence reminder - send_weekly_parking_summary, # Friday parking summary - send_daily_parking_reminder, # Daily parking reminder - run_scheduled_notifications # Called by cron/scheduler -) -``` - -### Email Behavior -1. If `SMTP_ENABLED=true`: Send via SMTP -2. If SMTP fails or disabled: Log to `EMAIL_LOG_FILE` -3. Never throws - always returns success/failure - ---- - -## Recent Improvements - -### Security Enhancements -- **Required SECRET_KEY**: App exits if not set -- **Rate limiting**: Login/register endpoints limited to 5 req/min -- **Password validation**: Requires uppercase, lowercase, number, 8+ chars -- **Proper logging**: All security events logged - -### Performance Optimizations -- **Fixed N+1 queries** in: - - `list_users()` - Batch query for manager names and counts - - `list_managers()` - Batch query for managed user counts - - `get_manager_guarantees()` - Batch query for user names - - `get_manager_exclusions()` - Batch query for user names - -### Code Consolidation -- **Utility functions** (`utils/helpers.py`): - - `generate_uuid()` - Replaces 50+ `str(uuid.uuid4())` calls - - `is_ldap_user()` - Replaces 4+ duplicated checks - - `validate_password()` - Consistent password validation -- **Simplified notifications** - Removed queue system, direct send - -### Logging Improvements -- Centralized logging via `config.logger` -- Replaced `print()` with proper logging -- Security events logged (login, password change, etc.) - ---- - -## API Quick Reference - -### Authentication -- `POST /api/auth/register` - Create user (rate limited) -- `POST /api/auth/login` - Get JWT token (rate limited) -- `GET /api/auth/me` - Current user (JWT or Authelia) - -### Presence -- `POST /api/presence/mark` - Mark single day -- `POST /api/presence/mark-bulk` - Mark multiple days -- `GET /api/presence/team` - Team calendar with parking - -### Parking -- `POST /api/parking/manual-assign` - Manager assigns spot -- `POST /api/parking/reassign-spot` - Reassign existing spot -- `GET /api/parking/eligible-users/{id}` - Users for reassignment - -### Manager Settings -- `GET/POST/DELETE /api/managers/closing-days` -- `GET/POST/DELETE /api/managers/weekly-closing-days` -- `GET/POST/DELETE /api/managers/guarantees` -- `GET/POST/DELETE /api/managers/exclusions` - ---- - -## Development Notes - -### Adding a New Route -1. Create file in `app/routes/` -2. Use `APIRouter(prefix="/api/...", tags=["..."])` -3. Register in `main.py`: `app.include_router(...)` -4. Add auth dependency: `current_user: User = Depends(get_current_user)` -5. Use `config.logger` for logging -6. Use `generate_uuid()` for new records - -### Database Changes -No migration system (Alembic) configured. Schema changes require: -1. Update [database/models.py](database/models.py) -2. Delete SQLite file or write manual migration -3. Run `create_test_db.py` for fresh database - -### Email Testing -With `SMTP_ENABLED=false`, check email log: -```bash -tail -f /tmp/parking-emails.log -``` - -### Running Scheduled Notifications -Add to cron or systemd timer: -```bash -# Every 5 minutes -*/5 * * * * cd /path/to/org-parking && python -c " -from database.connection import get_db -from services.notifications import run_scheduled_notifications -db = next(get_db()) -run_scheduled_notifications(db) -" -``` - ---- - -## File Quick Links - -| Purpose | File | -|---------|------| -| Main entry | [main.py](main.py) | -| Configuration | [app/config.py](app/config.py) | -| Database models | [database/models.py](database/models.py) | -| Parking algorithm | [services/parking.py](services/parking.py) | -| Notifications | [services/notifications.py](services/notifications.py) | -| Auth middleware | [utils/auth_middleware.py](utils/auth_middleware.py) | -| Utility helpers | [utils/helpers.py](utils/helpers.py) | -| Frontend API client | [frontend/js/api.js](frontend/js/api.js) | -| CSS styles | [frontend/css/styles.css](frontend/css/styles.css) | -| Docker config | [compose.yml](compose.yml) | -| Environment template | [.env.example](.env.example) | - ---- - -## Deployment Notes - -### Remote Server -- Host: `rocketscale.it` -- User: `rocky` -- SSH: `ssh rocky@rocketscale.it` -- Project path: `/home/rocky/org-parking` -- Related project: `/home/rocky/org-stack` (LLDAP, Authelia, etc.) - -### Environment Variables -Copy `.env.example` to `.env` and configure: -```bash -# Generate secure key -openssl rand -hex 32 -``` - -### Production Checklist -- [ ] Set strong `SECRET_KEY` -- [ ] Configure `ALLOWED_ORIGINS` (not `*`) -- [ ] Set `AUTHELIA_ENABLED=true` if using SSO -- [ ] Configure SMTP or check email log file -- [ ] Set up notification scheduler (cron/systemd) diff --git a/Caddyfile.snippet b/Caddyfile.snippet new file mode 100644 index 0000000..db04d5f --- /dev/null +++ b/Caddyfile.snippet @@ -0,0 +1,13 @@ +parking.lvh.me { + # Integrazione Authelia per autenticazione + forward_auth authelia:9091 { + uri /api/verify?rd=https://parking.lvh.me/ + copy_headers Remote-User Remote-Groups Remote-Name Remote-Email + } + + # Proxy inverso verso il container parking sulla porta 8000 + reverse_proxy parking:8000 + + # Usa certificati gestiti internamente per lvh.me (locale) + tls internal +} diff --git a/DEPENDENCIES.md b/DEPENDENCIES.md new file mode 100644 index 0000000..d47ee18 --- /dev/null +++ b/DEPENDENCIES.md @@ -0,0 +1,27 @@ +# Spiegazione delle Dipendenze del Progetto + +Questo documento spiega il motivo per cui ogni libreria elencata nel file `requirements.txt` è necessaria per il funzionamento dell'applicazione. + +## Librerie Principali + +### 1. Framework e Server Web +* **`fastapi`**: È il cuore dell'applicazione. Si tratta di un framework web moderno e veloce per costruire API con Python. Gestisce le richieste HTTP, il routing (gli URL) e la validazione dei dati. +* **`uvicorn[standard]`**: È il server ASGI necessario per eseguire l'applicazione FastAPI. FastAPI da solo non può girare; ha bisogno di un server come Uvicorn per accettare le connessioni dai client (browser, altre app). La versione `[standard]` include dipendenze extra per migliorare le prestazioni (come `uvloop`) e gestire meglio i protocolli web. + +### 2. Database e Dati +* **`sqlalchemy`**: È l'ORM (Object-Relational Mapper) utilizzato per interagire con il database. Permette di scrivere codice Python per creare, leggere, aggiornare ed eliminare dati nel database invece di scrivere SQL puro. +* **`pydantic[email]`**: Usato per la validazione dei dati. Assicura che i dati inviati all'API (come i dati di registrazione utente) siano nel formato corretto. L'extra `[email]` include la libreria `email-validator` per verificare che gli indirizzi email siano validi. +* **`pydantic-settings`**: Un'estensione di Pydantic specifica per la gestione delle configurazioni. Permette di leggere le impostazioni da variabili d'ambiente in modo sicuro e tipizzato. + +### 3. Autenticazione e Sicurezza +* **`python-jose[cryptography]`**: Serve per gestire i JSON Web Tokens (JWT). È fondamentale per il sistema di login, permettendo di creare token sicuri per identificare gli utenti autenticati. +* **`bcrypt`**: Una libreria per l'hashing delle password. È essenziale per la sicurezza, poiché permette di salvare le password nel database in modo criptato e non in chiaro. +* **`python-multipart`**: Necessario per supportare l'upload di form data. FastAPI lo richiede specificamente per gestire l'autenticazione OAuth2 (il login tramite form username/password). + +### 4. Utilità e Configurazioni +* **`python-dotenv`**: Permette all'applicazione di leggere le variabili d'configurazione da un file `.env`. È molto utile durante lo sviluppo per gestire segreti e impostazioni locali. +* **`slowapi`**: Una libreria per implementare il "Rate Limiting". Serve a proteggere l'API da un numero eccessivo di richieste in breve tempo (ad esempio, per prevenire attacchi di forza bruta o abusi). + +### 5. Dipendenze Indirette o Specifiche +* **`email-validator`**: Richiesto da Pydantic per la validazione delle email. +* **`idna`**: Supporto per nomi di dominio internazionalizzati (spesso una dipendenza di `email-validator` o librerie di rete). diff --git a/Dockerfile b/Dockerfile index 399d5da..7dfd4ad 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,6 +2,9 @@ FROM python:3.12-slim WORKDIR /app +# Prevent Python from buffering stdout and stderr +ENV PYTHONUNBUFFERED=1 + # Install dependencies COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt @@ -20,4 +23,4 @@ HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')" || exit 1 # Run with uvicorn -CMD ["python", "-m", "uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"] +CMD ["python", "-m", "uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "1"] diff --git a/README.md b/README.md index a7492e5..eb8d8f9 100644 --- a/README.md +++ b/README.md @@ -1,172 +1,214 @@ -# Parking Manager +# Org-Parking -A manager-centric parking spot management application with fair assignment algorithm. +Un'applicazione leggera gestionale per i parcheggi aziendali, progettata per le organizzazioni. Offre un algoritmo di assegnazione equa, tracciamento delle presenze ed è ottimizzata per basse risorse. -## Features +## Funzionalità -- **Manager-centric model**: Managers own parking spots, not offices -- **Fair assignment algorithm**: Users with lowest parking/presence ratio get priority -- **Presence tracking**: Calendar-based presence marking (present/remote/absent) -- **Closing days**: Support for specific dates and weekly recurring closures -- **Guarantees & exclusions**: Per-user parking rules -- **Authelia/LLDAP integration**: SSO authentication with group-based roles +- **Modello Centrato sull'Ufficio**: I posti auto appartengono agli Uffici, non ai Manager o agli Utenti. +- **Algoritmo di Assegnazione Equa**: Gli utenti con il rapporto parcheggi/presenze più basso hanno la priorità. +- **Tracciamento Presenze**: Marcatura delle presenze basata su calendario (presente/remoto/assente). +- **Regole di Chiusura Flessibili**: Supporto per chiusure in date specifiche e chiusure settimanali ricorrenti per ufficio. +- **Garanzie ed Esclusioni**: Regole di parcheggio per utente gestite dagli amministratori dell'ufficio. +- **Accesso Basato sui Ruoli**: + - **Admin**: Controllo completo del sistema, creazione uffici, gestione utenti. + - **Manager**: Gestisce le impostazioni del proprio ufficio e il team. + - **Impiegato**: Segna presenza, visualizza calendario, controlla stato parcheggio. +- **Basso Consumo di Memoria**: Ottimizzato per piccoli server (<500MB RAM). +- **Autenticazione**: Auth JWT integrata o integrazione SSO con Authelia. -## Architecture +## Architettura ``` -├── app/ -│ ├── routes/ # API endpoints -│ │ ├── auth.py # Authentication + holidays -│ │ ├── users.py # User management -│ │ ├── managers.py # Manager rules (closing days, guarantees) -│ │ ├── presence.py # Presence marking -│ │ └── parking.py # Parking assignments -│ └── config.py # Application configuration -├── database/ -│ ├── models.py # SQLAlchemy ORM models -│ └── connection.py # Database setup -├── services/ -│ ├── auth.py # JWT + password handling -│ ├── parking.py # Fair assignment algorithm -│ ├── holidays.py # Public holiday calculation -│ └── notifications.py # Email notifications (TODO: scheduler) -├── frontend/ -│ ├── pages/ # HTML pages -│ ├── js/ # JavaScript modules -│ └── css/ # Stylesheets -└── main.py # FastAPI application entry +app/ +├── routes/ # API endpoints +│ ├── auth.py # Autenticazione +│ ├── users.py # Gestione utenti +│ ├── offices.py # Gestione uffici (quote, regole) +│ ├── presence.py # Marcatura presenze +│ └── parking.py # Logica di assegnazione +└── config.py # Configurazione +database/ +├── models.py # Modelli SQLAlchemy ORM +└── connection.py # Setup Database +frontend/ # Frontend Vanilla JS pulito +├── pages/ # Viste HTML +├── js/ # Moduli logici +└── css/ # Stili ``` -## Quick Start (Development) +## Guida Rapida -```bash -# Create virtual environment -python3 -m venv .venv -source .venv/bin/activate +### Sviluppo Locale -# Install dependencies -pip install -r requirements.txt +1. **Setup Ambiente**: + ```bash + python3 -m venv .venv + source .venv/bin/activate + pip install -r requirements.txt + ``` -# Run development server -python main.py +2. **Avvio Server**: + ```bash + python main.py + ``` + Accedi a `http://localhost:8000` + +### Deployment Docker (Consigliato) + +Questa applicazione è ottimizzata per ambienti con risorse limitate (es. Raspberry Pi, piccoli VPS). + +1. **Build**: + ```bash + docker compose build + ``` + +2. **Run**: + ```bash + docker compose up -d + ``` + + **Nota sull'Uso della Memoria**: + Il `Dockerfile` è configurato per eseguire `uvicorn` con `--workers 1` per minimizzare l'uso della memoria (~50MB in idle). Se in esecuzione su un server più grande, puoi rimuovere questo flag nel `Dockerfile`. + +## Configurazione + +Copia `.env.example` in `.env` e configura: + +| Variabile | Descrizione | Default | +|-----------|-------------|---------| +| `SECRET_KEY` | **Richiesto**. Chiave cripto per JWT. | `""` (Esce se mancante) | +| `DATABASE_URL` | Vedi docs SQLAlchemy. | `sqlite:///data/parking.db` | +| `AUTHELIA_ENABLED`| Abilita supporto header SSO. | `false` | +| `SMTP_ENABLED` | Abilita notifiche email. | `false` | +| `LOG_LEVEL` | Verbosità log. | `INFO` | + +## Algoritmo di Equità + +I posti auto vengono assegnati giornalmente basandosi su un rapporto di equità: ``` - -Access at http://localhost:8000 - -## Docker Deployment - -```bash -# Build image -docker build -t parking-manager . - -# Run with environment variables -docker run -d \ - -p 8000:8000 \ - -v ./data:/app/data \ - -e SECRET_KEY=your-secret-key \ - -e AUTHELIA_ENABLED=true \ - parking-manager +Rapporto = (Giorni Parcheggiati con successo) / (Giorni di Presenza in ufficio) ``` - -Or use Docker Compose: - -```bash -docker compose up -d -``` - -## Environment Variables - -| Variable | Description | Default | -|----------|-------------|---------| -| `SECRET_KEY` | JWT signing key | Random (dev only) | -| `HOST` | Bind address | `0.0.0.0` | -| `PORT` | Server port | `8000` | -| `DATABASE_URL` | SQLite path | `sqlite:///data/parking.db` | -| `AUTHELIA_ENABLED` | Enable Authelia SSO | `false` | -| `ALLOWED_ORIGINS` | CORS origins | `*` | - -### SMTP (Notifications - Optional) - -| Variable | Description | -|----------|-------------| -| `SMTP_HOST` | SMTP server hostname | -| `SMTP_PORT` | SMTP port (default: 587) | -| `SMTP_USER` | SMTP username | -| `SMTP_PASSWORD` | SMTP password | -| `SMTP_FROM` | From email address | - -## Authentication - -### Standalone Mode -Built-in JWT authentication with bcrypt password hashing. Users register/login via `/login` and `/register`. - -### Authelia Mode -When `AUTHELIA_ENABLED=true`, the app trusts Authelia headers: -- `Remote-User`: User email/username -- `Remote-Name`: Display name -- `Remote-Groups`: Comma-separated group list - -Group mapping (follows lldap naming convention): -- `parking_admins` → admin role -- `managers` → manager role -- Others → employee role - -## User Roles - -| Role | Permissions | -|------|-------------| -| **admin** | Full access, manage users and managers | -| **manager** | Manage their team, set parking rules | -| **employee** | Mark own presence, view calendar | +- Gli utenti **Garantiti** vengono assegnati per primi. +- I posti **Rimanenti** sono distribuiti agli utenti presenti iniziando da chi ha il rapporto più **basso**. +- Gli utenti **Esclusi** non ricevono mai un posto. ## API Endpoints -### Authentication -- `POST /api/auth/login` - Login -- `POST /api/auth/register` - Register (standalone mode) -- `POST /api/auth/logout` - Logout -- `GET /api/auth/me` - Current user info -- `GET /api/auth/holidays/{year}` - Public holidays +Di seguito la lista delle chiamate API disponibili suddivise per modulo. -### Users -- `GET /api/users` - List users (admin) -- `POST /api/users` - Create user (admin) -- `PUT /api/users/{id}` - Update user (admin) -- `DELETE /api/users/{id}` - Delete user (admin) -- `GET /api/users/me/profile` - Own profile -- `PUT /api/users/me/settings` - Own settings +### Auth (`/api/auth`) +Gestione autenticazione e sessione. -### Managers -- `GET /api/managers` - List managers -- `GET /api/managers/{id}` - Manager details -- `PUT /api/managers/{id}/settings` - Update parking quota (admin) -- `GET/POST/DELETE /api/managers/{id}/closing-days` - Specific closures -- `GET/POST/DELETE /api/managers/{id}/weekly-closing-days` - Recurring closures -- `GET/POST/DELETE /api/managers/{id}/guarantees` - Parking guarantees -- `GET/POST/DELETE /api/managers/{id}/exclusions` - Parking exclusions +- `POST /register`: Registrazione nuovo utente (Disabilitato se Authelia è attivo). +- `POST /login`: Login con email e password (ritorna token JWT/cookie). +- `POST /logout`: Logout e invalidazione sessione. +- `GET /me`: Ritorna informazioni sull'utente corrente. +- `GET /config`: Ritorna la configurazione pubblica di autenticazione. +- `GET /holidays/{year}`: Ritorna i giorni festivi per l'anno specificato. -### Presence -- `POST /api/presence/mark` - Mark presence -- `POST /api/presence/mark-bulk` - Bulk mark -- `GET /api/presence/my-presences` - Own presences -- `GET /api/presence/team` - Team calendar (manager/admin) +### Users (`/api/users`) +Gestione utenti e profili. -### Parking -- `GET /api/parking/assignments/{date}` - Day's assignments -- `GET /api/parking/my-assignments` - Own assignments -- `POST /api/parking/manual-assign` - Manual assignment -- `POST /api/parking/reassign-spot` - Reassign spot +- `GET /`: Lista di tutti gli utenti (Solo Admin). +- `POST /`: Crea un nuovo utente (Solo Admin). +- `GET /{user_id}`: Dettaglio di un utente specifico (Solo Admin). +- `PUT /{user_id}`: Aggiorna dati di un utente (Solo Admin). +- `DELETE /{user_id}`: Elimina un utente (Solo Admin). +- `GET /me/profile`: Ottieni il proprio profilo. +- `PUT /me/profile`: Aggiorna il proprio profilo. +- `GET /me/settings`: Ottieni le proprie impostazioni. +- `PUT /me/settings`: Aggiorna le proprie impostazioni. +- `POST /me/change-password`: Modifica la propria password. -## Fairness Algorithm +### Offices (`/api/offices`) +Gestione uffici, regole di chiusura e quote. -Parking spots are assigned based on a fairness ratio: +- `GET /`: Lista di tutti gli uffici. +- `POST /`: Crea un nuovo ufficio (Solo Admin). +- `GET /{office_id}`: Dettagli di un ufficio. +- `PUT /{office_id}`: Aggiorna configurazione ufficio (Solo Admin). +- `DELETE /{office_id}`: Elimina un ufficio (Solo Admin). +- `GET /{office_id}/users`: Lista utenti assegnati all'ufficio. +- `GET /{office_id}/closing-days`: Lista giorni di chiusura specifici. +- `POST /{office_id}/closing-days`: Aggiungi giorno di chiusura. +- `DELETE /{office_id}/closing-days/{id}`: Rimuovi giorno di chiusura. +- `GET /{office_id}/weekly-closing-days`: Lista giorni di chiusura settimanali (es. Sabato/Domenica). +- `POST /{office_id}/weekly-closing-days`: Aggiungi giorno di chiusura settimanale. +- `DELETE /{office_id}/weekly-closing-days/{id}`: Rimuovi giorno di chiusura settimanale. +- `GET /{office_id}/guarantees`: Lista utenti con posto garantito. +- `POST /{office_id}/guarantees`: Aggiungi garanzia posto. +- `DELETE /{office_id}/guarantees/{id}`: Rimuovi garanzia. +- `GET /{office_id}/exclusions`: Lista utenti esclusi dal parcheggio. +- `POST /{office_id}/exclusions`: Aggiungi esclusione. +- `DELETE /{office_id}/exclusions/{id}`: Rimuovi esclusione. -``` -ratio = parking_days / presence_days -``` +### Presence (`/api/presence`) +Gestione presenze giornaliere. -Users with the lowest ratio get priority. Guaranteed users are always assigned first. +- `POST /mark`: Segna la propria presenza per una data (Presente/Remoto/Assente). +- `GET /my-presences`: Lista delle proprie presenze. +- `DELETE /{date}`: Rimuovi la propria presenza per una data. +- `POST /admin/mark`: Segna presenza per un altro utente (Manager/Admin). +- `DELETE /admin/{user_id}/{date}`: Rimuovi presenza di un altro utente (Manager/Admin). +- `GET /team`: Visualizza presenze e stato parcheggio del team. +- `GET /admin/{user_id}`: Storico presenze di un utente. -## License +### Parking (`/api/parking`) +Gestione assegnazioni posti auto. + +- `POST /init-office-pool`: Inizializza i posti disponibili per un giorno. +- `GET /assignments/{date}`: Lista assegnazioni per una data. +- `GET /my-assignments`: Le mie assegnazioni parcheggio. +- `POST /run-allocation`: Esegui manualmente l'algoritmo di assegnazione per una data. +- `POST /clear-assignments`: Cancella tutte le assegnazioni per una data. +- `POST /manual-assign`: Assegna manualmente un posto a un utente. +- `POST /reassign-spot`: Riassegna o libera un posto già assegnato. +- `POST /release-my-spot/{id}`: Rilascia il proprio posto assegnato. +- `GET /eligible-users/{id}`: Lista utenti idonei a ricevere un posto riassegnato. + +## Utilizzo con AUTHELIA + +Org-Parking supporta l'integrazione nativa con **Authelia** (o altri provider SSO compatibili con Forward Auth). Questo permette il Single Sign-On (SSO) e la gestione centralizzata degli utenti. + +### Configurazione + +1. **Abilita Authelia**: + Nel file `.env`, imposta `AUTHELIA_ENABLED=true`. + +2. **Configura gli Header del Proxy**: + Assicurati che il tuo reverse proxy (es. Traefik, Nginx) passi i seguenti header all'applicazione dopo l'autenticazione: + * `Remote-User`: Username dell'utente (spesso uguale all'email). + * `Remote-Email`: Email dell'utente. + * `Remote-Name`: Nome completo dell'utente (Opzionale). + * `Remote-Groups`: Gruppi di appartenenza (separati da virgola). + +3. **Gestione Admin**: + L'applicazione assegna automaticamente il ruolo di **Admin** agli utenti che appartengono al gruppo specificato nella variabile `AUTHELIA_ADMIN_GROUP` (default: `parking_admins`). + * Se un utente viene rimosso da questo gruppo su Authelia, perderà i privilegi di admin al login successivo. + * Gli altri ruoli (Manager, Employee) e l'assegnazione agli uffici vengono gestiti manualmente all'interno di Org-Parking da un amministratore. + +### Comportamento + +* **Creazione Utenti**: Gli utenti vengono creati automaticamente nel database di Org-Parking al loro primo accesso riuscito tramite Authelia. +* **Login/Logout**: Le pagine di login e registrazione interne vengono disabilitate o reindirizzano al portale SSO. +* **Password**: Org-Parking non gestisce le password in questa modalità; l'autenticazione è interamente delegata al provider esterno. +* **Sicurezza**: L'applicazione si fida degli header `Remote-*` solo se `AUTHELIA_ENABLED` è attivo. Assicurarsi che il container non sia esposto direttamente a internet senza passare per il proxy di autenticazione. + +## Note di Deployment + +- **Database**: SQLite è usato di default per semplicità e basso overhead. I dati persistono in `./data/parking.db`. +- **Sicurezza**: + - Rate limiting è attivo sugli endpoint sensibili (Login/Register). + - Le password sono hashate con Bcrypt. + - L'autenticazione via cookie è sicura di default. + +### Risoluzione Problemi Comuni + +**Errore: "Il reindirizzamento è stato determinato per essere non sicuro"** + +Questo errore appare se si tenta di accedere all'applicazione tramite HTTP (es. `http://lvh.me:8000`) mentre Authelia è su HTTPS. Authelia blocca i reindirizzamenti verso protocolli non sicuri. +**Soluzione**: Accedere all'applicazione tramite il Reverse Proxy (Caddy) usando HTTPS, ad esempio `https://parking.lvh.me`. Assicurarsi che Caddy sia configurato per gestire il dominio e l'autenticazione. + +## Licenza MIT diff --git a/app/config.py b/app/config.py index 1d8ec2a..eefe2dd 100644 --- a/app/config.py +++ b/app/config.py @@ -7,6 +7,10 @@ import sys import logging from pathlib import Path +# Paths +BASE_DIR = Path(__file__).resolve().parent.parent +FRONTEND_DIR = BASE_DIR / "frontend" + # Configure logging LOG_LEVEL = os.getenv("LOG_LEVEL", "INFO").upper() logging.basicConfig( @@ -17,6 +21,19 @@ logger = logging.getLogger("org-parking") # Database DATABASE_PATH = os.getenv("DATABASE_PATH", "parking.db") + +# Fix for local execution: if path is absolute (docker) but dir doesn't exist, fallback to local data/ +if os.path.isabs(DATABASE_PATH) and not os.path.exists(os.path.dirname(DATABASE_PATH)): + # Check if we are aiming for /app/data but running locally + if str(DATABASE_PATH).startswith("/app/") or not os.access(os.path.dirname(DATABASE_PATH), os.W_OK): + logger.warning(f"Configured DATABASE_PATH '{DATABASE_PATH}' folder not found/writable. Switching to local 'data' directory.") + + local_data_dir = BASE_DIR / "data" + local_data_dir.mkdir(exist_ok=True) + + DATABASE_PATH = str(local_data_dir / os.path.basename(DATABASE_PATH)) + logger.info(f"Using local database path: {DATABASE_PATH}") + DATABASE_URL = os.getenv("DATABASE_URL", f"sqlite:///{DATABASE_PATH}") # JWT Authentication @@ -35,7 +52,7 @@ HOST = os.getenv("HOST", "0.0.0.0") PORT = int(os.getenv("PORT", "8000")) # CORS -ALLOWED_ORIGINS = os.getenv("ALLOWED_ORIGINS", "http://localhost:8000,http://127.0.0.1:8000").split(",") +ALLOWED_ORIGINS = os.getenv("ALLOWED_ORIGINS", "http://localhost:8000,http://127.0.0.1:8000,http://lvh.me").split(",") # Authelia Integration AUTHELIA_ENABLED = os.getenv("AUTHELIA_ENABLED", "false").lower() == "true" @@ -67,6 +84,4 @@ EMAIL_LOG_FILE = os.getenv("EMAIL_LOG_FILE", "/tmp/parking-emails.log") RATE_LIMIT_REQUESTS = int(os.getenv("RATE_LIMIT_REQUESTS", "5")) # requests per window RATE_LIMIT_WINDOW = int(os.getenv("RATE_LIMIT_WINDOW", "60")) # seconds -# Paths -BASE_DIR = Path(__file__).resolve().parent.parent -FRONTEND_DIR = BASE_DIR / "frontend" + diff --git a/app/routes/auth.py b/app/routes/auth.py index 4984673..22571a3 100644 --- a/app/routes/auth.py +++ b/app/routes/auth.py @@ -9,6 +9,7 @@ from slowapi import Limiter from slowapi.util import get_remote_address from database.connection import get_db +from database.models import UserRole from services.auth import ( create_user, authenticate_user, create_access_token, get_user_by_email @@ -25,7 +26,6 @@ class RegisterRequest(BaseModel): email: EmailStr password: str name: str - manager_id: str | None = None class LoginRequest(BaseModel): @@ -42,16 +42,16 @@ class UserResponse(BaseModel): id: str email: str name: str | None - manager_id: str | None - role: str - manager_parking_quota: int | None = None + office_id: str | None + office_name: str | None = None + role: UserRole week_start_day: int = 0 # Notification preferences - notify_weekly_parking: int = 1 - notify_daily_parking: int = 1 + notify_weekly_parking: bool = True + notify_daily_parking: bool = True notify_daily_parking_hour: int = 8 notify_daily_parking_minute: int = 0 - notify_parking_changes: int = 1 + notify_parking_changes: bool = True @router.post("/register", response_model=TokenResponse) @@ -76,8 +76,7 @@ def register(request: Request, data: RegisterRequest, db: Session = Depends(get_ db=db, email=data.email, password=data.password, - name=data.name, - manager_id=data.manager_id + name=data.name ) config.logger.info(f"New user registered: {data.email}") @@ -126,15 +125,15 @@ def get_me(user=Depends(get_current_user)): id=user.id, email=user.email, name=user.name, - manager_id=user.manager_id, + office_id=user.office_id, + office_name=user.office.name if user.office else None, role=user.role, - manager_parking_quota=user.manager_parking_quota, week_start_day=get_notification_default(user.week_start_day, 0), - notify_weekly_parking=get_notification_default(user.notify_weekly_parking, 1), - notify_daily_parking=get_notification_default(user.notify_daily_parking, 1), + notify_weekly_parking=get_notification_default(user.notify_weekly_parking, True), + notify_daily_parking=get_notification_default(user.notify_daily_parking, True), notify_daily_parking_hour=get_notification_default(user.notify_daily_parking_hour, 8), notify_daily_parking_minute=get_notification_default(user.notify_daily_parking_minute, 0), - notify_parking_changes=get_notification_default(user.notify_parking_changes, 1) + notify_parking_changes=get_notification_default(user.notify_parking_changes, True) ) diff --git a/app/routes/managers.py b/app/routes/managers.py index c1eda53..acdb622 100644 --- a/app/routes/managers.py +++ b/app/routes/managers.py @@ -5,7 +5,7 @@ Manager settings, closing days, guarantees, and exclusions Key concept: Managers own parking spots and set rules for their managed users. Rules are set at manager level (users have manager_id pointing to their manager). """ -from datetime import datetime +from datetime import datetime, date from fastapi import APIRouter, Depends, HTTPException from pydantic import BaseModel from sqlalchemy.orm import Session @@ -26,7 +26,8 @@ router = APIRouter(prefix="/api/managers", tags=["managers"]) # Request/Response Models class ClosingDayCreate(BaseModel): - date: str # YYYY-MM-DD + date: date # Start date + end_date: date | None = None # Optional end date (inclusive) reason: str | None = None @@ -36,14 +37,16 @@ class WeeklyClosingDayCreate(BaseModel): class GuaranteeCreate(BaseModel): user_id: str - start_date: str | None = None - end_date: str | None = None + start_date: date | None = None + end_date: date | None = None + notes: str | None = None class ExclusionCreate(BaseModel): user_id: str - start_date: str | None = None - end_date: str | None = None + start_date: date | None = None + end_date: date | None = None + notes: str | None = None class ManagerSettingsUpdate(BaseModel): @@ -124,7 +127,7 @@ def update_manager_settings(manager_id: str, data: ManagerSettingsUpdate, db: Se raise HTTPException(status_code=400, detail=f"Spot prefix '{data.spot_prefix}' is already used") manager.manager_spot_prefix = data.spot_prefix - manager.updated_at = datetime.utcnow().isoformat() + manager.updated_at = datetime.utcnow() db.commit() return { @@ -155,7 +158,7 @@ def get_manager_closing_days(manager_id: str, db: Session = Depends(get_db), use days = db.query(ManagerClosingDay).filter( ManagerClosingDay.manager_id == manager_id ).order_by(ManagerClosingDay.date).all() - return [{"id": d.id, "date": d.date, "reason": d.reason} for d in days] + return [{"id": d.id, "date": d.date, "end_date": d.end_date, "reason": d.reason} for d in days] @router.post("/{manager_id}/closing-days") @@ -172,10 +175,14 @@ def add_manager_closing_day(manager_id: str, data: ClosingDayCreate, db: Session if existing: raise HTTPException(status_code=400, detail="Closing day already exists for this date") + if data.end_date and data.end_date < data.date: + raise HTTPException(status_code=400, detail="End date must be after start date") + closing_day = ManagerClosingDay( id=generate_uuid(), manager_id=manager_id, date=data.date, + end_date=data.end_date, reason=data.reason ) db.add(closing_day) @@ -270,7 +277,8 @@ def get_manager_guarantees(manager_id: str, db: Session = Depends(get_db), user= "user_id": g.user_id, "user_name": user_lookup.get(g.user_id), "start_date": g.start_date, - "end_date": g.end_date + "end_date": g.end_date, + "notes": g.notes } for g in guarantees ] @@ -292,13 +300,17 @@ def add_manager_guarantee(manager_id: str, data: GuaranteeCreate, db: Session = if existing: raise HTTPException(status_code=400, detail="User already has a parking guarantee") + if data.start_date and data.end_date and data.end_date < data.start_date: + raise HTTPException(status_code=400, detail="End date must be after start date") + guarantee = ParkingGuarantee( id=generate_uuid(), manager_id=manager_id, user_id=data.user_id, start_date=data.start_date, end_date=data.end_date, - created_at=datetime.utcnow().isoformat() + notes=data.notes, + created_at=datetime.utcnow() ) db.add(guarantee) db.commit() @@ -340,7 +352,8 @@ def get_manager_exclusions(manager_id: str, db: Session = Depends(get_db), user= "user_id": e.user_id, "user_name": user_lookup.get(e.user_id), "start_date": e.start_date, - "end_date": e.end_date + "end_date": e.end_date, + "notes": e.notes } for e in exclusions ] @@ -362,13 +375,17 @@ def add_manager_exclusion(manager_id: str, data: ExclusionCreate, db: Session = if existing: raise HTTPException(status_code=400, detail="User already has a parking exclusion") + if data.start_date and data.end_date and data.end_date < data.start_date: + raise HTTPException(status_code=400, detail="End date must be after start date") + exclusion = ParkingExclusion( id=generate_uuid(), manager_id=manager_id, user_id=data.user_id, start_date=data.start_date, end_date=data.end_date, - created_at=datetime.utcnow().isoformat() + notes=data.notes, + created_at=datetime.utcnow() ) db.add(exclusion) db.commit() diff --git a/app/routes/offices.py b/app/routes/offices.py new file mode 100644 index 0000000..e236bc6 --- /dev/null +++ b/app/routes/offices.py @@ -0,0 +1,500 @@ +""" +Office Management Routes +Office settings, closing days, guarantees, and exclusions +""" +from datetime import datetime, date +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel +from sqlalchemy.orm import Session +from sqlalchemy import func + +from database.connection import get_db +from database.models import ( + User, Office, + OfficeClosingDay, OfficeWeeklyClosingDay, + ParkingGuarantee, ParkingExclusion, + UserRole +) +from utils.auth_middleware import require_admin, require_manager_or_admin, get_current_user +from utils.helpers import generate_uuid +from app import config + +router = APIRouter(prefix="/api/offices", tags=["offices"]) + + +# Request/Response Models +class ValidOfficeCreate(BaseModel): + name: str + parking_quota: int = 0 + + +class ClosingDayCreate(BaseModel): + date: date # Start date + end_date: date | None = None # Optional end date (inclusive) + reason: str | None = None + + +class WeeklyClosingDayCreate(BaseModel): + weekday: int # 0=Sunday, 1=Monday, ..., 6=Saturday + + +class GuaranteeCreate(BaseModel): + user_id: str + start_date: date | None = None + end_date: date | None = None + notes: str | None = None + + +class ExclusionCreate(BaseModel): + user_id: str + start_date: date | None = None + end_date: date | None = None + notes: str | None = None + + +class OfficeSettingsUpdate(BaseModel): + parking_quota: int | None = None + name: str | None = None + booking_window_enabled: bool | None = None + booking_window_end_hour: int | None = None + booking_window_end_minute: int | None = None + + +# Helper check +def check_office_access(user: User, office_id: str): + if user.role == UserRole.ADMIN: + return True + if user.role == UserRole.MANAGER and user.office_id == office_id: + return True + raise HTTPException(status_code=403, detail="Access denied to this office") + + +# Office listing and details +@router.get("") +def list_offices(db: Session = Depends(get_db), user=Depends(require_manager_or_admin)): + """Get all offices with their user count and parking quota""" + offices = db.query(Office).all() + + # Batch query user counts + counts = db.query(User.office_id, func.count(User.id)).filter( + User.office_id.isnot(None) + ).group_by(User.office_id).all() + user_counts = {office_id: count for office_id, count in counts} + + return [ + { + "id": office.id, + "name": office.name, + "parking_quota": office.parking_quota, + "spot_prefix": office.spot_prefix, + "user_count": user_counts.get(office.id, 0) + } + for office in offices + ] + +def get_next_available_prefix(db: Session) -> str: + """Find the next available office prefix (A, B, C... AA, AB...)""" + existing = db.query(Office.spot_prefix).filter(Office.spot_prefix.isnot(None)).all() + used_prefixes = {row[0] for row in existing} + + # Try single letters A-Z + for i in range(26): + char = chr(65 + i) + if char not in used_prefixes: + return char + + # Try double letters AA-ZZ if needed + for i in range(26): + for j in range(26): + char = chr(65 + i) + chr(65 + j) + if char not in used_prefixes: + return char + + raise HTTPException(status_code=400, detail="No more office prefixes available") + + +@router.post("") +def create_office(data: ValidOfficeCreate, db: Session = Depends(get_db), user=Depends(require_admin)): + """Create a new office (admin only)""" + office = Office( + id=generate_uuid(), + name=data.name, + parking_quota=data.parking_quota, + spot_prefix=get_next_available_prefix(db), + created_at=datetime.utcnow() + ) + db.add(office) + db.commit() + return office + +@router.get("/{office_id}") +def get_office_details(office_id: str, db: Session = Depends(get_db), user=Depends(require_manager_or_admin)): + """Get office details""" + office = db.query(Office).filter(Office.id == office_id).first() + if not office: + raise HTTPException(status_code=404, detail="Office not found") + + check_office_access(user, office_id) + + user_count = db.query(User).filter(User.office_id == office_id).count() + + return { + "id": office.id, + "name": office.name, + "parking_quota": office.parking_quota, + "spot_prefix": office.spot_prefix, + "user_count": user_count, + "booking_window_enabled": office.booking_window_enabled, + "booking_window_end_hour": office.booking_window_end_hour, + "booking_window_end_minute": office.booking_window_end_minute + } + + +@router.put("/{office_id}") +def update_office_settings(office_id: str, data: OfficeSettingsUpdate, db: Session = Depends(get_db), user=Depends(require_admin)): + """Update office settings (admin only) - Manager can view but usually Admin sets quota""" + # Verify access - currently assume admin manages quota. If manager should too, update logic. + # User request description: "Admin manage all offices with CRUD... rimodulare posti auto". + # So Managers might not edit quota? Or maybe they can? + # Keeping it simple: require_admin for structural changes. + + office = db.query(Office).filter(Office.id == office_id).first() + if not office: + raise HTTPException(status_code=404, detail="Office not found") + + if data.name: + office.name = data.name + + if data.parking_quota is not None: + if data.parking_quota < 0: + raise HTTPException(status_code=400, detail="Parking quota must be non-negative") + office.parking_quota = data.parking_quota + + if data.booking_window_enabled is not None: + office.booking_window_enabled = data.booking_window_enabled + + if data.booking_window_end_hour is not None: + if not (0 <= data.booking_window_end_hour <= 23): + raise HTTPException(status_code=400, detail="Hour must be 0-23") + office.booking_window_end_hour = data.booking_window_end_hour + + if data.booking_window_end_minute is not None: + if not (0 <= data.booking_window_end_minute <= 59): + raise HTTPException(status_code=400, detail="Minute must be 0-59") + office.booking_window_end_minute = data.booking_window_end_minute + + office.updated_at = datetime.utcnow() + db.commit() + + return { + "id": office.id, + "name": office.name, + "parking_quota": office.parking_quota, + "spot_prefix": office.spot_prefix, + "booking_window_enabled": office.booking_window_enabled, + "booking_window_end_hour": office.booking_window_end_hour, + "booking_window_end_minute": office.booking_window_end_minute + } + +@router.delete("/{office_id}") +def delete_office(office_id: str, db: Session = Depends(get_db), user=Depends(require_admin)): + """Delete an office (admin only)""" + office = db.query(Office).filter(Office.id == office_id).first() + if not office: + raise HTTPException(status_code=404, detail="Office not found") + + db.delete(office) + db.commit() + return {"message": "Office deleted"} + + +@router.get("/{office_id}/users") +def get_office_users(office_id: str, db: Session = Depends(get_db), user=Depends(require_manager_or_admin)): + """Get all users in an office""" + check_office_access(user, office_id) + + office = db.query(Office).filter(Office.id == office_id).first() + if not office: + raise HTTPException(status_code=404, detail="Office not found") + + users = db.query(User).filter(User.office_id == office_id).all() + return [{"id": u.id, "name": u.name, "email": u.email, "role": u.role} for u in users] + + +# Closing days +@router.get("/{office_id}/closing-days") +def get_office_closing_days(office_id: str, db: Session = Depends(get_db), user=Depends(get_current_user)): + """Get closing days for an office""" + # Any user in the office can read closing days? Or just manager? + # check_office_access(user, office_id) # Let's allow read for all authenticated (frontend might need it) + + days = db.query(OfficeClosingDay).filter( + OfficeClosingDay.office_id == office_id + ).order_by(OfficeClosingDay.date).all() + return [{"id": d.id, "date": d.date, "end_date": d.end_date, "reason": d.reason} for d in days] + + +@router.post("/{office_id}/closing-days") +def add_office_closing_day(office_id: str, data: ClosingDayCreate, db: Session = Depends(get_db), user=Depends(require_manager_or_admin)): + """Add a closing day for an office""" + check_office_access(user, office_id) + + office = db.query(Office).filter(Office.id == office_id).first() + if not office: + raise HTTPException(status_code=404, detail="Office not found") + + existing = db.query(OfficeClosingDay).filter( + OfficeClosingDay.office_id == office_id, + OfficeClosingDay.date == data.date + ).first() + if existing: + raise HTTPException(status_code=400, detail="Closing day already exists for this date") + + if data.end_date and data.end_date < data.date: + raise HTTPException(status_code=400, detail="End date must be after start date") + + closing_day = OfficeClosingDay( + id=generate_uuid(), + office_id=office_id, + date=data.date, + end_date=data.end_date, + reason=data.reason + ) + db.add(closing_day) + db.commit() + return {"id": closing_day.id, "message": "Closing day added"} + + +@router.delete("/{office_id}/closing-days/{closing_day_id}") +def remove_office_closing_day(office_id: str, closing_day_id: str, db: Session = Depends(get_db), user=Depends(require_manager_or_admin)): + """Remove a closing day for an office""" + check_office_access(user, office_id) + + closing_day = db.query(OfficeClosingDay).filter( + OfficeClosingDay.id == closing_day_id, + OfficeClosingDay.office_id == office_id + ).first() + if not closing_day: + raise HTTPException(status_code=404, detail="Closing day not found") + + db.delete(closing_day) + db.commit() + return {"message": "Closing day removed"} + + +# Weekly closing days +@router.get("/{office_id}/weekly-closing-days") +def get_office_weekly_closing_days(office_id: str, db: Session = Depends(get_db), user=Depends(get_current_user)): + """Get weekly closing days for an office""" + days = db.query(OfficeWeeklyClosingDay).filter( + OfficeWeeklyClosingDay.office_id == office_id + ).all() + return [{"id": d.id, "weekday": d.weekday} for d in days] + + +@router.post("/{office_id}/weekly-closing-days") +def add_office_weekly_closing_day(office_id: str, data: WeeklyClosingDayCreate, db: Session = Depends(get_db), user=Depends(require_manager_or_admin)): + """Add a weekly closing day for an office""" + check_office_access(user, office_id) + + office = db.query(Office).filter(Office.id == office_id).first() + if not office: + raise HTTPException(status_code=404, detail="Office not found") + + if data.weekday < 0 or data.weekday > 6: + raise HTTPException(status_code=400, detail="Weekday must be 0-6 (0=Sunday, 6=Saturday)") + + existing = db.query(OfficeWeeklyClosingDay).filter( + OfficeWeeklyClosingDay.office_id == office_id, + OfficeWeeklyClosingDay.weekday == data.weekday + ).first() + if existing: + raise HTTPException(status_code=400, detail="Weekly closing day already exists for this weekday") + + weekly_closing = OfficeWeeklyClosingDay( + id=generate_uuid(), + office_id=office_id, + weekday=data.weekday + ) + db.add(weekly_closing) + db.commit() + return {"id": weekly_closing.id, "message": "Weekly closing day added"} + + +@router.delete("/{office_id}/weekly-closing-days/{weekly_id}") +def remove_office_weekly_closing_day(office_id: str, weekly_id: str, db: Session = Depends(get_db), user=Depends(require_manager_or_admin)): + """Remove a weekly closing day for an office""" + check_office_access(user, office_id) + + weekly_closing = db.query(OfficeWeeklyClosingDay).filter( + OfficeWeeklyClosingDay.id == weekly_id, + OfficeWeeklyClosingDay.office_id == office_id + ).first() + if not weekly_closing: + raise HTTPException(status_code=404, detail="Weekly closing day not found") + + db.delete(weekly_closing) + db.commit() + return {"message": "Weekly closing day removed"} + + +# Guarantees +@router.get("/{office_id}/guarantees") +def get_office_guarantees(office_id: str, db: Session = Depends(get_db), user=Depends(require_manager_or_admin)): + """Get parking guarantees for an office""" + check_office_access(user, office_id) + + guarantees = db.query(ParkingGuarantee).filter(ParkingGuarantee.office_id == office_id).all() + + # Batch query to get all user names at once + user_ids = [g.user_id for g in guarantees] + if user_ids: + users = db.query(User).filter(User.id.in_(user_ids)).all() + user_lookup = {u.id: u.name for u in users} + else: + user_lookup = {} + + return [ + { + "id": g.id, + "user_id": g.user_id, + "user_name": user_lookup.get(g.user_id), + "start_date": g.start_date, + "end_date": g.end_date, + "notes": g.notes + } + for g in guarantees + ] + + +@router.post("/{office_id}/guarantees") +def add_office_guarantee(office_id: str, data: GuaranteeCreate, db: Session = Depends(get_db), user=Depends(require_manager_or_admin)): + """Add parking guarantee for an office""" + check_office_access(user, office_id) + + office = db.query(Office).filter(Office.id == office_id).first() + if not office: + raise HTTPException(status_code=404, detail="Office not found") + if not db.query(User).filter(User.id == data.user_id).first(): + raise HTTPException(status_code=404, detail="User not found") + + existing = db.query(ParkingGuarantee).filter( + ParkingGuarantee.office_id == office_id, + ParkingGuarantee.user_id == data.user_id + ).first() + if existing: + raise HTTPException(status_code=400, detail="User already has a parking guarantee") + + if data.start_date and data.end_date and data.end_date < data.start_date: + raise HTTPException(status_code=400, detail="End date must be after start date") + + guarantee = ParkingGuarantee( + id=generate_uuid(), + office_id=office_id, + user_id=data.user_id, + start_date=data.start_date, + end_date=data.end_date, + notes=data.notes, + created_at=datetime.utcnow() + ) + db.add(guarantee) + db.commit() + return {"id": guarantee.id, "message": "Guarantee added"} + + +@router.delete("/{office_id}/guarantees/{guarantee_id}") +def remove_office_guarantee(office_id: str, guarantee_id: str, db: Session = Depends(get_db), user=Depends(require_manager_or_admin)): + """Remove parking guarantee for an office""" + check_office_access(user, office_id) + + guarantee = db.query(ParkingGuarantee).filter( + ParkingGuarantee.id == guarantee_id, + ParkingGuarantee.office_id == office_id + ).first() + if not guarantee: + raise HTTPException(status_code=404, detail="Guarantee not found") + + db.delete(guarantee) + db.commit() + return {"message": "Guarantee removed"} + + +# Exclusions +@router.get("/{office_id}/exclusions") +def get_office_exclusions(office_id: str, db: Session = Depends(get_db), user=Depends(require_manager_or_admin)): + """Get parking exclusions for an office""" + check_office_access(user, office_id) + + exclusions = db.query(ParkingExclusion).filter(ParkingExclusion.office_id == office_id).all() + + # Batch query to get all user names at once + user_ids = [e.user_id for e in exclusions] + if user_ids: + users = db.query(User).filter(User.id.in_(user_ids)).all() + user_lookup = {u.id: u.name for u in users} + else: + user_lookup = {} + + return [ + { + "id": e.id, + "user_id": e.user_id, + "user_name": user_lookup.get(e.user_id), + "start_date": e.start_date, + "end_date": e.end_date, + "notes": e.notes + } + for e in exclusions + ] + + +@router.post("/{office_id}/exclusions") +def add_office_exclusion(office_id: str, data: ExclusionCreate, db: Session = Depends(get_db), user=Depends(require_manager_or_admin)): + """Add parking exclusion for an office""" + check_office_access(user, office_id) + + office = db.query(Office).filter(Office.id == office_id).first() + if not office: + raise HTTPException(status_code=404, detail="Office not found") + if not db.query(User).filter(User.id == data.user_id).first(): + raise HTTPException(status_code=404, detail="User not found") + + existing = db.query(ParkingExclusion).filter( + ParkingExclusion.office_id == office_id, + ParkingExclusion.user_id == data.user_id + ).first() + if existing: + raise HTTPException(status_code=400, detail="User already has a parking exclusion") + + if data.start_date and data.end_date and data.end_date < data.start_date: + raise HTTPException(status_code=400, detail="End date must be after start date") + + exclusion = ParkingExclusion( + id=generate_uuid(), + office_id=office_id, + user_id=data.user_id, + start_date=data.start_date, + end_date=data.end_date, + notes=data.notes, + created_at=datetime.utcnow() + ) + db.add(exclusion) + db.commit() + return {"id": exclusion.id, "message": "Exclusion added"} + + +@router.delete("/{office_id}/exclusions/{exclusion_id}") +def remove_office_exclusion(office_id: str, exclusion_id: str, db: Session = Depends(get_db), user=Depends(require_manager_or_admin)): + """Remove parking exclusion for an office""" + check_office_access(user, office_id) + + exclusion = db.query(ParkingExclusion).filter( + ParkingExclusion.id == exclusion_id, + ParkingExclusion.office_id == office_id + ).first() + if not exclusion: + raise HTTPException(status_code=404, detail="Exclusion not found") + + db.delete(exclusion) + db.commit() + return {"message": "Exclusion removed"} diff --git a/app/routes/parking.py b/app/routes/parking.py index 0f32b23..8cd2c36 100644 --- a/app/routes/parking.py +++ b/app/routes/parking.py @@ -2,21 +2,33 @@ Parking Management Routes Parking assignments, spot management, and pool initialization +Manager-centric model: +- Managers own parking spots (defined by manager_parking_quota) +- Spots are named with manager's letter prefix (A1, A2, B1, B2...) +- Assignments reference manager_id directly +""" +""" +Parking Management Routes +Parking assignments, spot management, and pool initialization + Manager-centric model: - Managers own parking spots (defined by manager_parking_quota) - Spots are named with manager's letter prefix (A1, A2, B1, B2...) - Assignments reference manager_id directly """ from typing import List -from datetime import datetime +from datetime import datetime, date from fastapi import APIRouter, Depends, HTTPException from pydantic import BaseModel from sqlalchemy.orm import Session from database.connection import get_db -from database.models import DailyParkingAssignment, User +from database.models import DailyParkingAssignment, User, UserRole, Office from utils.auth_middleware import get_current_user, require_manager_or_admin -from services.parking import initialize_parking_pool, get_spot_display_name +from services.parking import ( + initialize_parking_pool, get_spot_display_name, release_user_spot, + run_batch_allocation, clear_assignments_for_office_date +) from services.notifications import notify_parking_assigned, notify_parking_released, notify_parking_reassigned from app import config @@ -25,14 +37,14 @@ router = APIRouter(prefix="/api/parking", tags=["parking"]) # Request/Response Models class InitPoolRequest(BaseModel): - date: str # YYYY-MM-DD + date: date class ManualAssignRequest(BaseModel): - manager_id: str + office_id: str user_id: str spot_id: str - date: str + date: date class ReassignSpotRequest(BaseModel): @@ -42,50 +54,57 @@ class ReassignSpotRequest(BaseModel): class AssignmentResponse(BaseModel): id: str - date: str + date: date spot_id: str spot_display_name: str | None = None user_id: str | None - manager_id: str + office_id: str user_name: str | None = None user_email: str | None = None +class RunAllocationRequest(BaseModel): + date: date + office_id: str + + +class ClearAssignmentsRequest(BaseModel): + date: date + office_id: str + + # Routes -@router.post("/init-manager-pool") -def init_manager_pool(request: InitPoolRequest, db: Session = Depends(get_db), current_user=Depends(require_manager_or_admin)): - """Initialize parking pool for a manager on a given date""" - try: - datetime.strptime(request.date, "%Y-%m-%d") - except ValueError: - raise HTTPException(status_code=400, detail="Invalid date format") +@router.post("/init-office-pool") +def init_office_pool(request: InitPoolRequest, db: Session = Depends(get_db), current_user=Depends(require_manager_or_admin)): + """Initialize parking pool for an office on a given date""" + pool_date = request.date - quota = current_user.manager_parking_quota or 0 - if quota == 0: - return {"success": True, "message": "No parking quota configured", "spots": 0} + if not current_user.office_id: + raise HTTPException(status_code=400, detail="User does not belong to an office") + + office = db.query(Office).filter(Office.id == current_user.office_id).first() + if not office or not office.parking_quota: + return {"success": True, "message": "No parking quota configured", "spots": 0} - spots = initialize_parking_pool(current_user.id, quota, request.date, db) + spots = initialize_parking_pool(office.id, office.parking_quota, pool_date, db) return {"success": True, "spots": spots} -@router.get("/assignments/{date}", response_model=List[AssignmentResponse]) -def get_assignments(date: str, manager_id: str = None, db: Session = Depends(get_db), current_user=Depends(get_current_user)): - """Get parking assignments for a date, optionally filtered by manager""" - try: - datetime.strptime(date, "%Y-%m-%d") - except ValueError: - raise HTTPException(status_code=400, detail="Invalid date format") +@router.get("/assignments/{date_val}", response_model=List[AssignmentResponse]) +def get_assignments(date_val: date, office_id: str = None, db: Session = Depends(get_db), current_user=Depends(get_current_user)): + """Get parking assignments for a date, optionally filtered by office""" + query_date = date_val - query = db.query(DailyParkingAssignment).filter(DailyParkingAssignment.date == date) - if manager_id: - query = query.filter(DailyParkingAssignment.manager_id == manager_id) + query = db.query(DailyParkingAssignment).filter(DailyParkingAssignment.date == query_date) + if office_id: + query = query.filter(DailyParkingAssignment.office_id == office_id) assignments = query.all() results = [] for assignment in assignments: - # Get display name using manager's spot prefix - spot_display_name = get_spot_display_name(assignment.spot_id, assignment.manager_id, db) + # Get display name using office's spot prefix + spot_display_name = get_spot_display_name(assignment.spot_id, assignment.office_id, db) result = AssignmentResponse( id=assignment.id, @@ -93,7 +112,7 @@ def get_assignments(date: str, manager_id: str = None, db: Session = Depends(get spot_id=assignment.spot_id, spot_display_name=spot_display_name, user_id=assignment.user_id, - manager_id=assignment.manager_id + office_id=assignment.office_id ) if assignment.user_id: @@ -108,7 +127,7 @@ def get_assignments(date: str, manager_id: str = None, db: Session = Depends(get @router.get("/my-assignments", response_model=List[AssignmentResponse]) -def get_my_assignments(start_date: str = None, end_date: str = None, db: Session = Depends(get_db), current_user=Depends(get_current_user)): +def get_my_assignments(start_date: date = None, end_date: date = None, db: Session = Depends(get_db), current_user=Depends(get_current_user)): """Get current user's parking assignments""" query = db.query(DailyParkingAssignment).filter( DailyParkingAssignment.user_id == current_user.id @@ -123,7 +142,7 @@ def get_my_assignments(start_date: str = None, end_date: str = None, db: Session results = [] for assignment in assignments: - spot_display_name = get_spot_display_name(assignment.spot_id, assignment.manager_id, db) + spot_display_name = get_spot_display_name(assignment.spot_id, assignment.office_id, db) results.append(AssignmentResponse( id=assignment.id, @@ -131,7 +150,7 @@ def get_my_assignments(start_date: str = None, end_date: str = None, db: Session spot_id=assignment.spot_id, spot_display_name=spot_display_name, user_id=assignment.user_id, - manager_id=assignment.manager_id, + office_id=assignment.office_id, user_name=current_user.name, user_email=current_user.email )) @@ -139,27 +158,55 @@ def get_my_assignments(start_date: str = None, end_date: str = None, db: Session return results + return results + + +@router.post("/run-allocation") +def run_fair_allocation(data: RunAllocationRequest, db: Session = Depends(get_db), current_user=Depends(require_manager_or_admin)): + """Manually trigger fair allocation for a date (Test Tool)""" + # Verify office access + if current_user.role == UserRole.MANAGER and current_user.office_id != data.office_id: + raise HTTPException(status_code=403, detail="Not authorized for this office") + + result = run_batch_allocation(data.office_id, data.date, db) + return {"message": "Allocation completed", "result": result} + + +@router.post("/clear-assignments") +def clear_assignments(data: ClearAssignmentsRequest, db: Session = Depends(get_db), current_user=Depends(require_manager_or_admin)): + """Clear all assignments for a date (Test Tool)""" + # Verify office access + if current_user.role == UserRole.MANAGER and current_user.office_id != data.office_id: + raise HTTPException(status_code=403, detail="Not authorized for this office") + + count = clear_assignments_for_office_date(data.office_id, data.date, db) + return {"message": "Assignments cleared", "count": count} + + @router.post("/manual-assign") def manual_assign(data: ManualAssignRequest, db: Session = Depends(get_db), current_user=Depends(require_manager_or_admin)): """Manually assign a spot to a user""" + assign_date = data.date + # Verify user exists user = db.query(User).filter(User.id == data.user_id).first() if not user: raise HTTPException(status_code=404, detail="User not found") - # Verify manager exists and check permission - manager = db.query(User).filter(User.id == data.manager_id, User.role == "manager").first() - if not manager: - raise HTTPException(status_code=404, detail="Manager not found") + # Verify office exists + office = db.query(Office).filter(Office.id == data.office_id).first() + if not office: + raise HTTPException(status_code=404, detail="Office not found") - # Only admin or the manager themselves can assign spots - if current_user.role != "admin" and current_user.id != data.manager_id: - raise HTTPException(status_code=403, detail="Not authorized to assign spots for this manager") + # Only admin or the manager of that office can assign spots + is_manager = (current_user.role == UserRole.MANAGER and current_user.office_id == data.office_id) + if current_user.role != UserRole.ADMIN and not is_manager: + raise HTTPException(status_code=403, detail="Not authorized to assign spots for this office") # Check if spot exists and is free spot = db.query(DailyParkingAssignment).filter( - DailyParkingAssignment.manager_id == data.manager_id, - DailyParkingAssignment.date == data.date, + DailyParkingAssignment.office_id == data.office_id, + DailyParkingAssignment.date == assign_date, DailyParkingAssignment.spot_id == data.spot_id ).first() @@ -170,7 +217,7 @@ def manual_assign(data: ManualAssignRequest, db: Session = Depends(get_db), curr # Check if user already has a spot for this date (from any manager) existing = db.query(DailyParkingAssignment).filter( - DailyParkingAssignment.date == data.date, + DailyParkingAssignment.date == assign_date, DailyParkingAssignment.user_id == data.user_id ).first() @@ -180,7 +227,7 @@ def manual_assign(data: ManualAssignRequest, db: Session = Depends(get_db), curr spot.user_id = data.user_id db.commit() - spot_display_name = get_spot_display_name(data.spot_id, data.manager_id, db) + spot_display_name = get_spot_display_name(data.spot_id, data.office_id, db) return {"message": "Spot assigned", "spot_id": data.spot_id, "spot_display_name": spot_display_name} @@ -198,7 +245,7 @@ def release_my_spot(assignment_id: str, db: Session = Depends(get_db), current_u raise HTTPException(status_code=403, detail="You can only release your own parking spot") # Get spot display name for notification - spot_display_name = get_spot_display_name(assignment.spot_id, assignment.manager_id, db) + spot_display_name = get_spot_display_name(assignment.spot_id, assignment.office_id, db) assignment.user_id = None db.commit() @@ -223,9 +270,9 @@ def reassign_spot(data: ReassignSpotRequest, db: Session = Depends(get_db), curr raise HTTPException(status_code=404, detail="Assignment not found") # Check permission: admin, manager who owns the spot, or current spot holder - is_admin = current_user.role == 'admin' + is_admin = current_user.role == UserRole.ADMIN is_spot_owner = assignment.user_id == current_user.id - is_manager = current_user.id == assignment.manager_id + is_manager = (current_user.role == UserRole.MANAGER and current_user.office_id == assignment.office_id) if not (is_admin or is_manager or is_spot_owner): raise HTTPException(status_code=403, detail="Not authorized to reassign this spot") @@ -235,9 +282,17 @@ def reassign_spot(data: ReassignSpotRequest, db: Session = Depends(get_db), curr old_user = db.query(User).filter(User.id == old_user_id).first() if old_user_id else None # Get spot display name for notifications - spot_display_name = get_spot_display_name(assignment.spot_id, assignment.manager_id, db) + spot_display_name = get_spot_display_name(assignment.spot_id, assignment.office_id, db) - if data.new_user_id: + if data.new_user_id == "auto": + # "Auto assign" means releasing the spot so the system picks the next person + # release_user_spot returns True if it released it (and potentially reassigned it) + success = release_user_spot(assignment.office_id, assignment.user_id, assignment.date, db) + if not success: + raise HTTPException(status_code=400, detail="Could not auto-reassign spot") + return {"message": "Spot released for auto-assignment"} + + elif data.new_user_id: # Check new user exists new_user = db.query(User).filter(User.id == data.new_user_id).first() if not new_user: @@ -275,7 +330,7 @@ def reassign_spot(data: ReassignSpotRequest, db: Session = Depends(get_db), curr db.refresh(assignment) # Build response - spot_display_name = get_spot_display_name(assignment.spot_id, assignment.manager_id, db) + spot_display_name = get_spot_display_name(assignment.spot_id, assignment.office_id, db) result = AssignmentResponse( id=assignment.id, @@ -283,7 +338,7 @@ def reassign_spot(data: ReassignSpotRequest, db: Session = Depends(get_db), curr spot_id=assignment.spot_id, spot_display_name=spot_display_name, user_id=assignment.user_id, - manager_id=assignment.manager_id + office_id=assignment.office_id ) if assignment.user_id: @@ -308,16 +363,16 @@ def get_eligible_users(assignment_id: str, db: Session = Depends(get_db), curren raise HTTPException(status_code=404, detail="Assignment not found") # Check permission: admin, manager who owns the spot, or current spot holder - is_admin = current_user.role == 'admin' + is_admin = current_user.role == UserRole.ADMIN is_spot_owner = assignment.user_id == current_user.id - is_manager = current_user.id == assignment.manager_id + is_manager = (current_user.role == UserRole.MANAGER and current_user.office_id == assignment.office_id) if not (is_admin or is_manager or is_spot_owner): raise HTTPException(status_code=403, detail="Not authorized") - # Get users in this manager's team (including the manager themselves) + # Get users in this office (including the manager themselves) users = db.query(User).filter( - (User.manager_id == assignment.manager_id) | (User.id == assignment.manager_id), + User.office_id == assignment.office_id, User.id != assignment.user_id # Exclude current holder ).all() diff --git a/app/routes/presence.py b/app/routes/presence.py index da5c7b6..1e073eb 100644 --- a/app/routes/presence.py +++ b/app/routes/presence.py @@ -3,13 +3,13 @@ Presence Management Routes User presence marking and admin management """ from typing import List -from datetime import datetime, timedelta -from fastapi import APIRouter, Depends, HTTPException +from datetime import datetime, timedelta, date +from fastapi import APIRouter, Depends, HTTPException, Query from pydantic import BaseModel from sqlalchemy.orm import Session from database.connection import get_db -from database.models import UserPresence, User, DailyParkingAssignment +from database.models import UserPresence, User, DailyParkingAssignment, UserRole, PresenceStatus, Office from utils.auth_middleware import get_current_user, require_manager_or_admin from utils.helpers import generate_uuid from services.parking import handle_presence_change, get_spot_display_name @@ -20,38 +20,26 @@ router = APIRouter(prefix="/api/presence", tags=["presence"]) # Request/Response Models class PresenceMarkRequest(BaseModel): - date: str # YYYY-MM-DD - status: str # present, remote, absent + date: date + status: PresenceStatus class AdminPresenceMarkRequest(BaseModel): user_id: str - date: str - status: str + date: date + status: PresenceStatus -class BulkPresenceRequest(BaseModel): - start_date: str - end_date: str - status: str - days: List[int] | None = None # Optional: [0,1,2,3,4] for Mon-Fri -class AdminBulkPresenceRequest(BaseModel): - user_id: str - start_date: str - end_date: str - status: str - days: List[int] | None = None - class PresenceResponse(BaseModel): id: str user_id: str - date: str - status: str - created_at: str | None - updated_at: str | None + date: date + status: PresenceStatus + created_at: datetime | None + updated_at: datetime | None parking_spot_number: str | None = None class Config: @@ -59,51 +47,38 @@ class PresenceResponse(BaseModel): # Helper functions -def validate_status(status: str): - if status not in ["present", "remote", "absent"]: - raise HTTPException(status_code=400, detail="Status must be: present, remote, or absent") - - -def parse_date(date_str: str) -> datetime: - try: - return datetime.strptime(date_str, "%Y-%m-%d") - except ValueError: - raise HTTPException(status_code=400, detail="Invalid date format. Use YYYY-MM-DD") - def check_manager_access(current_user: User, target_user: User, db: Session): """Check if current_user has access to target_user""" - if current_user.role == "admin": + if current_user.role == UserRole.ADMIN: return True - if current_user.role == "manager": - # Manager can access users they manage - if target_user.manager_id == current_user.id: + if current_user.role == UserRole.MANAGER: + # Manager can access users in their Office + if target_user.office_id == current_user.office_id: return True - raise HTTPException(status_code=403, detail="User is not managed by you") + raise HTTPException(status_code=403, detail="User is not in your office") raise HTTPException(status_code=403, detail="Access denied") def _mark_presence_for_user( user_id: str, - date: str, - status: str, + presence_date: date, + status: PresenceStatus, db: Session, target_user: User ) -> UserPresence: """ Core presence marking logic - shared by user and admin routes. """ - validate_status(status) - parse_date(date) existing = db.query(UserPresence).filter( UserPresence.user_id == user_id, - UserPresence.date == date + UserPresence.date == presence_date ).first() - now = datetime.utcnow().isoformat() + now = datetime.utcnow() old_status = existing.status if existing else None if existing: @@ -116,7 +91,7 @@ def _mark_presence_for_user( presence = UserPresence( id=generate_uuid(), user_id=user_id, - date=date, + date=presence_date, status=status, created_at=now, updated_at=now @@ -125,114 +100,36 @@ def _mark_presence_for_user( db.commit() db.refresh(presence) - # Handle parking assignment - # Use manager_id if user has one, or user's own id if they are a manager - parking_manager_id = target_user.manager_id - if not parking_manager_id and target_user.role == "manager": - # Manager is part of their own team for parking purposes - parking_manager_id = target_user.id - - if old_status != status and parking_manager_id: + # Handle parking assignment (if user is in an office) + if target_user.office_id and old_status != status: try: handle_presence_change( - user_id, date, - old_status or "absent", status, - parking_manager_id, db + user_id, presence_date, + old_status or PresenceStatus.ABSENT, status, + target_user.office_id, db ) except Exception as e: - config.logger.warning(f"Parking handler failed for user {user_id} on {date}: {e}") + config.logger.warning(f"Parking handler failed for user {user_id} on {presence_date}: {e}") return presence -def _bulk_mark_presence( - user_id: str, - start_date: str, - end_date: str, - status: str, - days: List[int] | None, - db: Session, - target_user: User -) -> List[UserPresence]: - """ - Core bulk presence marking logic - shared by user and admin routes. - """ - validate_status(status) - start = parse_date(start_date) - end = parse_date(end_date) - if end < start: - raise HTTPException(status_code=400, detail="End date must be after start date") - if (end - start).days > 90: - raise HTTPException(status_code=400, detail="Range cannot exceed 90 days") - - results = [] - current_date = start - now = datetime.utcnow().isoformat() - - while current_date <= end: - if days is None or current_date.weekday() in days: - date_str = current_date.strftime("%Y-%m-%d") - - existing = db.query(UserPresence).filter( - UserPresence.user_id == user_id, - UserPresence.date == date_str - ).first() - - old_status = existing.status if existing else None - - if existing: - existing.status = status - existing.updated_at = now - results.append(existing) - else: - presence = UserPresence( - id=generate_uuid(), - user_id=user_id, - date=date_str, - status=status, - created_at=now, - updated_at=now - ) - db.add(presence) - results.append(presence) - - # Handle parking for each date - # Use manager_id if user has one, or user's own id if they are a manager - parking_manager_id = target_user.manager_id - if not parking_manager_id and target_user.role == "manager": - parking_manager_id = target_user.id - - if old_status != status and parking_manager_id: - try: - handle_presence_change( - user_id, date_str, - old_status or "absent", status, - parking_manager_id, db - ) - except Exception as e: - config.logger.warning(f"Parking handler failed for user {user_id} on {date_str}: {e}") - - current_date += timedelta(days=1) - - db.commit() - return results def _delete_presence( user_id: str, - date: str, + presence_date: date, db: Session, target_user: User ) -> dict: """ Core presence deletion logic - shared by user and admin routes. """ - parse_date(date) presence = db.query(UserPresence).filter( UserPresence.user_id == user_id, - UserPresence.date == date + UserPresence.date == presence_date ).first() if not presence: @@ -242,20 +139,15 @@ def _delete_presence( db.delete(presence) db.commit() - # Use manager_id if user has one, or user's own id if they are a manager - parking_manager_id = target_user.manager_id - if not parking_manager_id and target_user.role == "manager": - parking_manager_id = target_user.id - - if parking_manager_id: + if target_user.office_id: try: handle_presence_change( - user_id, date, - old_status, "absent", - parking_manager_id, db + user_id, presence_date, + old_status, PresenceStatus.ABSENT, + target_user.office_id, db ) except Exception as e: - config.logger.warning(f"Parking handler failed for user {user_id} on {date}: {e}") + config.logger.warning(f"Parking handler failed for user {user_id} on {presence_date}: {e}") return {"message": "Presence deleted"} @@ -267,34 +159,26 @@ def mark_presence(data: PresenceMarkRequest, db: Session = Depends(get_db), curr return _mark_presence_for_user(current_user.id, data.date, data.status, db, current_user) -@router.post("/mark-bulk", response_model=List[PresenceResponse]) -def mark_bulk_presence(data: BulkPresenceRequest, db: Session = Depends(get_db), current_user=Depends(get_current_user)): - """Mark presence for a date range""" - return _bulk_mark_presence( - current_user.id, data.start_date, data.end_date, - data.status, data.days, db, current_user - ) + @router.get("/my-presences", response_model=List[PresenceResponse]) -def get_my_presences(start_date: str = None, end_date: str = None, db: Session = Depends(get_db), current_user=Depends(get_current_user)): +def get_my_presences(start_date: date = None, end_date: date = None, db: Session = Depends(get_db), current_user=Depends(get_current_user)): """Get current user's presences""" query = db.query(UserPresence).filter(UserPresence.user_id == current_user.id) if start_date: - parse_date(start_date) query = query.filter(UserPresence.date >= start_date) if end_date: - parse_date(end_date) query = query.filter(UserPresence.date <= end_date) return query.order_by(UserPresence.date.desc()).all() -@router.delete("/{date}") -def delete_presence(date: str, db: Session = Depends(get_db), current_user=Depends(get_current_user)): +@router.delete("/{date_val}") +def delete_presence(date_val: date, db: Session = Depends(get_db), current_user=Depends(get_current_user)): """Delete presence for a date""" - return _delete_presence(current_user.id, date, db, current_user) + return _delete_presence(current_user.id, date_val, db, current_user) # Admin/Manager Routes @@ -309,66 +193,47 @@ def admin_mark_presence(data: AdminPresenceMarkRequest, db: Session = Depends(ge return _mark_presence_for_user(data.user_id, data.date, data.status, db, target_user) -@router.post("/admin/mark-bulk", response_model=List[PresenceResponse]) -def admin_mark_bulk_presence(data: AdminBulkPresenceRequest, db: Session = Depends(get_db), current_user=Depends(require_manager_or_admin)): - """Bulk mark presence for any user (manager/admin)""" - target_user = db.query(User).filter(User.id == data.user_id).first() - if not target_user: - raise HTTPException(status_code=404, detail="User not found") - - check_manager_access(current_user, target_user, db) - return _bulk_mark_presence( - data.user_id, data.start_date, data.end_date, - data.status, data.days, db, target_user - ) -@router.delete("/admin/{user_id}/{date}") -def admin_delete_presence(user_id: str, date: str, db: Session = Depends(get_db), current_user=Depends(require_manager_or_admin)): + +@router.delete("/admin/{user_id}/{date_val}") +def admin_delete_presence(user_id: str, date_val: date, db: Session = Depends(get_db), current_user=Depends(require_manager_or_admin)): """Delete presence for any user (manager/admin)""" target_user = db.query(User).filter(User.id == user_id).first() if not target_user: raise HTTPException(status_code=404, detail="User not found") check_manager_access(current_user, target_user, db) - return _delete_presence(user_id, date, db, target_user) + return _delete_presence(user_id, date_val, db, target_user) @router.get("/team") -def get_team_presences(start_date: str, end_date: str, manager_id: str = None, db: Session = Depends(get_db), current_user=Depends(get_current_user)): - """Get team presences with parking info, filtered by manager. - - Admins can see all teams - - Managers see their own team - - Employees can only see their own team (read-only view) +def get_team_presences(start_date: date, end_date: date, office_id: str = None, db: Session = Depends(get_db), current_user=Depends(get_current_user)): + """Get office presences with parking info. + - Admins can see all users (or filter by office_id) + - Managers see their own office's users + - Employees can see their own office's users (read-only view) """ - parse_date(start_date) - parse_date(end_date) - # Get users based on permissions and manager filter - # Note: Manager is part of their own team (for parking assignment purposes) - if current_user.role == "employee": - # Employees can only see their own team (users with same manager_id + the manager) - if not current_user.manager_id: - return [] # No manager assigned, no team to show - users = db.query(User).filter( - (User.manager_id == current_user.manager_id) | (User.id == current_user.manager_id) - ).all() - elif manager_id: - # Filter by specific manager (for admins/managers) - include the manager themselves - users = db.query(User).filter( - (User.manager_id == manager_id) | (User.id == manager_id) - ).all() - elif current_user.role == "admin": - # Admin sees all users - users = db.query(User).all() + if current_user.role == UserRole.ADMIN: + if office_id: + users = db.query(User).filter(User.office_id == office_id).all() + else: + users = db.query(User).all() + + elif current_user.office_id: + # Non-admin users see their office members + users = db.query(User).filter(User.office_id == current_user.office_id).all() + else: - # Manager sees their team + themselves - users = db.query(User).filter( - (User.manager_id == current_user.id) | (User.id == current_user.id) - ).all() + # No office assigned + return [] - # Batch query presences and parking for all users + # Batch query presences and parking for all selected users user_ids = [u.id for u in users] + if not user_ids: + return [] + presences = db.query(UserPresence).filter( UserPresence.user_id.in_(user_ids), UserPresence.date >= start_date, @@ -389,7 +254,7 @@ def get_team_presences(start_date: str, end_date: str, manager_id: str = None, d parking_lookup[p.user_id] = [] parking_info_lookup[p.user_id] = [] parking_lookup[p.user_id].append(p.date) - spot_display_name = get_spot_display_name(p.spot_id, p.manager_id, db) + spot_display_name = get_spot_display_name(p.spot_id, p.office_id, db) parking_info_lookup[p.user_id].append({ "id": p.id, "date": p.date, @@ -397,10 +262,10 @@ def get_team_presences(start_date: str, end_date: str, manager_id: str = None, d "spot_display_name": spot_display_name }) - # Build manager lookup for display - manager_ids = list(set(u.manager_id for u in users if u.manager_id)) - managers = db.query(User).filter(User.id.in_(manager_ids)).all() if manager_ids else [] - manager_lookup = {m.id: m.name for m in managers} + # Build office lookup for display (replacing old manager_lookup) + office_ids = list(set(u.office_id for u in users if u.office_id)) + offices = db.query(Office).filter(Office.id.in_(office_ids)).all() if office_ids else [] + office_lookup = {o.id: o.name for o in offices} # Build response result = [] @@ -410,8 +275,8 @@ def get_team_presences(start_date: str, end_date: str, manager_id: str = None, d result.append({ "id": user.id, "name": user.name, - "manager_id": user.manager_id, - "manager_name": manager_lookup.get(user.manager_id), + "office_id": user.office_id, + "office_name": office_lookup.get(user.office_id), "presences": [{"date": p.date, "status": p.status} for p in user_presences], "parking_dates": parking_lookup.get(user.id, []), "parking_info": parking_info_lookup.get(user.id, []) @@ -421,7 +286,7 @@ def get_team_presences(start_date: str, end_date: str, manager_id: str = None, d @router.get("/admin/{user_id}") -def get_user_presences(user_id: str, start_date: str = None, end_date: str = None, db: Session = Depends(get_db), current_user=Depends(require_manager_or_admin)): +def get_user_presences(user_id: str, start_date: date = None, end_date: date = None, db: Session = Depends(get_db), current_user=Depends(require_manager_or_admin)): """Get any user's presences with parking info (manager/admin)""" target_user = db.query(User).filter(User.id == user_id).first() if not target_user: @@ -432,24 +297,23 @@ def get_user_presences(user_id: str, start_date: str = None, end_date: str = Non query = db.query(UserPresence).filter(UserPresence.user_id == user_id) if start_date: - parse_date(start_date) query = query.filter(UserPresence.date >= start_date) if end_date: - parse_date(end_date) query = query.filter(UserPresence.date <= end_date) presences = query.order_by(UserPresence.date.desc()).all() # Batch query parking assignments - date_strs = [p.date for p in presences] + dates = [p.date for p in presences] parking_map = {} - if date_strs: + if dates: + # Note: Assignments link to user. We can find spot display name by looking up assignment -> office assignments = db.query(DailyParkingAssignment).filter( DailyParkingAssignment.user_id == user_id, - DailyParkingAssignment.date.in_(date_strs) + DailyParkingAssignment.date.in_(dates) ).all() for a in assignments: - parking_map[a.date] = get_spot_display_name(a.spot_id, a.manager_id, db) + parking_map[a.date] = get_spot_display_name(a.spot_id, a.office_id, db) # Build response result = [] diff --git a/app/routes/users.py b/app/routes/users.py index 5a18ac5..76e2040 100644 --- a/app/routes/users.py +++ b/app/routes/users.py @@ -8,7 +8,7 @@ from pydantic import BaseModel, EmailStr from sqlalchemy.orm import Session from database.connection import get_db -from database.models import User +from database.models import User, UserRole, Office from utils.auth_middleware import get_current_user, require_admin from utils.helpers import ( generate_uuid, is_ldap_user, is_ldap_admin, @@ -25,16 +25,14 @@ class UserCreate(BaseModel): email: EmailStr password: str name: str | None = None - role: str = "employee" - manager_id: str | None = None + role: UserRole = UserRole.EMPLOYEE + office_id: str | None = None class UserUpdate(BaseModel): name: str | None = None - role: str | None = None - manager_id: str | None = None - manager_parking_quota: int | None = None - manager_spot_prefix: str | None = None + role: UserRole | None = None + office_id: str | None = None class ProfileUpdate(BaseModel): @@ -44,11 +42,11 @@ class ProfileUpdate(BaseModel): class SettingsUpdate(BaseModel): week_start_day: int | None = None # Notification preferences - notify_weekly_parking: int | None = None - notify_daily_parking: int | None = None + notify_weekly_parking: bool | None = None + notify_daily_parking: bool | None = None notify_daily_parking_hour: int | None = None notify_daily_parking_minute: int | None = None - notify_parking_changes: int | None = None + notify_parking_changes: bool | None = None class ChangePasswordRequest(BaseModel): @@ -60,61 +58,54 @@ class UserResponse(BaseModel): id: str email: str name: str | None - role: str - manager_id: str | None = None - manager_name: str | None = None - manager_parking_quota: int | None = None - manager_spot_prefix: str | None = None - managed_user_count: int | None = None + role: UserRole + office_id: str | None = None + office_name: str | None = None is_ldap_user: bool = False is_ldap_admin: bool = False created_at: str | None + parking_ratio: float | None = None class Config: from_attributes = True -def user_to_response(user: User, db: Session, manager_lookup: dict = None, managed_counts: dict = None) -> dict: +def user_to_response(user: User, db: Session, office_lookup: dict = None) -> dict: """ Convert user to response dict with computed fields. - - Args: - user: The user to convert - db: Database session - manager_lookup: Optional pre-fetched dict of manager_id -> name (for batch operations) - managed_counts: Optional pre-fetched dict of user_id -> managed_user_count (for batch operations) """ - # Get manager name - use lookup if available, otherwise query - manager_name = None - if user.manager_id: - if manager_lookup is not None: - manager_name = manager_lookup.get(user.manager_id) + # Get office name - use lookup if available, otherwise query + office_name = None + if user.office_id: + if office_lookup is not None: + office_name = office_lookup.get(user.office_id) else: - manager = db.query(User).filter(User.id == user.manager_id).first() - if manager: - manager_name = manager.name + office = db.query(Office).filter(Office.id == user.office_id).first() + if office: + office_name = office.name - # Count managed users if this user is a manager - managed_user_count = None - if user.role == "manager": - if managed_counts is not None: - managed_user_count = managed_counts.get(user.id, 0) - else: - managed_user_count = db.query(User).filter(User.manager_id == user.id).count() + # Calculate parking ratio (score) + parking_ratio = None + if user.office_id: + try: + # Avoid circular import by importing inside function if needed, + # or ensure services.parking doesn't import this file. + from services.parking import get_user_parking_ratio + parking_ratio = get_user_parking_ratio(user.id, user.office_id, db) + except ImportError: + pass return { "id": user.id, "email": user.email, "name": user.name, "role": user.role, - "manager_id": user.manager_id, - "manager_name": manager_name, - "manager_parking_quota": user.manager_parking_quota, - "manager_spot_prefix": user.manager_spot_prefix, - "managed_user_count": managed_user_count, + "office_id": user.office_id, + "office_name": office_name, "is_ldap_user": is_ldap_user(user), "is_ldap_admin": is_ldap_admin(user), - "created_at": user.created_at + "created_at": user.created_at.isoformat() if user.created_at else None, + "parking_ratio": parking_ratio } @@ -125,23 +116,12 @@ def list_users(db: Session = Depends(get_db), user=Depends(require_admin)): users = db.query(User).all() # Build lookups to avoid N+1 queries - # Manager lookup: id -> name - manager_ids = list(set(u.manager_id for u in users if u.manager_id)) - managers = db.query(User).filter(User.id.in_(manager_ids)).all() if manager_ids else [] - manager_lookup = {m.id: m.name for m in managers} + # Office lookup: id -> name + office_ids = list(set(u.office_id for u in users if u.office_id)) + offices = db.query(Office).filter(Office.id.in_(office_ids)).all() if office_ids else [] + office_lookup = {o.id: o.name for o in offices} - # Managed user counts for managers - from sqlalchemy import func - manager_user_ids = [u.id for u in users if u.role == "manager"] - if manager_user_ids: - counts = db.query(User.manager_id, func.count(User.id)).filter( - User.manager_id.in_(manager_user_ids) - ).group_by(User.manager_id).all() - managed_counts = {manager_id: count for manager_id, count in counts} - else: - managed_counts = {} - - return [user_to_response(u, db, manager_lookup, managed_counts) for u in users] + return [user_to_response(u, db, office_lookup) for u in users] @router.get("/{user_id}") @@ -162,18 +142,17 @@ def create_user(data: UserCreate, db: Session = Depends(get_db), user=Depends(re if db.query(User).filter(User.email == data.email).first(): raise HTTPException(status_code=400, detail="Email already registered") - if data.role not in ["admin", "manager", "employee"]: - raise HTTPException(status_code=400, detail="Invalid role") + # Role validation handled by Pydantic Enum # Validate password strength password_errors = validate_password(data.password) if password_errors: raise HTTPException(status_code=400, detail=format_password_errors(password_errors)) - if data.manager_id: - manager = db.query(User).filter(User.id == data.manager_id).first() - if not manager or manager.role != "manager": - raise HTTPException(status_code=400, detail="Invalid manager") + if data.office_id: + office = db.query(Office).filter(Office.id == data.office_id).first() + if not office: + raise HTTPException(status_code=400, detail="Invalid office") new_user = User( id=generate_uuid(), @@ -181,8 +160,8 @@ def create_user(data: UserCreate, db: Session = Depends(get_db), user=Depends(re password_hash=hash_password(data.password), name=data.name, role=data.role, - manager_id=data.manager_id, - created_at=datetime.utcnow().isoformat() + office_id=data.office_id, + created_at=datetime.utcnow() ) db.add(new_user) @@ -211,54 +190,20 @@ def update_user(user_id: str, data: UserUpdate, db: Session = Depends(get_db), u # Role update if data.role is not None: - if data.role not in ["admin", "manager", "employee"]: - raise HTTPException(status_code=400, detail="Invalid role") # Can't change admin role for LDAP admins (they get admin from parking_admins group) - if target_is_ldap_admin and data.role != "admin": + if target_is_ldap_admin and data.role != UserRole.ADMIN: raise HTTPException(status_code=400, detail="Admin role is managed by LDAP group (parking_admins)") - # If changing from manager to another role, check for managed users - if target.role == "manager" and data.role != "manager": - managed_count = db.query(User).filter(User.manager_id == user_id).count() - if managed_count > 0: - raise HTTPException(status_code=400, detail=f"Cannot change role: {managed_count} users are assigned to this manager") - # Clear manager-specific fields - target.manager_parking_quota = 0 - target.manager_spot_prefix = None target.role = data.role - # Manager assignment (any user including admins can be assigned to a manager) - if data.manager_id is not None: - if data.manager_id: - manager = db.query(User).filter(User.id == data.manager_id).first() - if not manager or manager.role != "manager": - raise HTTPException(status_code=400, detail="Invalid manager") - if data.manager_id == user_id: - raise HTTPException(status_code=400, detail="User cannot be their own manager") - target.manager_id = data.manager_id if data.manager_id else None + # Office assignment + if "office_id" in data.__fields_set__: + if data.office_id: + office = db.query(Office).filter(Office.id == data.office_id).first() + if not office: + raise HTTPException(status_code=400, detail="Invalid office") + target.office_id = data.office_id if data.office_id else None - # Manager-specific fields - if data.manager_parking_quota is not None: - if target.role != "manager": - raise HTTPException(status_code=400, detail="Parking quota only for managers") - target.manager_parking_quota = data.manager_parking_quota - - if data.manager_spot_prefix is not None: - if target.role != "manager": - raise HTTPException(status_code=400, detail="Spot prefix only for managers") - prefix = data.manager_spot_prefix.upper() if data.manager_spot_prefix else None - if prefix and not prefix.isalpha(): - raise HTTPException(status_code=400, detail="Spot prefix must be a letter") - # Check for duplicate prefix - if prefix: - existing = db.query(User).filter( - User.manager_spot_prefix == prefix, - User.id != user_id - ).first() - if existing: - raise HTTPException(status_code=400, detail=f"Spot prefix '{prefix}' is already used by another manager") - target.manager_spot_prefix = prefix - - target.updated_at = datetime.utcnow().isoformat() + target.updated_at = datetime.utcnow() db.commit() db.refresh(target) return user_to_response(target, db) @@ -274,12 +219,6 @@ def delete_user(user_id: str, db: Session = Depends(get_db), current_user=Depend if not target: raise HTTPException(status_code=404, detail="User not found") - # Check if user is a manager with managed users - if target.role == "manager": - managed_count = db.query(User).filter(User.manager_id == user_id).count() - if managed_count > 0: - raise HTTPException(status_code=400, detail=f"Cannot delete: {managed_count} users are assigned to this manager") - db.delete(target) db.commit() return {"message": "User deleted"} @@ -289,20 +228,20 @@ def delete_user(user_id: str, db: Session = Depends(get_db), current_user=Depend @router.get("/me/profile") def get_profile(db: Session = Depends(get_db), current_user=Depends(get_current_user)): """Get current user's profile""" - # Get manager name - manager_name = None - if current_user.manager_id: - manager = db.query(User).filter(User.id == current_user.manager_id).first() - if manager: - manager_name = manager.name + # Get office name + office_name = None + if current_user.office_id: + office = db.query(Office).filter(Office.id == current_user.office_id).first() + if office: + office_name = office.name return { "id": current_user.id, "email": current_user.email, "name": current_user.name, "role": current_user.role, - "manager_id": current_user.manager_id, - "manager_name": manager_name, + "office_id": current_user.office_id, + "office_name": office_name, "is_ldap_user": is_ldap_user(current_user) } @@ -314,7 +253,7 @@ def update_profile(data: ProfileUpdate, db: Session = Depends(get_db), current_u if is_ldap_user(current_user): raise HTTPException(status_code=400, detail="Name is managed by LDAP") current_user.name = data.name - current_user.updated_at = datetime.utcnow().isoformat() + current_user.updated_at = datetime.utcnow() db.commit() return {"message": "Profile updated"} @@ -325,11 +264,11 @@ def get_settings(current_user=Depends(get_current_user)): """Get current user's settings""" return { "week_start_day": get_notification_default(current_user.week_start_day, 0), - "notify_weekly_parking": get_notification_default(current_user.notify_weekly_parking, 1), - "notify_daily_parking": get_notification_default(current_user.notify_daily_parking, 1), + "notify_weekly_parking": get_notification_default(current_user.notify_weekly_parking, True), + "notify_daily_parking": get_notification_default(current_user.notify_daily_parking, True), "notify_daily_parking_hour": get_notification_default(current_user.notify_daily_parking_hour, 8), "notify_daily_parking_minute": get_notification_default(current_user.notify_daily_parking_minute, 0), - "notify_parking_changes": get_notification_default(current_user.notify_parking_changes, 1) + "notify_parking_changes": get_notification_default(current_user.notify_parking_changes, True) } @@ -337,8 +276,8 @@ def get_settings(current_user=Depends(get_current_user)): def update_settings(data: SettingsUpdate, db: Session = Depends(get_db), current_user=Depends(get_current_user)): """Update current user's settings""" if data.week_start_day is not None: - if data.week_start_day not in [0, 1]: - raise HTTPException(status_code=400, detail="Week start must be 0 (Sunday) or 1 (Monday)") + if data.week_start_day not in [0, 6]: + raise HTTPException(status_code=400, detail="Week start must be 0 (Monday) or 6 (Sunday)") current_user.week_start_day = data.week_start_day # Notification preferences @@ -361,7 +300,7 @@ def update_settings(data: SettingsUpdate, db: Session = Depends(get_db), current if data.notify_parking_changes is not None: current_user.notify_parking_changes = data.notify_parking_changes - current_user.updated_at = datetime.utcnow().isoformat() + current_user.updated_at = datetime.utcnow() db.commit() return { "message": "Settings updated", @@ -389,7 +328,7 @@ def change_password(data: ChangePasswordRequest, db: Session = Depends(get_db), raise HTTPException(status_code=400, detail=format_password_errors(password_errors)) current_user.password_hash = hash_password(data.new_password) - current_user.updated_at = datetime.utcnow().isoformat() + current_user.updated_at = datetime.utcnow() db.commit() config.logger.info(f"User {current_user.email} changed password") return {"message": "Password changed"} diff --git a/compose.yml b/compose.yml index 425989d..cd61b3e 100644 --- a/compose.yml +++ b/compose.yml @@ -3,8 +3,6 @@ services: build: . container_name: parking restart: unless-stopped - ports: - - "8000:8000" volumes: - ./data:/app/data env_file: @@ -21,6 +19,13 @@ services: start_period: 10s networks: - org-network + labels: + - "caddy=parking.lvh.me" + - "caddy.reverse_proxy={{upstreams 8000}}" + - "caddy.forward_auth=authelia:9091" + - "caddy.forward_auth.uri=/api/verify?rd=https://parking.lvh.me/" + - "caddy.forward_auth.copy_headers=Remote-User Remote-Groups Remote-Name Remote-Email" + networks: org-network: diff --git a/create_test_db.py b/create_test_db.py deleted file mode 100644 index dacb070..0000000 --- a/create_test_db.py +++ /dev/null @@ -1,132 +0,0 @@ -""" -Create test database with sample data -Run: .venv/bin/python create_test_db.py - -Manager-centric model: -- Users have a manager_id pointing to their manager -- Managers own parking spots (manager_parking_quota) -- Each manager has a spot prefix (A, B, C...) for display names -""" -import uuid -from datetime import datetime, timezone -from database.connection import engine, SessionLocal -from database.models import Base, User -from services.auth import hash_password - -# Drop and recreate all tables for clean slate -Base.metadata.drop_all(bind=engine) -Base.metadata.create_all(bind=engine) - -db = SessionLocal() - -now = datetime.now(timezone.utc).isoformat() -password_hash = hash_password("password123") - -# Create users with manager-centric model -# manager_id points to the user's manager - -users_data = [ - { - "id": "admin", - "email": "admin@example.com", - "name": "Admin User", - "role": "admin", - "manager_id": None, # Admins don't have managers - }, - { - "id": "manager1", - "email": "manager1@example.com", - "name": "Alice Manager", - "role": "manager", - "manager_id": None, # Managers don't have managers - "manager_parking_quota": 3, - "manager_spot_prefix": "A", - }, - { - "id": "manager2", - "email": "manager2@example.com", - "name": "Bob Manager", - "role": "manager", - "manager_id": None, - "manager_parking_quota": 2, - "manager_spot_prefix": "B", - }, - { - "id": "user1", - "email": "user1@example.com", - "name": "User One", - "role": "employee", - "manager_id": "manager1", # Managed by Alice - }, - { - "id": "user2", - "email": "user2@example.com", - "name": "User Two", - "role": "employee", - "manager_id": "manager1", # Managed by Alice - }, - { - "id": "user3", - "email": "user3@example.com", - "name": "User Three", - "role": "employee", - "manager_id": "manager1", # Managed by Alice - }, - { - "id": "user4", - "email": "user4@example.com", - "name": "User Four", - "role": "employee", - "manager_id": "manager2", # Managed by Bob - }, - { - "id": "user5", - "email": "user5@example.com", - "name": "User Five", - "role": "employee", - "manager_id": "manager2", # Managed by Bob - }, -] - -for data in users_data: - user = User( - id=data["id"], - email=data["email"], - password_hash=password_hash, - name=data["name"], - role=data["role"], - manager_id=data.get("manager_id"), - manager_parking_quota=data.get("manager_parking_quota", 0), - manager_spot_prefix=data.get("manager_spot_prefix"), - created_at=now - ) - db.add(user) - print(f"Created user: {user.email} ({user.role})") - -db.commit() -db.close() - -print("\n" + "="*60) -print("Test database created successfully!") -print("="*60) -print("\nTest accounts (all use password: password123):") -print("-"*60) -print(f"{'Email':<25} {'Role':<10} {'Manager':<15}") -print("-"*60) -print(f"{'admin@example.com':<25} {'admin':<10} {'-':<15}") -print(f"{'manager1@example.com':<25} {'manager':<10} {'-':<15}") -print(f"{'manager2@example.com':<25} {'manager':<10} {'-':<15}") -print(f"{'user1@example.com':<25} {'employee':<10} {'Alice':<15}") -print(f"{'user2@example.com':<25} {'employee':<10} {'Alice':<15}") -print(f"{'user3@example.com':<25} {'employee':<10} {'Alice':<15}") -print(f"{'user4@example.com':<25} {'employee':<10} {'Bob':<15}") -print(f"{'user5@example.com':<25} {'employee':<10} {'Bob':<15}") -print("-"*60) -print("\nParking pools:") -print(" Alice (manager1): 3 spots (A1,A2,A3)") -print(" -> manages: user1, user2, user3") -print(" -> 3 users, 3 spots = 100% ratio target") -print() -print(" Bob (manager2): 2 spots (B1,B2)") -print(" -> manages: user4, user5") -print(" -> 2 users, 2 spots = 100% ratio target") diff --git a/database/models.py b/database/models.py index de3f2df..9644375 100644 --- a/database/models.py +++ b/database/models.py @@ -2,12 +2,73 @@ SQLAlchemy ORM Models Clean, focused data models for parking management """ -from sqlalchemy import Column, String, Integer, Text, ForeignKey, Index +import enum +from sqlalchemy import Column, String, Integer, Text, ForeignKey, Index, Enum, Date, DateTime, Boolean from sqlalchemy.orm import relationship, declarative_base +from datetime import datetime, date Base = declarative_base() +class UserRole(str, enum.Enum): + ADMIN = "admin" + MANAGER = "manager" + EMPLOYEE = "employee" + + +class PresenceStatus(str, enum.Enum): + PRESENT = "present" + REMOTE = "remote" + ABSENT = "absent" + + +class NotificationType(str, enum.Enum): + PRESENCE_REMINDER = "presence_reminder" + WEEKLY_PARKING = "weekly_parking" + DAILY_PARKING = "daily_parking" + PARKING_CHANGE = "parking_change" + + +class WeekDay(enum.IntEnum): + # Matches Python's calendar (0=Monday)? No! + # The current DB convention in ManagerWeeklyClosingDay seems to be 0=Sunday based on comment: + # "0=Sunday, 1=Monday, ..., 6=Saturday" + # To keep consistency with existing logic comments, we'll stick to that, + # OR we can switch to standard Python (0=Monday). + # Plan said: "IntEnum matching DB convention (0=Sunday, 1=Monday, ...)" + MONDAY = 0 + TUESDAY = 1 + WEDNESDAY = 2 + THURSDAY = 3 + FRIDAY = 4 + SATURDAY = 5 + SUNDAY = 6 + + + +class Office(Base): + """Organization units that have parking spots""" + __tablename__ = "offices" + + id = Column(Text, primary_key=True) + name = Column(Text, nullable=False) + parking_quota = Column(Integer, default=0) + spot_prefix = Column(Text) # Letter prefix: A, B, C + + # Booking Window Settings (Batch Assignment) + booking_window_enabled = Column(Boolean, default=False) + booking_window_end_hour = Column(Integer, default=18) # 0-23 + booking_window_end_minute = Column(Integer, default=0) # 0-59 + + created_at = Column(DateTime, default=datetime.utcnow) + updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + + # Relationships + users = relationship("User", back_populates="office") + closing_days = relationship("OfficeClosingDay", back_populates="office", cascade="all, delete-orphan") + weekly_closing_days = relationship("OfficeWeeklyClosingDay", back_populates="office", cascade="all, delete-orphan") + + class User(Base): """Application users""" __tablename__ = "users" @@ -16,34 +77,30 @@ class User(Base): email = Column(Text, unique=True, nullable=False) password_hash = Column(Text) name = Column(Text) - role = Column(Text, nullable=False, default="employee") # admin, manager, employee - manager_id = Column(Text, ForeignKey("users.id")) # Who manages this user (any user can have a manager) - - # Manager-specific fields (only relevant for role='manager') - manager_parking_quota = Column(Integer, default=0) # Number of spots this manager controls - manager_spot_prefix = Column(Text) # Letter prefix for spots: A, B, C, etc. + role = Column(Enum(UserRole, values_callable=lambda obj: [e.value for e in obj]), nullable=False, default=UserRole.EMPLOYEE) + office_id = Column(Text, ForeignKey("offices.id")) # Which office this user belongs to # User preferences - week_start_day = Column(Integer, default=0) # 0=Sunday, 1=Monday, ..., 6=Saturday + week_start_day = Column(Integer, default=0) # 0=Sunday, 1=Monday, ... (Matches WeekDay logic) # Notification preferences - notify_weekly_parking = Column(Integer, default=1) # Weekly parking summary (Friday at 12) - notify_daily_parking = Column(Integer, default=1) # Daily parking reminder + notify_weekly_parking = Column(Boolean, default=True) # Weekly parking summary (Friday at 12) + notify_daily_parking = Column(Boolean, default=True) # Daily parking reminder notify_daily_parking_hour = Column(Integer, default=8) # Hour for daily reminder (0-23) notify_daily_parking_minute = Column(Integer, default=0) # Minute for daily reminder (0-59) - notify_parking_changes = Column(Integer, default=1) # Immediate notification on assignment changes + notify_parking_changes = Column(Boolean, default=True) # Immediate notification on assignment changes - created_at = Column(Text) - updated_at = Column(Text) + created_at = Column(DateTime, default=datetime.utcnow) + updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) # Relationships - manager = relationship("User", remote_side=[id], backref="managed_users") + office = relationship("Office", back_populates="users") presences = relationship("UserPresence", back_populates="user", cascade="all, delete-orphan") assignments = relationship("DailyParkingAssignment", back_populates="user", foreign_keys="DailyParkingAssignment.user_id") __table_args__ = ( Index('idx_user_email', 'email'), - Index('idx_user_manager', 'manager_id'), + Index('idx_user_office', 'office_id'), ) @@ -53,10 +110,10 @@ class UserPresence(Base): id = Column(Text, primary_key=True) user_id = Column(Text, ForeignKey("users.id", ondelete="CASCADE"), nullable=False) - date = Column(Text, nullable=False) # YYYY-MM-DD - status = Column(Text, nullable=False) # present, remote, absent - created_at = Column(Text) - updated_at = Column(Text) + date = Column(Date, nullable=False) + status = Column(Enum(PresenceStatus, values_callable=lambda obj: [e.value for e in obj]), nullable=False) # present, remote, absent + created_at = Column(DateTime, default=datetime.utcnow) + updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) # Relationships user = relationship("User", back_populates="presences") @@ -68,97 +125,100 @@ class UserPresence(Base): class DailyParkingAssignment(Base): - """Parking spot assignments per day - spots belong to managers""" + """Parking spot assignments per day - spots belong to offices""" __tablename__ = "daily_parking_assignments" id = Column(Text, primary_key=True) - date = Column(Text, nullable=False) # YYYY-MM-DD - spot_id = Column(Text, nullable=False) # A1, A2, B1, B2, etc. (prefix from manager) + date = Column(Date, nullable=False) + spot_id = Column(Text, nullable=False) # A1, A2, B1, B2, etc. (prefix from office) user_id = Column(Text, ForeignKey("users.id", ondelete="SET NULL")) - manager_id = Column(Text, ForeignKey("users.id", ondelete="CASCADE"), nullable=False) # Manager who owns the spot - created_at = Column(Text) + office_id = Column(Text, ForeignKey("offices.id", ondelete="CASCADE"), nullable=False) # Office that owns the spot + created_at = Column(DateTime, default=datetime.utcnow) # Relationships user = relationship("User", back_populates="assignments", foreign_keys=[user_id]) - manager = relationship("User", foreign_keys=[manager_id]) + office = relationship("Office") __table_args__ = ( - Index('idx_assignment_manager_date', 'manager_id', 'date'), + Index('idx_assignment_office_date', 'office_id', 'date'), Index('idx_assignment_user', 'user_id'), Index('idx_assignment_date_spot', 'date', 'spot_id'), ) -class ManagerClosingDay(Base): - """Specific date closing days for a manager's parking pool (holidays, special closures)""" - __tablename__ = "manager_closing_days" +class OfficeClosingDay(Base): + """Specific date closing days for an office's parking pool (holidays, special closures)""" + __tablename__ = "office_closing_days" id = Column(Text, primary_key=True) - manager_id = Column(Text, ForeignKey("users.id", ondelete="CASCADE"), nullable=False) - date = Column(Text, nullable=False) # YYYY-MM-DD + office_id = Column(Text, ForeignKey("offices.id", ondelete="CASCADE"), nullable=False) + date = Column(Date, nullable=False) + end_date = Column(Date) reason = Column(Text) # Relationships - manager = relationship("User") + office = relationship("Office", back_populates="closing_days") __table_args__ = ( - Index('idx_closing_manager_date', 'manager_id', 'date', unique=True), + Index('idx_closing_office_date', 'office_id', 'date', unique=True), ) -class ManagerWeeklyClosingDay(Base): - """Weekly recurring closing days for a manager's parking pool (e.g., Saturday and Sunday)""" - __tablename__ = "manager_weekly_closing_days" +class OfficeWeeklyClosingDay(Base): + """Weekly recurring closing days for an office's parking pool (e.g., Saturday and Sunday)""" + __tablename__ = "office_weekly_closing_days" id = Column(Text, primary_key=True) - manager_id = Column(Text, ForeignKey("users.id", ondelete="CASCADE"), nullable=False) - weekday = Column(Integer, nullable=False) # 0=Sunday, 1=Monday, ..., 6=Saturday + office_id = Column(Text, ForeignKey("offices.id", ondelete="CASCADE"), nullable=False) + weekday = Column(Integer, nullable=False) # 0=Sunday, 1=Monday, ..., 6=Saturday (Matches WeekDay Enum logic) # Relationships - manager = relationship("User") + office = relationship("Office", back_populates="weekly_closing_days") __table_args__ = ( - Index('idx_weekly_closing_manager_day', 'manager_id', 'weekday', unique=True), + Index('idx_weekly_closing_office_day', 'office_id', 'weekday', unique=True), ) class ParkingGuarantee(Base): - """Users guaranteed a parking spot when present (set by manager)""" + """Users guaranteed a parking spot when present (set by office manager)""" __tablename__ = "parking_guarantees" id = Column(Text, primary_key=True) - manager_id = Column(Text, ForeignKey("users.id", ondelete="CASCADE"), nullable=False) + office_id = Column(Text, ForeignKey("offices.id", ondelete="CASCADE"), nullable=False) user_id = Column(Text, ForeignKey("users.id", ondelete="CASCADE"), nullable=False) - start_date = Column(Text) # Optional: YYYY-MM-DD (null = no start limit) - end_date = Column(Text) # Optional: YYYY-MM-DD (null = no end limit) - created_at = Column(Text) + start_date = Column(Date) # Optional (null = no start limit) + end_date = Column(Date) # Optional (null = no end limit) + notes = Column(Text, nullable=True) + created_at = Column(DateTime, default=datetime.utcnow) # Relationships - manager = relationship("User", foreign_keys=[manager_id]) + office = relationship("Office") user = relationship("User", foreign_keys=[user_id]) __table_args__ = ( - Index('idx_guarantee_manager_user', 'manager_id', 'user_id', unique=True), + Index('idx_guarantee_office_user', 'office_id', 'user_id', unique=True), ) class ParkingExclusion(Base): - """Users excluded from parking assignment (set by manager)""" + """Users excluded from parking assignment (set by office manager)""" __tablename__ = "parking_exclusions" id = Column(Text, primary_key=True) - manager_id = Column(Text, ForeignKey("users.id", ondelete="CASCADE"), nullable=False) + office_id = Column(Text, ForeignKey("offices.id", ondelete="CASCADE"), nullable=False) user_id = Column(Text, ForeignKey("users.id", ondelete="CASCADE"), nullable=False) - start_date = Column(Text) # Optional: YYYY-MM-DD (null = no start limit) - end_date = Column(Text) # Optional: YYYY-MM-DD (null = no end limit) - created_at = Column(Text) + start_date = Column(Date) # Optional + end_date = Column(Date) # Optional + notes = Column(Text, nullable=True) + created_at = Column(DateTime, default=datetime.utcnow) # Relationships - manager = relationship("User", foreign_keys=[manager_id]) + office = relationship("Office") user = relationship("User", foreign_keys=[user_id]) __table_args__ = ( - Index('idx_exclusion_manager_user', 'manager_id', 'user_id', unique=True), + Index('idx_exclusion_office_user', 'office_id', 'user_id', unique=True), ) @@ -168,9 +228,9 @@ class NotificationLog(Base): id = Column(Text, primary_key=True) user_id = Column(Text, ForeignKey("users.id", ondelete="CASCADE"), nullable=False) - notification_type = Column(Text, nullable=False) # presence_reminder, weekly_parking, daily_parking, parking_change - reference_date = Column(Text) # The date/week this notification refers to (YYYY-MM-DD or YYYY-Www) - sent_at = Column(Text, nullable=False) + notification_type = Column(Enum(NotificationType, values_callable=lambda obj: [e.value for e in obj]), nullable=False) + reference_date = Column(Text) # The date/week this notification refers to (YYYY-MM-DD or YYYY-Www) - keeping as Text for flexibility + sent_at = Column(DateTime, default=datetime.utcnow) __table_args__ = ( Index('idx_notification_user_type_date', 'user_id', 'notification_type', 'reference_date'), @@ -183,11 +243,11 @@ class NotificationQueue(Base): id = Column(Text, primary_key=True) user_id = Column(Text, ForeignKey("users.id", ondelete="CASCADE"), nullable=False) - notification_type = Column(Text, nullable=False) # parking_change + notification_type = Column(Enum(NotificationType, values_callable=lambda obj: [e.value for e in obj]), nullable=False) # parking_change subject = Column(Text, nullable=False) body = Column(Text, nullable=False) - created_at = Column(Text, nullable=False) - sent_at = Column(Text) # null = not sent yet + created_at = Column(DateTime, default=datetime.utcnow) + sent_at = Column(DateTime) # null = not sent yet __table_args__ = ( Index('idx_queue_pending', 'sent_at'), diff --git a/deploy/Caddyfile.snippet b/deploy/Caddyfile.snippet deleted file mode 100644 index 0f589ca..0000000 --- a/deploy/Caddyfile.snippet +++ /dev/null @@ -1,8 +0,0 @@ -# Caddy configuration snippet for parking.rocketscale.it -# Add this block to org-stack/Caddyfile after the (auth) snippet definition - -# Parking Manager - Protected by Authelia -parking.rocketscale.it { - import auth - reverse_proxy parking:8000 -} diff --git a/deploy/DEPLOY.md b/deploy/DEPLOY.md deleted file mode 100644 index 07e9a20..0000000 --- a/deploy/DEPLOY.md +++ /dev/null @@ -1,219 +0,0 @@ -# Deployment Guide for parking.rocketscale.it - -## Prerequisites - -- org-stack running on rocky@rocketscale.it -- Git repository on git.rocketscale.it (optional, can use rsync) - -## Directory Structure - -Parking is deployed as a **separate directory** alongside org-stack: - -``` -~/ -├── org-stack/ # Main stack (Caddy, Authelia, LLDAP, etc.) -│ ├── compose.yml -│ ├── Caddyfile -│ ├── authelia/ -│ └── .env -│ -└── org-parking/ # Parking app (separate) - ├── compose.yml # Production compose (connects to org-stack network) - ├── .env # Own .env with PARKING_SECRET_KEY - ├── Dockerfile - └── ... -``` - -## Step 1: Deploy to Server - -Option A - Using rsync (recommended for development): -```bash -# From development machine -rsync -avz --exclude '.git' --exclude '__pycache__' --exclude '*.pyc' \ - --exclude '.env' --exclude 'data/' --exclude '*.db' --exclude '.venv/' \ - /path/to/org-parking/ rocky@rocketscale.it:~/org-parking/ -``` - -Option B - Using git: -```bash -ssh rocky@rocketscale.it -cd ~ -git clone git@git.rocketscale.it:rocky/parking-manager.git org-parking -``` - -## Step 2: Create Production compose.yml - -Create `~/org-parking/compose.yml` on the server: - -```yaml -services: - parking: - build: . - container_name: parking - restart: unless-stopped - volumes: - - parking_data:/app/data - environment: - - SECRET_KEY=${PARKING_SECRET_KEY} - - DATABASE_PATH=/app/data/parking.db - - AUTHELIA_ENABLED=true - - ALLOWED_ORIGINS=https://parking.rocketscale.it - - SMTP_HOST=${SMTP_HOST:-} - - SMTP_PORT=${SMTP_PORT:-587} - - SMTP_USER=${SMTP_USER:-} - - SMTP_PASSWORD=${SMTP_PASSWORD:-} - - SMTP_FROM=${SMTP_FROM:-} - networks: - - org-network - healthcheck: - test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"] - interval: 30s - timeout: 10s - retries: 3 - start_period: 10s - -volumes: - parking_data: - -networks: - org-network: - external: true - name: org-stack_org-network -``` - -## Step 3: Create .env File - -Create `~/org-parking/.env` with a secret key: - -```bash -cd ~/org-parking -python3 -c "import secrets; print(f'PARKING_SECRET_KEY={secrets.token_hex(32)}')" > .env -``` - -**Note**: Each directory needs its own `.env` file since docker compose only reads from the current directory. - -## Step 4: Add to Caddyfile - -Add to `~/org-stack/Caddyfile`: - -``` -# Parking Manager - Protected by Authelia -parking.rocketscale.it { - import auth - reverse_proxy parking:8000 -} -``` - -## Step 5: Add Authelia Access Control Rule - -**Important**: Authelia's `access_control` must include parking.rocketscale.it or you'll get 403 Forbidden. - -Edit `~/org-stack/authelia/configuration.yml` and add to the `access_control.rules` section: - -```yaml -access_control: - default_policy: deny - rules: - # ... existing rules ... - - # Parking Manager - require authentication - - domain: parking.rocketscale.it - policy: one_factor -``` - -After editing, restart Authelia: -```bash -cd ~/org-stack -docker compose restart authelia -``` - -## Step 6: Create LLDAP Group - -In lldap (https://ldap.rocketscale.it): - -1. Create group: `parking_admins` -2. Add yourself (or whoever should be admin) to this group - -**Role Management:** -- `parking_admins` group → **admin** role (synced from LLDAP on each login) -- **manager** role → assigned by admin in the app UI (Manage Users page) -- **employee** role → default for all other users - -The admin can promote users to manager and assign offices via the Manage Users page. - -## Step 7: Build and Deploy - -```bash -# Build and start parking service -cd ~/org-parking -docker compose build parking -docker compose up -d - -# Reload Caddy to pick up new domain (from org-stack) -cd ~/org-stack -docker compose exec caddy caddy reload --config /etc/caddy/Caddyfile - -# Check logs -cd ~/org-parking -docker compose logs -f parking -``` - -## Step 8: Verify - -1. Go to https://parking.rocketscale.it -2. You should be redirected to Authelia for login -3. After login, you should see the parking app -4. Your user should be auto-created with `admin` role (if in parking-admins group) - -## Troubleshooting - -### 403 Forbidden from Authelia -- Authelia's `access_control` doesn't have a rule for parking.rocketscale.it -- Add the domain to `~/org-stack/authelia/configuration.yml` (see Step 6) -- Restart Authelia: `docker compose restart authelia` - -### 401 Unauthorized -- Check Authelia headers are being passed -- Check `docker compose logs authelia` - -### Login redirect loop (keeps redirecting to /login) -- Frontend JS must use async auth checking for Authelia mode -- The `api.requireAuth()` must call `/api/auth/me` endpoint (not check localStorage) -- Ensure all page JS files use: `currentUser = await api.requireAuth();` - -### User has wrong role -- **Admin role**: Verify user is in `parking_admins` LLDAP group (synced on each login) -- **Manager role**: Must be assigned by admin via Manage Users page (not from LLDAP) -- **Employee role**: Default for users not in `parking_admins` group - -### Database errors -- Check volume mount: `docker compose exec parking ls -la /app/data` -- Check permissions: `docker compose exec parking id` - -## Architecture Notes - -### Authelia Integration -The app supports two authentication modes: -1. **JWT mode** (standalone): Users login via `/login`, get JWT token stored in localStorage -2. **Authelia mode** (SSO): Authelia handles login, passes headers to backend - -When `AUTHELIA_ENABLED=true`: -- Backend reads user info from headers: `Remote-User`, `Remote-Email`, `Remote-Name`, `Remote-Groups` -- Users are auto-created on first login -- Roles are synced from LLDAP groups on each request -- Frontend calls `/api/auth/me` to get user info (backend reads headers) - -### Role Management - -Only the **admin** role is synced from LLDAP: -```python -AUTHELIA_ADMIN_GROUP = "parking_admins" # → role: admin -``` - -Other roles are managed within the app: -- **manager**: Assigned by admin via Manage Users page -- **employee**: Default role for all non-admin users - -This separation allows: -- LLDAP to control who has admin access -- App admin to assign manager roles and office assignments without LLDAP changes diff --git a/deploy/compose.production.yml b/deploy/compose.production.yml deleted file mode 100644 index 6c3a5fc..0000000 --- a/deploy/compose.production.yml +++ /dev/null @@ -1,32 +0,0 @@ -# Production compose file for org-stack integration -# This will be added to ~/org-stack/compose.yml on the server - -services: - parking: - build: ./parking - container_name: parking - restart: unless-stopped - volumes: - - parking_data:/app/data - environment: - - SECRET_KEY=${PARKING_SECRET_KEY} - - DATABASE_PATH=/app/data/parking.db - - AUTHELIA_ENABLED=true - - ALLOWED_ORIGINS=https://parking.rocketscale.it - # SMTP (shared with other services) - - SMTP_HOST=${SMTP_HOST:-} - - SMTP_PORT=${SMTP_PORT:-587} - - SMTP_USER=${SMTP_USER:-} - - SMTP_PASSWORD=${SMTP_PASSWORD:-} - - SMTP_FROM=${SMTP_FROM:-} - networks: - - org-network - depends_on: - - authelia - -volumes: - parking_data: - -networks: - org-network: - external: true diff --git a/frontend/assets/parking-map.png b/frontend/assets/parking-map.png new file mode 100644 index 0000000..b4669c3 Binary files /dev/null and b/frontend/assets/parking-map.png differ diff --git a/frontend/css/styles.css b/frontend/css/styles.css index f1c1ea1..3a36154 100644 --- a/frontend/css/styles.css +++ b/frontend/css/styles.css @@ -30,7 +30,9 @@ /* ============================================================================ Reset & Base ============================================================================ */ -*, *::before, *::after { +*, +*::before, +*::after { box-sizing: border-box; margin: 0; padding: 0; @@ -54,7 +56,9 @@ button { cursor: pointer; } -input, select, textarea { +input, +select, +textarea { font-family: inherit; font-size: inherit; } @@ -431,11 +435,12 @@ input, select, textarea { width: 100%; max-width: 480px; max-height: 90vh; - overflow: auto; + overflow-y: auto; + overflow-x: hidden; } .modal-small { - max-width: 360px; + max-width: 420px; } .modal-header { @@ -612,16 +617,23 @@ input, select, textarea { .calendar-day .parking-badge { position: absolute; - bottom: 0.25rem; - left: 50%; - transform: translateX(-50%); + bottom: 6px; + left: 4px; + right: 4px; background: #dbeafe; color: #1e40af; - font-size: 0.6rem; + font-size: 0.8rem; font-weight: 600; - padding: 0.1rem 0.3rem; - border-radius: 3px; + padding: 0.3rem 0; + border-radius: 6px; + border: 1px solid #93c5fd; line-height: 1; + text-align: center; + transform: none; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05); } /* Status colors */ @@ -644,6 +656,28 @@ input, select, textarea { background: white; } +/* Closed Day */ +.calendar-day.closed { + background: #e5e7eb; + color: #9ca3af; + cursor: not-allowed; + border-color: #d1d5db; +} + +.calendar-day.closed:hover { + border-color: #d1d5db; +} + +.calendar-day.closed .day-number { + opacity: 0.5; +} + +.team-calendar td.closed { + background: #e5e7eb; + color: #9ca3af; + cursor: not-allowed; +} + /* Legend */ .legend { display: flex; @@ -975,11 +1009,11 @@ input, select, textarea { border-radius: 50%; } -.toggle-switch input:checked + .toggle-slider { +.toggle-switch input:checked+.toggle-slider { background-color: var(--primary); } -.toggle-switch input:checked + .toggle-slider:before { +.toggle-switch input:checked+.toggle-slider:before { transform: translateX(22px); } @@ -1747,3 +1781,24 @@ input, select, textarea { font-size: 0.75rem; } } + +/* Toast Animations */ +@keyframes slideInBottom { + from { + transform: translate(-50%, 100%); + opacity: 0; + } + to { + transform: translate(-50%, 0); + opacity: 1; + } +} + +@keyframes fadeOut { + from { + opacity: 1; + } + to { + opacity: 0; + } +} \ No newline at end of file diff --git a/frontend/js/admin-offices.js b/frontend/js/admin-offices.js new file mode 100644 index 0000000..87746d1 --- /dev/null +++ b/frontend/js/admin-offices.js @@ -0,0 +1,167 @@ +/** + * Admin Offices Page + * Manage offices, quotas, and prefixes + */ + +let currentUser = null; +let offices = []; + +document.addEventListener('DOMContentLoaded', async () => { + currentUser = await api.requireAuth(); + if (!currentUser) return; + + if (currentUser.role !== 'admin') { + window.location.href = '/presence'; + return; + } + + await loadOffices(); + setupEventListeners(); +}); + +async function loadOffices() { + const response = await api.get('/api/offices'); + if (response && response.ok) { + offices = await response.json(); + renderOffices(); + } +} + +function renderOffices() { + const tbody = document.getElementById('officesBody'); + + if (offices.length === 0) { + tbody.innerHTML = '
Nessun giorno di chiusura specifico.
'; return; } container.innerHTML = days.map(day => `Nessuna garanzia di parcheggio attiva.
'; return; } - container.innerHTML = guarantees.map(g => { - const dateRange = formatDateRange(g.start_date, g.end_date); - return ` + container.innerHTML = guarantees.map(g => `Nessuna esclusione attiva.
'; + return; + } + + container.innerHTML = exclusions.map(e => ` +| Nome | +Quota Posti | +Prefisso | +Utenti | +Azioni | +
|---|