fix landing page

This commit is contained in:
Stefano Manfredi
2025-12-02 23:18:43 +00:00
parent 7168fa4b72
commit ce9e2fdf2a
17 changed files with 727 additions and 457 deletions

View File

@@ -1,9 +1,15 @@
# Parking Manager Configuration
# Security - REQUIRED: Change in production!
# =============================================================================
# REQUIRED - Security
# =============================================================================
# MUST be set to a random string of at least 32 characters
# Generate with: openssl rand -hex 32
SECRET_KEY=change-me-to-a-random-string-at-least-32-chars
# =============================================================================
# Server
# =============================================================================
HOST=0.0.0.0
PORT=8000
@@ -13,14 +19,54 @@ DATABASE_PATH=/app/data/parking.db
# CORS (comma-separated origins, or * for all)
ALLOWED_ORIGINS=https://parking.rocketscale.it
# JWT token expiration (minutes, default 24 hours)
ACCESS_TOKEN_EXPIRE_MINUTES=1440
# Logging level (DEBUG, INFO, WARNING, ERROR)
LOG_LEVEL=INFO
# =============================================================================
# Rate Limiting
# =============================================================================
# Number of requests allowed per window for sensitive endpoints (login, register)
RATE_LIMIT_REQUESTS=5
# Window size in seconds
RATE_LIMIT_WINDOW=60
# =============================================================================
# Authentication
# =============================================================================
# Set to true when behind Authelia reverse proxy
AUTHELIA_ENABLED=false
# SMTP - Email Notifications (optional)
SMTP_HOST=
# Header names (only change if your proxy uses different headers)
AUTHELIA_HEADER_USER=Remote-User
AUTHELIA_HEADER_NAME=Remote-Name
AUTHELIA_HEADER_EMAIL=Remote-Email
AUTHELIA_HEADER_GROUPS=Remote-Groups
# LLDAP group that maps to admin role
AUTHELIA_ADMIN_GROUP=parking_admins
# External URLs for Authelia mode (used for landing page buttons)
# Login URL - Authelia's login page (users are redirected here to authenticate)
AUTHELIA_LOGIN_URL=https://auth.rocketscale.it
# Registration URL - External registration portal (org-stack self-registration)
REGISTRATION_URL=https://register.rocketscale.it
# =============================================================================
# Email Notifications
# =============================================================================
# Set to true to enable email sending
SMTP_ENABLED=false
# SMTP server configuration
SMTP_HOST=localhost
SMTP_PORT=587
SMTP_USER=
SMTP_PASSWORD=
SMTP_FROM=noreply@rocketscale.it
SMTP_FROM=noreply@parking.local
SMTP_USE_TLS=true
# When SMTP is disabled, emails are logged to this file
EMAIL_LOG_FILE=/tmp/parking-emails.log

278
CLAUDE.md
View File

@@ -9,21 +9,26 @@
- **Frontend:** Vanilla JavaScript (no frameworks)
- **Auth:** JWT tokens + Authelia SSO support
- **Containerization:** Docker + Docker Compose
- **Rate Limiting:** slowapi
### Architecture
```
app/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
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
python -m uvicorn main:app --reload --host 0.0.0.0 --port 8000
SECRET_KEY=dev-secret-key python -m uvicorn main:app --reload --host 0.0.0.0 --port 8000
# Docker
docker compose up -d
@@ -41,7 +46,8 @@ python create_test_db.py
- FastAPI async patterns with `Depends()` for dependency injection
- Pydantic models for request/response validation
- SQLAlchemy ORM (no raw SQL)
- UUIDs as string primary keys: `str(uuid.uuid4())`
- 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
@@ -53,152 +59,102 @@ python create_test_db.py
### Authentication
- Dual mode: JWT tokens (standalone) or Authelia headers (SSO)
- LDAP users have `password_hash = None`
- Check pattern: `config.AUTHELIA_ENABLED and user.password_hash is None`
- Use helper: `is_ldap_user(user)` from `utils.helpers`
---
## Known Issues & Technical Debt
### Critical
1. **Inactive Notification System**
- Location: [services/notifications.py](services/notifications.py)
- Issue: All code implemented but no scheduler integrated
- TODO: Integrate APScheduler or similar
2. **Default SECRET_KEY**
- Location: [app/config.py:13](app/config.py#L13)
- Issue: Defaults to "change-me-in-production"
- Fix: Add startup validation to error if default key used
3. **No CSRF Protection**
- Forms use token auth only, vulnerable to CSRF attacks
- Fix: Implement CSRF tokens or validate referer header
4. **No Rate Limiting**
- Login endpoint has no brute force protection
- Fix: Add slowapi or similar middleware
### Performance
1. **N+1 Query Problems**
- Location: [app/routes/managers.py:244-259](app/routes/managers.py#L244-L259)
- Location: [app/routes/presence.py:336-419](app/routes/presence.py#L336-L419)
- Issue: Loops that query database for each item
- Fix: Use joins and relationship loading
2. **Inefficient Spot Prefix Lookups**
- Location: [services/parking.py:56-64](services/parking.py#L56-L64)
- Issue: Repeated DB queries for same data
- Fix: Cache in request context
### Code Quality
1. **Duplicated LDAP Check Logic** (4+ locations)
```python
# Appears in: users.py:91, 168, 257, 280
is_ldap_user = config.AUTHELIA_ENABLED and user.password_hash is None
```
- Fix: Create `utils.is_ldap_user(user)` helper
2. **Inline JavaScript in HTML**
- 500+ lines embedded across pages
- Affected: team-rules.html, team-calendar.html, settings.html
- Fix: Extract to separate JS files
3. **Inconsistent Response Formats**
- Some endpoints return dicts, others Pydantic models
- Fix: Standardize on Pydantic response schemas
4. **God Object: User Model**
- Location: [database/models.py:11-47](database/models.py#L11-L47)
- Issue: 27 columns mixing auth, profile, preferences, manager settings
- Fix: Normalize into UserProfile, UserPreferences, ManagerSettings tables
5. **Repetitive CRUD in managers.py**
- Location: [app/routes/managers.py](app/routes/managers.py)
- Issue: 4 resources × 3 operations with near-identical code
- Fix: Create generic CRUD factory or base class
6. **Silent Exception Handling**
- Location: [app/routes/presence.py:135-143](app/routes/presence.py#L135-L143)
- Issue: Catches all exceptions and only prints
- Fix: Log properly and propagate meaningful errors
---
## Areas for Simplification
### 1. Consolidate Response Building
The `user_to_response()` pattern is duplicated in [users.py:76-107](app/routes/users.py#L76-L107) and [users.py:254-274](app/routes/users.py#L254-L274). Create single reusable function.
### 2. Generic CRUD Router Factory
[managers.py](app/routes/managers.py) has 12 nearly identical endpoints. Create:
### Utility Functions (`utils/helpers.py`)
```python
def create_crud_router(model: Type, schema: Type, parent_key: str):
router = APIRouter()
# Generate GET, POST, DELETE endpoints
return router
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
)
```
### 3. Frontend State Management
Each page maintains its own globals (`currentUser`, `currentData`). Consider simple app-level state or Web Components.
---
### 4. Date Handling
All dates stored as TEXT strings. Could use proper DATE columns for better query performance and validation.
## 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:
### 5. Notification Service Activation
Create [services/scheduler.py](services/scheduler.py) with APScheduler:
```python
from apscheduler.schedulers.asyncio import AsyncIOScheduler
scheduler = AsyncIOScheduler()
scheduler.add_job(process_notification_queue, 'interval', minutes=5)
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
---
## Security Improvements Needed
## Recent Improvements
| Priority | Issue | Location | Recommendation |
|----------|-------|----------|----------------|
| HIGH | Rate limiting | main.py | Add slowapi middleware |
| HIGH | CSRF protection | All forms | Implement CSRF tokens |
| HIGH | Secret validation | config.py:13 | Error on default key |
| MEDIUM | Password validation | auth.py:63-67 | Enforce complexity rules |
| MEDIUM | Input sanitization | notification emails | Use template library |
| LOW | CORS configuration | compose.yml | Document production settings |
### 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
## Testing Strategy (Missing)
### 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
No tests currently exist. Recommended structure:
```
tests/
├── conftest.py # Fixtures (test DB, client, auth)
├── test_auth.py # JWT and Authelia modes
├── test_parking.py # Fair assignment algorithm
├── test_presence.py # Bulk operations, team calendar
├── test_managers.py # CRUD operations
└── integration/
└── test_workflows.py # End-to-end scenarios
```
Key test scenarios:
1. Parking algorithm with varying ratios
2. Manager closing days affect assignments
3. Guarantee and exclusion rules
4. Authelia header authentication flow
5. LDAP vs local user password handling
### 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 (standalone mode)
- `POST /api/auth/login` - Get JWT token
- `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
@@ -226,25 +182,31 @@ Key test scenarios:
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 Migrations
### 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
### Frontend Page Pattern
```html
<script type="module">
import api from '/js/api.js';
import { initNav } from '/js/nav.js';
### Email Testing
With `SMTP_ENABLED=false`, check email log:
```bash
tail -f /tmp/parking-emails.log
```
document.addEventListener('DOMContentLoaded', async () => {
await api.checkAuth(true); // Redirect to login if not auth'd
initNav();
// Page-specific logic
});
</script>
### 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)
"
```
---
@@ -257,7 +219,35 @@ document.addEventListener('DOMContentLoaded', async () => {
| 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)

View File

@@ -3,16 +3,32 @@ Application Configuration
Environment-based settings with sensible defaults
"""
import os
import sys
import logging
from pathlib import Path
# Configure logging
LOG_LEVEL = os.getenv("LOG_LEVEL", "INFO").upper()
logging.basicConfig(
level=getattr(logging, LOG_LEVEL, logging.INFO),
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s"
)
logger = logging.getLogger("org-parking")
# Database
DATABASE_PATH = os.getenv("DATABASE_PATH", "parking.db")
DATABASE_URL = os.getenv("DATABASE_URL", f"sqlite:///{DATABASE_PATH}")
# JWT Authentication
SECRET_KEY = os.getenv("SECRET_KEY", "change-me-in-production")
SECRET_KEY = os.getenv("SECRET_KEY", "")
if not SECRET_KEY:
logger.error("FATAL: SECRET_KEY environment variable is required")
sys.exit(1)
if SECRET_KEY == "change-me-in-production":
logger.warning("WARNING: Using default SECRET_KEY - change this in production!")
ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES = 1440 # 24 hours
ACCESS_TOKEN_EXPIRE_MINUTES = int(os.getenv("ACCESS_TOKEN_EXPIRE_MINUTES", "1440")) # 24 hours
# Server
HOST = os.getenv("HOST", "0.0.0.0")
@@ -32,12 +48,24 @@ AUTHELIA_HEADER_GROUPS = os.getenv("AUTHELIA_HEADER_GROUPS", "Remote-Groups")
# Manager role and user assignments are managed by admin in the app UI
AUTHELIA_ADMIN_GROUP = os.getenv("AUTHELIA_ADMIN_GROUP", "parking_admins")
# Email (optional)
SMTP_HOST = os.getenv("SMTP_HOST", "")
# External URLs for Authelia mode
# When AUTHELIA_ENABLED, login redirects to Authelia and register to external portal
AUTHELIA_LOGIN_URL = os.getenv("AUTHELIA_LOGIN_URL", "") # e.g., https://auth.rocketscale.it
REGISTRATION_URL = os.getenv("REGISTRATION_URL", "") # e.g., https://register.rocketscale.it
# Email configuration (following org-stack pattern)
SMTP_ENABLED = os.getenv("SMTP_ENABLED", "false").lower() == "true"
SMTP_HOST = os.getenv("SMTP_HOST", "localhost")
SMTP_PORT = int(os.getenv("SMTP_PORT", "587"))
SMTP_USER = os.getenv("SMTP_USER", "")
SMTP_PASSWORD = os.getenv("SMTP_PASSWORD", "")
SMTP_FROM_EMAIL = os.getenv("SMTP_FROM_EMAIL", SMTP_USER)
SMTP_FROM = os.getenv("SMTP_FROM", SMTP_USER or "noreply@parking.local")
SMTP_USE_TLS = os.getenv("SMTP_USE_TLS", "true").lower() == "true"
EMAIL_LOG_FILE = os.getenv("EMAIL_LOG_FILE", "/tmp/parking-emails.log")
# Rate limiting
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

View File

@@ -2,20 +2,23 @@
Authentication Routes
Login, register, logout, and user info
"""
from fastapi import APIRouter, Depends, HTTPException, status, Response
from fastapi import APIRouter, Depends, HTTPException, status, Response, Request
from pydantic import BaseModel, EmailStr
from sqlalchemy.orm import Session
from slowapi import Limiter
from slowapi.util import get_remote_address
from database.connection import get_db
from services.auth import (
create_user, authenticate_user, create_access_token,
get_user_by_email, hash_password, verify_password
get_user_by_email
)
from utils.auth_middleware import get_current_user
from utils.helpers import validate_password, format_password_errors, get_notification_default
from app import config
import re
router = APIRouter(prefix="/api/auth", tags=["auth"])
limiter = Limiter(key_func=get_remote_address)
class RegisterRequest(BaseModel):
@@ -52,7 +55,8 @@ class UserResponse(BaseModel):
@router.post("/register", response_model=TokenResponse)
def register(data: RegisterRequest, db: Session = Depends(get_db)):
@limiter.limit(f"{config.RATE_LIMIT_REQUESTS}/minute")
def register(request: Request, data: RegisterRequest, db: Session = Depends(get_db)):
"""Register a new user"""
if get_user_by_email(db, data.email):
raise HTTPException(
@@ -60,10 +64,12 @@ def register(data: RegisterRequest, db: Session = Depends(get_db)):
detail="Email already registered"
)
if len(data.password) < 8:
# Validate password strength
password_errors = validate_password(data.password)
if password_errors:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Password must be at least 8 characters"
detail=format_password_errors(password_errors)
)
user = create_user(
@@ -74,16 +80,19 @@ def register(data: RegisterRequest, db: Session = Depends(get_db)):
manager_id=data.manager_id
)
config.logger.info(f"New user registered: {data.email}")
token = create_access_token(user.id, user.email)
return TokenResponse(access_token=token)
@router.post("/login", response_model=TokenResponse)
def login(data: LoginRequest, response: Response, db: Session = Depends(get_db)):
@limiter.limit(f"{config.RATE_LIMIT_REQUESTS}/minute")
def login(request: Request, data: LoginRequest, response: Response, db: Session = Depends(get_db)):
"""Login with email and password"""
user = authenticate_user(db, data.email, data.password)
if not user:
config.logger.warning(f"Failed login attempt for: {data.email}")
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid credentials"
@@ -99,6 +108,7 @@ def login(data: LoginRequest, response: Response, db: Session = Depends(get_db))
samesite="lax"
)
config.logger.info(f"User logged in: {data.email}")
return TokenResponse(access_token=token)
@@ -119,15 +129,27 @@ def get_me(user=Depends(get_current_user)):
manager_id=user.manager_id,
role=user.role,
manager_parking_quota=user.manager_parking_quota,
week_start_day=user.week_start_day or 0,
notify_weekly_parking=user.notify_weekly_parking if user.notify_weekly_parking is not None else 1,
notify_daily_parking=user.notify_daily_parking if user.notify_daily_parking is not None else 1,
notify_daily_parking_hour=user.notify_daily_parking_hour if user.notify_daily_parking_hour is not None else 8,
notify_daily_parking_minute=user.notify_daily_parking_minute if user.notify_daily_parking_minute is not None else 0,
notify_parking_changes=user.notify_parking_changes if user.notify_parking_changes is not None else 1
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_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)
)
@router.get("/config")
def get_auth_config():
"""Get authentication configuration for frontend.
Returns info about auth mode and external URLs.
"""
return {
"authelia_enabled": config.AUTHELIA_ENABLED,
"login_url": config.AUTHELIA_LOGIN_URL if config.AUTHELIA_ENABLED else None,
"registration_url": config.REGISTRATION_URL if config.AUTHELIA_ENABLED else None
}
@router.get("/holidays/{year}")
def get_holidays(year: int):
"""Get public holidays for a given year"""

View File

@@ -9,7 +9,7 @@ from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from sqlalchemy.orm import Session
import uuid
from sqlalchemy import func
from database.connection import get_db
from database.models import (
@@ -18,6 +18,8 @@ from database.models import (
ParkingGuarantee, ParkingExclusion
)
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/managers", tags=["managers"])
@@ -54,21 +56,28 @@ class ManagerSettingsUpdate(BaseModel):
def list_managers(db: Session = Depends(get_db), user=Depends(require_manager_or_admin)):
"""Get all managers with their managed user count and parking quota"""
managers = db.query(User).filter(User.role == "manager").all()
result = []
for manager in managers:
managed_user_count = db.query(User).filter(User.manager_id == manager.id).count()
# Batch query to get managed user counts for all managers at once
manager_ids = [m.id for m in managers]
if manager_ids:
counts = db.query(User.manager_id, func.count(User.id)).filter(
User.manager_id.in_(manager_ids)
).group_by(User.manager_id).all()
managed_counts = {manager_id: count for manager_id, count in counts}
else:
managed_counts = {}
result.append({
return [
{
"id": manager.id,
"name": manager.name,
"email": manager.email,
"parking_quota": manager.manager_parking_quota or 0,
"spot_prefix": manager.manager_spot_prefix,
"managed_user_count": managed_user_count
})
return result
"managed_user_count": managed_counts.get(manager.id, 0)
}
for manager in managers
]
@router.get("/{manager_id}")
@@ -164,7 +173,7 @@ def add_manager_closing_day(manager_id: str, data: ClosingDayCreate, db: Session
raise HTTPException(status_code=400, detail="Closing day already exists for this date")
closing_day = ManagerClosingDay(
id=str(uuid.uuid4()),
id=generate_uuid(),
manager_id=manager_id,
date=data.date,
reason=data.reason
@@ -217,7 +226,7 @@ def add_manager_weekly_closing_day(manager_id: str, data: WeeklyClosingDayCreate
raise HTTPException(status_code=400, detail="Weekly closing day already exists for this weekday")
weekly_closing = ManagerWeeklyClosingDay(
id=str(uuid.uuid4()),
id=generate_uuid(),
manager_id=manager_id,
weekday=data.weekday
)
@@ -246,17 +255,25 @@ def remove_manager_weekly_closing_day(manager_id: str, weekly_id: str, db: Sessi
def get_manager_guarantees(manager_id: str, db: Session = Depends(get_db), user=Depends(require_manager_or_admin)):
"""Get parking guarantees for a manager"""
guarantees = db.query(ParkingGuarantee).filter(ParkingGuarantee.manager_id == manager_id).all()
result = []
for g in guarantees:
target_user = db.query(User).filter(User.id == g.user_id).first()
result.append({
# 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": target_user.name if target_user else None,
"user_name": user_lookup.get(g.user_id),
"start_date": g.start_date,
"end_date": g.end_date
})
return result
}
for g in guarantees
]
@router.post("/{manager_id}/guarantees")
@@ -276,7 +293,7 @@ def add_manager_guarantee(manager_id: str, data: GuaranteeCreate, db: Session =
raise HTTPException(status_code=400, detail="User already has a parking guarantee")
guarantee = ParkingGuarantee(
id=str(uuid.uuid4()),
id=generate_uuid(),
manager_id=manager_id,
user_id=data.user_id,
start_date=data.start_date,
@@ -308,17 +325,25 @@ def remove_manager_guarantee(manager_id: str, guarantee_id: str, db: Session = D
def get_manager_exclusions(manager_id: str, db: Session = Depends(get_db), user=Depends(require_manager_or_admin)):
"""Get parking exclusions for a manager"""
exclusions = db.query(ParkingExclusion).filter(ParkingExclusion.manager_id == manager_id).all()
result = []
for e in exclusions:
target_user = db.query(User).filter(User.id == e.user_id).first()
result.append({
# 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": target_user.name if target_user else None,
"user_name": user_lookup.get(e.user_id),
"start_date": e.start_date,
"end_date": e.end_date
})
return result
}
for e in exclusions
]
@router.post("/{manager_id}/exclusions")
@@ -338,7 +363,7 @@ def add_manager_exclusion(manager_id: str, data: ExclusionCreate, db: Session =
raise HTTPException(status_code=400, detail="User already has a parking exclusion")
exclusion = ParkingExclusion(
id=str(uuid.uuid4()),
id=generate_uuid(),
manager_id=manager_id,
user_id=data.user_id,
start_date=data.start_date,

View File

@@ -12,13 +12,13 @@ from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from sqlalchemy.orm import Session
import uuid
from database.connection import get_db
from database.models import DailyParkingAssignment, User
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.notifications import queue_parking_change_notification
from services.notifications import notify_parking_assigned, notify_parking_released, notify_parking_reassigned
from app import config
router = APIRouter(prefix="/api/parking", tags=["parking"])
@@ -203,12 +203,10 @@ def release_my_spot(assignment_id: str, db: Session = Depends(get_db), current_u
assignment.user_id = None
db.commit()
# Queue notification (self-release, so just confirmation)
queue_parking_change_notification(
current_user, assignment.date, "released",
spot_display_name, db=db
)
# Send notification (self-release, so just confirmation)
notify_parking_released(current_user, assignment.date, spot_display_name)
config.logger.info(f"User {current_user.email} released parking spot {spot_display_name} on {assignment.date}")
return {"message": "Parking spot released"}
@@ -257,26 +255,21 @@ def reassign_spot(data: ReassignSpotRequest, db: Session = Depends(get_db), curr
assignment.user_id = data.new_user_id
# Queue notifications
# Send notifications
# Notify old user that spot was reassigned
if old_user and old_user.id != new_user.id:
queue_parking_change_notification(
old_user, assignment.date, "reassigned",
spot_display_name, new_user.name, db
)
notify_parking_reassigned(old_user, assignment.date, spot_display_name, new_user.name)
# Notify new user that spot was assigned
queue_parking_change_notification(
new_user, assignment.date, "assigned",
spot_display_name, db=db
)
notify_parking_assigned(new_user, assignment.date, spot_display_name)
config.logger.info(f"Parking spot {spot_display_name} on {assignment.date} reassigned from {old_user.email if old_user else 'unassigned'} to {new_user.email}")
else:
assignment.user_id = None
# Notify old user that spot was released
if old_user:
queue_parking_change_notification(
old_user, assignment.date, "released",
spot_display_name, db=db
)
notify_parking_released(old_user, assignment.date, spot_display_name)
config.logger.info(f"Parking spot {spot_display_name} on {assignment.date} released by {old_user.email if old_user else 'unknown'}")
db.commit()
db.refresh(assignment)

View File

@@ -7,12 +7,13 @@ from datetime import datetime, timedelta
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from sqlalchemy.orm import Session
import uuid
from database.connection import get_db
from database.models import UserPresence, User, DailyParkingAssignment
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
from app import config
router = APIRouter(prefix="/api/presence", tags=["presence"])
@@ -113,7 +114,7 @@ def _mark_presence_for_user(
presence = existing
else:
presence = UserPresence(
id=str(uuid.uuid4()),
id=generate_uuid(),
user_id=user_id,
date=date,
status=status,
@@ -139,7 +140,7 @@ def _mark_presence_for_user(
parking_manager_id, db
)
except Exception as e:
print(f"Warning: Parking handler failed: {e}")
config.logger.warning(f"Parking handler failed for user {user_id} on {date}: {e}")
return presence
@@ -186,7 +187,7 @@ def _bulk_mark_presence(
results.append(existing)
else:
presence = UserPresence(
id=str(uuid.uuid4()),
id=generate_uuid(),
user_id=user_id,
date=date_str,
status=status,
@@ -209,8 +210,8 @@ def _bulk_mark_presence(
old_status or "absent", status,
parking_manager_id, db
)
except Exception:
pass
except Exception as e:
config.logger.warning(f"Parking handler failed for user {user_id} on {date_str}: {e}")
current_date += timedelta(days=1)
@@ -253,8 +254,8 @@ def _delete_presence(
old_status, "absent",
parking_manager_id, db
)
except Exception:
pass
except Exception as e:
config.logger.warning(f"Parking handler failed for user {user_id} on {date}: {e}")
return {"message": "Presence deleted"}

View File

@@ -2,17 +2,18 @@
User Management Routes
Admin user CRUD and user self-service (profile, settings, password)
"""
from typing import List
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, EmailStr
from sqlalchemy.orm import Session
import uuid
import re
from database.connection import get_db
from database.models import User
from utils.auth_middleware import get_current_user, require_admin
from utils.helpers import (
generate_uuid, is_ldap_user, is_ldap_admin,
validate_password, format_password_errors, get_notification_default
)
from services.auth import hash_password, verify_password
from app import config
@@ -73,23 +74,33 @@ class UserResponse(BaseModel):
from_attributes = True
def user_to_response(user: User, db: Session) -> dict:
"""Convert user to response dict with computed fields"""
# Get manager name if user has a manager
def user_to_response(user: User, db: Session, manager_lookup: dict = None, managed_counts: 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:
manager = db.query(User).filter(User.id == user.manager_id).first()
if manager:
manager_name = manager.name
if manager_lookup is not None:
manager_name = manager_lookup.get(user.manager_id)
else:
manager = db.query(User).filter(User.id == user.manager_id).first()
if manager:
manager_name = manager.name
# Count managed users if this user is a manager
managed_user_count = None
if user.role == "manager":
managed_user_count = db.query(User).filter(User.manager_id == user.id).count()
# Determine if user is LDAP-managed
is_ldap_user = config.AUTHELIA_ENABLED and user.password_hash is None
is_ldap_admin = is_ldap_user and user.role == "admin"
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()
return {
"id": user.id,
@@ -101,8 +112,8 @@ def user_to_response(user: User, db: Session) -> dict:
"manager_parking_quota": user.manager_parking_quota,
"manager_spot_prefix": user.manager_spot_prefix,
"managed_user_count": managed_user_count,
"is_ldap_user": is_ldap_user,
"is_ldap_admin": is_ldap_admin,
"is_ldap_user": is_ldap_user(user),
"is_ldap_admin": is_ldap_admin(user),
"created_at": user.created_at
}
@@ -112,7 +123,25 @@ def user_to_response(user: User, db: Session) -> dict:
def list_users(db: Session = Depends(get_db), user=Depends(require_admin)):
"""List all users (admin only)"""
users = db.query(User).all()
return [user_to_response(u, db) for u in users]
# 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}
# 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]
@router.get("/{user_id}")
@@ -136,13 +165,18 @@ def create_user(data: UserCreate, db: Session = Depends(get_db), user=Depends(re
if data.role not in ["admin", "manager", "employee"]:
raise HTTPException(status_code=400, detail="Invalid role")
# 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")
new_user = User(
id=str(uuid.uuid4()),
id=generate_uuid(),
email=data.email,
password_hash=hash_password(data.password),
name=data.name,
@@ -154,6 +188,7 @@ def create_user(data: UserCreate, db: Session = Depends(get_db), user=Depends(re
db.add(new_user)
db.commit()
db.refresh(new_user)
config.logger.info(f"Admin created new user: {data.email}")
return user_to_response(new_user, db)
@@ -165,12 +200,12 @@ def update_user(user_id: str, data: UserUpdate, db: Session = Depends(get_db), u
raise HTTPException(status_code=404, detail="User not found")
# Check if user is LDAP-managed
is_ldap_user = config.AUTHELIA_ENABLED and target.password_hash is None
is_ldap_admin = is_ldap_user and target.role == "admin"
target_is_ldap = is_ldap_user(target)
target_is_ldap_admin = is_ldap_admin(target)
# Name update - blocked for LDAP users
if data.name is not None:
if is_ldap_user:
if target_is_ldap:
raise HTTPException(status_code=400, detail="Name is managed by LDAP")
target.name = data.name
@@ -179,7 +214,7 @@ def update_user(user_id: str, data: UserUpdate, db: Session = Depends(get_db), u
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 is_ldap_admin and data.role != "admin":
if target_is_ldap_admin and data.role != "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":
@@ -254,8 +289,6 @@ 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"""
is_ldap_user = config.AUTHELIA_ENABLED and current_user.password_hash is None
# Get manager name
manager_name = None
if current_user.manager_id:
@@ -270,17 +303,15 @@ def get_profile(db: Session = Depends(get_db), current_user=Depends(get_current_
"role": current_user.role,
"manager_id": current_user.manager_id,
"manager_name": manager_name,
"is_ldap_user": is_ldap_user
"is_ldap_user": is_ldap_user(current_user)
}
@router.put("/me/profile")
def update_profile(data: ProfileUpdate, db: Session = Depends(get_db), current_user=Depends(get_current_user)):
"""Update current user's profile (limited fields)"""
is_ldap_user = config.AUTHELIA_ENABLED and current_user.password_hash is None
if data.name is not None:
if is_ldap_user:
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()
@@ -293,12 +324,12 @@ def update_profile(data: ProfileUpdate, db: Session = Depends(get_db), current_u
def get_settings(current_user=Depends(get_current_user)):
"""Get current user's settings"""
return {
"week_start_day": current_user.week_start_day or 0,
"notify_weekly_parking": current_user.notify_weekly_parking if current_user.notify_weekly_parking is not None else 1,
"notify_daily_parking": current_user.notify_daily_parking if current_user.notify_daily_parking is not None else 1,
"notify_daily_parking_hour": current_user.notify_daily_parking_hour if current_user.notify_daily_parking_hour is not None else 8,
"notify_daily_parking_minute": current_user.notify_daily_parking_minute if current_user.notify_daily_parking_minute is not None else 0,
"notify_parking_changes": current_user.notify_parking_changes if current_user.notify_parking_changes is not None else 1
"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_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)
}
@@ -346,28 +377,19 @@ def update_settings(data: SettingsUpdate, db: Session = Depends(get_db), current
@router.post("/me/change-password")
def change_password(data: ChangePasswordRequest, db: Session = Depends(get_db), current_user=Depends(get_current_user)):
"""Change current user's password (not available in LDAP mode)"""
if config.AUTHELIA_ENABLED and current_user.password_hash is None:
if is_ldap_user(current_user):
raise HTTPException(status_code=400, detail="Password is managed by LDAP")
if not verify_password(data.current_password, current_user.password_hash):
raise HTTPException(status_code=400, detail="Current password is incorrect")
# Validate new password
password = data.new_password
errors = []
if len(password) < 8:
errors.append("at least 8 characters")
if not re.search(r'[A-Z]', password):
errors.append("one uppercase letter")
if not re.search(r'[a-z]', password):
errors.append("one lowercase letter")
if not re.search(r'[0-9]', password):
errors.append("one number")
password_errors = validate_password(data.new_password)
if password_errors:
raise HTTPException(status_code=400, detail=format_password_errors(password_errors))
if errors:
raise HTTPException(status_code=400, detail=f"Password must contain: {', '.join(errors)}")
current_user.password_hash = hash_password(password)
current_user.password_hash = hash_password(data.new_password)
current_user.updated_at = datetime.utcnow().isoformat()
db.commit()
config.logger.info(f"User {current_user.email} changed password")
return {"message": "Password changed"}

View File

@@ -7,19 +7,12 @@ services:
- "8000:8000"
volumes:
- ./data:/app/data
env_file:
- .env
environment:
- SECRET_KEY=${SECRET_KEY:-change-me-in-production}
- HOST=0.0.0.0
- PORT=8000
- DATABASE_PATH=/app/data/parking.db
- AUTHELIA_ENABLED=${AUTHELIA_ENABLED:-false}
- ALLOWED_ORIGINS=${ALLOWED_ORIGINS:-*}
# SMTP (optional)
- SMTP_HOST=${SMTP_HOST:-}
- SMTP_PORT=${SMTP_PORT:-587}
- SMTP_USER=${SMTP_USER:-}
- SMTP_PASSWORD=${SMTP_PASSWORD:-}
- SMTP_FROM=${SMTP_FROM:-}
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"]
interval: 30s

View File

@@ -15,16 +15,18 @@
<p>Manage team presence and parking assignments</p>
</div>
<div style="display: flex; flex-direction: column; gap: 1rem;">
<a href="/login" class="btn btn-dark btn-full">Sign In</a>
<a href="/register" class="btn btn-secondary btn-full">Create Account</a>
<div id="authButtons" style="display: flex; flex-direction: column; gap: 1rem;">
<!-- Buttons will be populated by JavaScript based on auth mode -->
<div class="loading">Loading...</div>
</div>
</div>
</div>
<script>
// Redirect if already logged in (JWT token or Authelia)
async function checkAndRedirect() {
async function init() {
const buttonsDiv = document.getElementById('authButtons');
// Check if already authenticated
// Check JWT token first
if (localStorage.getItem('access_token')) {
window.location.href = '/presence';
@@ -33,15 +35,56 @@
// Check Authelia (backend will read headers)
try {
const response = await fetch('/api/auth/me');
if (response.ok) {
const meResponse = await fetch('/api/auth/me');
if (meResponse.ok) {
window.location.href = '/presence';
return;
}
} catch (e) {
// Not authenticated, stay on landing
// Not authenticated, continue
}
// Get auth configuration
try {
const configResponse = await fetch('/api/auth/config');
const config = await configResponse.json();
if (config.authelia_enabled) {
// Authelia mode: Login goes to Authelia, Register goes to external portal
let buttons = '';
if (config.login_url) {
// Redirect to Authelia login with return URL
const returnUrl = encodeURIComponent(window.location.origin + '/presence');
buttons += `<a href="${config.login_url}?rd=${returnUrl}" class="btn btn-dark btn-full">Sign In</a>`;
} else {
// No login URL configured - just try to access the app (Authelia will intercept)
buttons += `<a href="/presence" class="btn btn-dark btn-full">Sign In</a>`;
}
if (config.registration_url) {
buttons += `<a href="${config.registration_url}" class="btn btn-secondary btn-full" target="_blank">Create Account</a>`;
buttons += `<p class="auth-footer" style="margin-top: 0.5rem; text-align: center; font-size: 0.875rem; color: #666;">Registration requires admin approval</p>`;
}
buttonsDiv.innerHTML = buttons;
} else {
// Standalone mode: Local login and registration
buttonsDiv.innerHTML = `
<a href="/login" class="btn btn-dark btn-full">Sign In</a>
<a href="/register" class="btn btn-secondary btn-full">Create Account</a>
`;
}
} catch (e) {
// Fallback to standalone mode
buttonsDiv.innerHTML = `
<a href="/login" class="btn btn-dark btn-full">Sign In</a>
<a href="/register" class="btn btn-secondary btn-full">Create Account</a>
`;
}
}
checkAndRedirect();
init();
</script>
</body>
</html>

View File

@@ -37,11 +37,30 @@
<script src="/js/api.js"></script>
<script>
// Redirect if already logged in
if (api.isAuthenticated()) {
window.location.href = '/presence';
async function init() {
// Redirect if already logged in
if (api.isAuthenticated()) {
window.location.href = '/presence';
return;
}
// Check auth mode - if Authelia enabled, redirect to Authelia login
try {
const configResponse = await fetch('/api/auth/config');
const config = await configResponse.json();
if (config.authelia_enabled && config.login_url) {
const returnUrl = encodeURIComponent(window.location.origin + '/presence');
window.location.href = `${config.login_url}?rd=${returnUrl}`;
return;
}
} catch (e) {
// Continue with local login
}
}
init();
document.getElementById('loginForm').addEventListener('submit', async (e) => {
e.preventDefault();

View File

@@ -42,11 +42,29 @@
<script src="/js/api.js"></script>
<script>
// Redirect if already logged in
if (api.isAuthenticated()) {
window.location.href = '/presence';
async function init() {
// Redirect if already logged in
if (api.isAuthenticated()) {
window.location.href = '/presence';
return;
}
// Check auth mode - if Authelia enabled, redirect to external registration portal
try {
const configResponse = await fetch('/api/auth/config');
const config = await configResponse.json();
if (config.authelia_enabled && config.registration_url) {
window.location.href = config.registration_url;
return;
}
} catch (e) {
// Continue with local registration
}
}
init();
document.getElementById('registerForm').addEventListener('submit', async (e) => {
e.preventDefault();

17
main.py
View File

@@ -2,11 +2,14 @@
Parking Manager Application
FastAPI + SQLite + Vanilla JS
"""
from fastapi import FastAPI
from fastapi import FastAPI, Request
from fastapi.staticfiles import StaticFiles
from fastapi.responses import FileResponse, RedirectResponse
from fastapi.responses import FileResponse, RedirectResponse, JSONResponse
from fastapi.middleware.cors import CORSMiddleware
from contextlib import asynccontextmanager
from slowapi import Limiter, _rate_limit_exceeded_handler
from slowapi.util import get_remote_address
from slowapi.errors import RateLimitExceeded
from app import config
from app.routes.auth import router as auth_router
@@ -16,16 +19,26 @@ from app.routes.presence import router as presence_router
from app.routes.parking import router as parking_router
from database.connection import init_db
# Rate limiter setup
limiter = Limiter(key_func=get_remote_address)
@asynccontextmanager
async def lifespan(app: FastAPI):
"""Initialize database on startup"""
config.logger.info("Starting Parking Manager application")
init_db()
config.logger.info("Database initialized")
yield
config.logger.info("Shutting down Parking Manager application")
app = FastAPI(title="Parking Manager", version="1.0.0", lifespan=lifespan)
# Add rate limiter
app.state.limiter = limiter
app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
# CORS
app.add_middleware(
CORSMiddleware,

View File

@@ -4,3 +4,4 @@ pydantic[email]==2.10.3
sqlalchemy==2.0.36
python-jose[cryptography]==3.3.0
bcrypt==4.2.1
slowapi==0.1.9

View File

@@ -1,105 +1,170 @@
"""
Notification Service
Handles email notifications for presence reminders and parking assignments
TODO: This service is NOT YET ACTIVE. To enable notifications:
1. Add APScheduler or similar to run run_scheduled_notifications() periodically
2. Configure SMTP environment variables (SMTP_HOST, SMTP_USER, SMTP_PASSWORD, SMTP_FROM)
3. Notifications will be sent for:
- Presence reminders (Thursday at 12:00)
- Weekly parking summary (Friday at 12:00)
- Daily parking reminders (at user's preferred time)
- Immediate parking change notifications (via queue)
Handles email notifications for presence reminders and parking assignments.
Follows org-stack pattern: direct SMTP send with file fallback when disabled.
"""
import smtplib
import os
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
from datetime import datetime, timedelta
from sqlalchemy.orm import Session
import uuid
from typing import TYPE_CHECKING
from database.models import (
User, UserPresence, DailyParkingAssignment,
NotificationLog, NotificationQueue
)
from services.parking import get_spot_display_name
from app import config
from utils.helpers import generate_uuid
if TYPE_CHECKING:
from sqlalchemy.orm import Session
from database.models import User
# Email configuration (from environment variables)
SMTP_HOST = os.getenv("SMTP_HOST", "localhost")
SMTP_PORT = int(os.getenv("SMTP_PORT", "587"))
SMTP_USER = os.getenv("SMTP_USER", "")
SMTP_PASSWORD = os.getenv("SMTP_PASSWORD", "")
SMTP_FROM = os.getenv("SMTP_FROM", "noreply@parkingmanager.local")
SMTP_USE_TLS = os.getenv("SMTP_USE_TLS", "true").lower() == "true"
def log_email_to_file(to: str, subject: str, body: str):
"""Log email to file when SMTP is disabled (org-stack pattern)"""
timestamp = datetime.now().isoformat()
try:
with open(config.EMAIL_LOG_FILE, 'a') as f:
f.write(f'\n{"="*80}\n')
f.write(f'Timestamp: {timestamp}\n')
f.write(f'To: {to}\n')
f.write(f'Subject: {subject}\n')
f.write(f'Body:\n{body}\n')
f.write(f'{"="*80}\n')
config.logger.info(f"[EMAIL] Logged to {config.EMAIL_LOG_FILE}: {to} - {subject}")
except Exception as e:
config.logger.error(f"[EMAIL] Failed to log email: {e}")
def send_email(to_email: str, subject: str, body_html: str, body_text: str = None):
"""Send an email"""
if not SMTP_USER or not SMTP_PASSWORD:
print(f"[NOTIFICATION] Email not configured. Would send to {to_email}: {subject}")
return False
def send_email(to_email: str, subject: str, body_html: str, body_text: str = None) -> bool:
"""
Send an email via SMTP or log to file if SMTP disabled.
Returns True if sent/logged successfully, False otherwise.
"""
# Extract plain text from HTML if not provided
if not body_text:
import re
body_text = re.sub('<[^<]+?>', '', body_html)
if not config.SMTP_ENABLED:
log_email_to_file(to_email, subject, body_text)
return True
try:
msg = MIMEMultipart("alternative")
msg["Subject"] = subject
msg["From"] = SMTP_FROM
msg["From"] = config.SMTP_FROM
msg["To"] = to_email
if body_text:
msg.attach(MIMEText(body_text, "plain"))
msg.attach(MIMEText(body_text, "plain"))
msg.attach(MIMEText(body_html, "html"))
with smtplib.SMTP(SMTP_HOST, SMTP_PORT) as server:
if SMTP_USE_TLS:
with smtplib.SMTP(config.SMTP_HOST, config.SMTP_PORT) as server:
if config.SMTP_USE_TLS:
server.starttls()
server.login(SMTP_USER, SMTP_PASSWORD)
server.sendmail(SMTP_FROM, to_email, msg.as_string())
if config.SMTP_USER and config.SMTP_PASSWORD:
server.login(config.SMTP_USER, config.SMTP_PASSWORD)
server.send_message(msg)
print(f"[NOTIFICATION] Email sent to {to_email}: {subject}")
config.logger.info(f"[EMAIL] Sent to {to_email}: {subject}")
return True
except Exception as e:
print(f"[NOTIFICATION] Failed to send email to {to_email}: {e}")
config.logger.error(f"[EMAIL] Failed to send to {to_email}: {e}")
# Fallback to file logging
log_email_to_file(to_email, subject, body_text)
return False
def get_week_dates(reference_date: datetime):
def get_week_dates(reference_date: datetime) -> list[datetime]:
"""Get Monday-Sunday dates for the week containing reference_date"""
# Find Monday of this week
monday = reference_date - timedelta(days=reference_date.weekday())
return [monday + timedelta(days=i) for i in range(7)]
def get_next_week_dates(reference_date: datetime):
def get_next_week_dates(reference_date: datetime) -> list[datetime]:
"""Get Monday-Sunday dates for the week after reference_date"""
# Find Monday of next week
days_until_next_monday = 7 - reference_date.weekday()
next_monday = reference_date + timedelta(days=days_until_next_monday)
return [next_monday + timedelta(days=i) for i in range(7)]
def check_week_presence_compiled(user_id: str, week_dates: list, db: Session) -> bool:
"""Check if user has filled presence for all working days in a week"""
date_strs = [d.strftime("%Y-%m-%d") for d in week_dates]
presences = db.query(UserPresence).filter(
UserPresence.user_id == user_id,
UserPresence.date.in_(date_strs)
).all()
# Consider week compiled if at least 5 days have presence marked
# (allowing for weekends or holidays)
return len(presences) >= 5
def get_week_reference(date: datetime) -> str:
"""Get ISO week reference string (e.g., 2024-W48)"""
return date.strftime("%Y-W%W")
def send_presence_reminder(user: User, next_week_dates: list, db: Session) -> bool:
# =============================================================================
# Notification sending functions
# =============================================================================
def notify_parking_assigned(user: "User", date: str, spot_name: str):
"""Send notification when parking spot is assigned"""
if not user.notify_parking_changes:
return
date_obj = datetime.strptime(date, "%Y-%m-%d")
day_name = date_obj.strftime("%A, %B %d")
subject = f"Parking spot assigned for {day_name}"
body_html = f"""
<html>
<body>
<h2>Parking Spot Assigned</h2>
<p>Hi {user.name},</p>
<p>You have been assigned a parking spot for {day_name}:</p>
<p style="font-size: 18px; font-weight: bold;">Spot {spot_name}</p>
<p>Best regards,<br>Parking Manager</p>
</body>
</html>
"""
send_email(user.email, subject, body_html)
def notify_parking_released(user: "User", date: str, spot_name: str):
"""Send notification when parking spot is released"""
if not user.notify_parking_changes:
return
date_obj = datetime.strptime(date, "%Y-%m-%d")
day_name = date_obj.strftime("%A, %B %d")
subject = f"Parking spot released for {day_name}"
body_html = f"""
<html>
<body>
<h2>Parking Spot Released</h2>
<p>Hi {user.name},</p>
<p>Your parking spot (Spot {spot_name}) for {day_name} has been released.</p>
<p>Best regards,<br>Parking Manager</p>
</body>
</html>
"""
send_email(user.email, subject, body_html)
def notify_parking_reassigned(user: "User", date: str, spot_name: str, new_user_name: str):
"""Send notification when parking spot is reassigned to someone else"""
if not user.notify_parking_changes:
return
date_obj = datetime.strptime(date, "%Y-%m-%d")
day_name = date_obj.strftime("%A, %B %d")
subject = f"Parking spot reassigned for {day_name}"
body_html = f"""
<html>
<body>
<h2>Parking Spot Reassigned</h2>
<p>Hi {user.name},</p>
<p>Your parking spot (Spot {spot_name}) for {day_name} has been reassigned to {new_user_name}.</p>
<p>Best regards,<br>Parking Manager</p>
</body>
</html>
"""
send_email(user.email, subject, body_html)
def send_presence_reminder(user: "User", next_week_dates: list[datetime], db: "Session") -> bool:
"""Send presence compilation reminder for next week"""
from database.models import UserPresence, NotificationLog
week_ref = get_week_reference(next_week_dates[0])
# Check if already sent today for this week
@@ -112,11 +177,17 @@ def send_presence_reminder(user: User, next_week_dates: list, db: Session) -> bo
).first()
if existing:
return False # Already sent today
return False
# Check if week is compiled
if check_week_presence_compiled(user.id, next_week_dates, db):
return False # Already compiled
# Check if week is compiled (at least 5 days marked)
date_strs = [d.strftime("%Y-%m-%d") for d in next_week_dates]
presences = db.query(UserPresence).filter(
UserPresence.user_id == user.id,
UserPresence.date.in_(date_strs)
).all()
if len(presences) >= 5:
return False
# Send reminder
start_date = next_week_dates[0].strftime("%B %d")
@@ -137,9 +208,8 @@ def send_presence_reminder(user: User, next_week_dates: list, db: Session) -> bo
"""
if send_email(user.email, subject, body_html):
# Log the notification
log = NotificationLog(
id=str(uuid.uuid4()),
id=generate_uuid(),
user_id=user.id,
notification_type="presence_reminder",
reference_date=week_ref,
@@ -152,8 +222,11 @@ def send_presence_reminder(user: User, next_week_dates: list, db: Session) -> bo
return False
def send_weekly_parking_summary(user: User, next_week_dates: list, db: Session) -> bool:
def send_weekly_parking_summary(user: "User", next_week_dates: list[datetime], db: "Session") -> bool:
"""Send weekly parking assignment summary for next week (Friday at 12)"""
from database.models import DailyParkingAssignment, NotificationLog
from services.parking import get_spot_display_name
if not user.notify_weekly_parking:
return False
@@ -177,7 +250,7 @@ def send_weekly_parking_summary(user: User, next_week_dates: list, db: Session)
).all()
if not assignments:
return False # No assignments, no need to notify
return False
# Build assignment list
assignment_lines = []
@@ -208,7 +281,7 @@ def send_weekly_parking_summary(user: User, next_week_dates: list, db: Session)
if send_email(user.email, subject, body_html):
log = NotificationLog(
id=str(uuid.uuid4()),
id=generate_uuid(),
user_id=user.id,
notification_type="weekly_parking",
reference_date=week_ref,
@@ -221,8 +294,11 @@ def send_weekly_parking_summary(user: User, next_week_dates: list, db: Session)
return False
def send_daily_parking_reminder(user: User, date: datetime, db: Session) -> bool:
def send_daily_parking_reminder(user: "User", date: datetime, db: "Session") -> bool:
"""Send daily parking reminder for a specific date"""
from database.models import DailyParkingAssignment, NotificationLog
from services.parking import get_spot_display_name
if not user.notify_daily_parking:
return False
@@ -245,10 +321,9 @@ def send_daily_parking_reminder(user: User, date: datetime, db: Session) -> bool
).first()
if not assignment:
return False # No assignment today
return False
spot_name = get_spot_display_name(assignment.spot_id, assignment.manager_id, db)
day_name = date.strftime("%A, %B %d")
subject = f"Parking reminder for {day_name}"
@@ -266,7 +341,7 @@ def send_daily_parking_reminder(user: User, date: datetime, db: Session) -> bool
if send_email(user.email, subject, body_html):
log = NotificationLog(
id=str(uuid.uuid4()),
id=generate_uuid(),
user_id=user.id,
notification_type="daily_parking",
reference_date=date_str,
@@ -279,92 +354,18 @@ def send_daily_parking_reminder(user: User, date: datetime, db: Session) -> bool
return False
def queue_parking_change_notification(
user: User,
date: str,
change_type: str, # "assigned", "released", "reassigned"
spot_name: str,
new_user_name: str = None,
db: Session = None
):
"""Queue an immediate notification for a parking assignment change"""
if not user.notify_parking_changes:
return
def run_scheduled_notifications(db: "Session"):
"""
Run all scheduled notifications - called by a scheduler/cron job.
date_obj = datetime.strptime(date, "%Y-%m-%d")
day_name = date_obj.strftime("%A, %B %d")
Schedule:
- Thursday at 12:00: Presence reminder for next week
- Friday at 12:00: Weekly parking summary
- Daily at user's preferred time: Daily parking reminder (Mon-Fri)
"""
from database.models import User
if change_type == "assigned":
subject = f"Parking spot assigned for {day_name}"
body = f"""
<html>
<body>
<h2>Parking Spot Assigned</h2>
<p>Hi {user.name},</p>
<p>You have been assigned a parking spot for {day_name}:</p>
<p style="font-size: 18px; font-weight: bold;">Spot {spot_name}</p>
<p>Best regards,<br>Parking Manager</p>
</body>
</html>
"""
elif change_type == "released":
subject = f"Parking spot released for {day_name}"
body = f"""
<html>
<body>
<h2>Parking Spot Released</h2>
<p>Hi {user.name},</p>
<p>Your parking spot (Spot {spot_name}) for {day_name} has been released.</p>
<p>Best regards,<br>Parking Manager</p>
</body>
</html>
"""
elif change_type == "reassigned":
subject = f"Parking spot reassigned for {day_name}"
body = f"""
<html>
<body>
<h2>Parking Spot Reassigned</h2>
<p>Hi {user.name},</p>
<p>Your parking spot (Spot {spot_name}) for {day_name} has been reassigned to {new_user_name}.</p>
<p>Best regards,<br>Parking Manager</p>
</body>
</html>
"""
else:
return
# Add to queue
notification = NotificationQueue(
id=str(uuid.uuid4()),
user_id=user.id,
notification_type="parking_change",
subject=subject,
body=body,
created_at=datetime.now().isoformat()
)
db.add(notification)
db.commit()
def process_notification_queue(db: Session):
"""Process and send all pending notifications in the queue"""
pending = db.query(NotificationQueue).filter(
NotificationQueue.sent_at.is_(None)
).all()
for notification in pending:
user = db.query(User).filter(User.id == notification.user_id).first()
if user and send_email(user.email, notification.subject, notification.body):
notification.sent_at = datetime.now().isoformat()
db.commit()
def run_scheduled_notifications(db: Session):
"""Run all scheduled notifications - called by a scheduler/cron job"""
now = datetime.now()
today = now.date()
current_hour = now.hour
current_minute = now.minute
current_weekday = now.weekday() # 0=Monday, 6=Sunday
@@ -372,7 +373,7 @@ def run_scheduled_notifications(db: Session):
users = db.query(User).all()
for user in users:
# Thursday at 12: Presence reminder (unmanageable)
# Thursday at 12: Presence reminder
if current_weekday == 3 and current_hour == 12 and current_minute < 5:
next_week = get_next_week_dates(now)
send_presence_reminder(user, next_week, db)
@@ -388,6 +389,3 @@ def run_scheduled_notifications(db: Session):
user_minute = user.notify_daily_parking_minute or 0
if current_hour == user_hour and abs(current_minute - user_minute) < 5:
send_daily_parking_reminder(user, now, db)
# Process queued notifications
process_notification_queue(db)

View File

@@ -8,15 +8,16 @@ Key concepts:
- Spots are named like A1, A2, B1, B2 based on manager prefix
- Fairness: users with lowest parking_days/presence_days ratio get priority
"""
import uuid
from datetime import datetime, timezone
from sqlalchemy.orm import Session
from sqlalchemy import func, or_
from sqlalchemy import or_
from database.models import (
DailyParkingAssignment, User, UserPresence,
ParkingGuarantee, ParkingExclusion, ManagerClosingDay, ManagerWeeklyClosingDay
)
from utils.helpers import generate_uuid
from app import config
def get_spot_prefix(manager: User, db: Session) -> str:
@@ -109,7 +110,7 @@ def initialize_parking_pool(manager_id: str, quota: int, date: str, db: Session)
for i in range(1, quota + 1):
spot = DailyParkingAssignment(
id=str(uuid.uuid4()),
id=generate_uuid(),
date=date,
spot_id=f"spot-{i}",
user_id=None,
@@ -119,6 +120,7 @@ def initialize_parking_pool(manager_id: str, quota: int, date: str, db: Session)
db.add(spot)
db.commit()
config.logger.debug(f"Initialized {quota} parking spots for manager {manager_id} on {date}")
return quota

56
utils/helpers.py Normal file
View File

@@ -0,0 +1,56 @@
"""
Shared Utility Functions
Common helpers used across the application
"""
import uuid
import re
from typing import TYPE_CHECKING
from app import config
if TYPE_CHECKING:
from database.models import User
def generate_uuid() -> str:
"""Generate a new UUID string"""
return str(uuid.uuid4())
def is_ldap_user(user: "User") -> bool:
"""Check if user is managed by LDAP (Authelia mode with no local password)"""
return config.AUTHELIA_ENABLED and user.password_hash is None
def is_ldap_admin(user: "User") -> bool:
"""Check if user is an LDAP-managed admin"""
return is_ldap_user(user) and user.role == "admin"
def validate_password(password: str) -> list[str]:
"""
Validate password strength.
Returns list of error messages (empty if valid).
"""
errors = []
if len(password) < 8:
errors.append("at least 8 characters")
if not re.search(r'[A-Z]', password):
errors.append("one uppercase letter")
if not re.search(r'[a-z]', password):
errors.append("one lowercase letter")
if not re.search(r'[0-9]', password):
errors.append("one number")
return errors
def format_password_errors(errors: list[str]) -> str:
"""Format password validation errors into a message"""
if not errors:
return ""
return f"Password must contain: {', '.join(errors)}"
def get_notification_default(value, default):
"""Get notification setting value with default fallback"""
return value if value is not None else default