Refactor to manager-centric model, add team calendar for all users

Key changes:
- Removed office-centric model (deleted offices.py, office-rules)
- Renamed to team-rules, managers are part of their own team
- Team calendar visible to all (read-only for employees)
- Admins can have a manager assigned
This commit is contained in:
Stefano Manfredi
2025-12-02 13:30:04 +00:00
parent 2ad8ba3424
commit 7168fa4b72
30 changed files with 1016 additions and 910 deletions

263
CLAUDE.md Normal file
View File

@@ -0,0 +1,263 @@
# 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
### 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
```
## Build & Run Commands
```bash
# Development
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)
- UUIDs as string primary keys: `str(uuid.uuid4())`
- 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`
- Check pattern: `config.AUTHELIA_ENABLED and user.password_hash is None`
---
## 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:
```python
def create_crud_router(model: Type, schema: Type, parent_key: str):
router = APIRouter()
# Generate GET, POST, DELETE endpoints
return router
```
### 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.
### 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)
```
---
## Security Improvements Needed
| 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 |
---
## Testing Strategy (Missing)
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
---
## API Quick Reference
### Authentication
- `POST /api/auth/register` - Create user (standalone mode)
- `POST /api/auth/login` - Get JWT token
- `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)`
### Database Migrations
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';
document.addEventListener('DOMContentLoaded', async () => {
await api.checkAuth(true); // Redirect to login if not auth'd
initNav();
// Page-specific logic
});
</script>
```
---
## 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) |
| Auth middleware | [utils/auth_middleware.py](utils/auth_middleware.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) |

View File

@@ -18,7 +18,6 @@ A manager-centric parking spot management application with fair assignment algor
│ ├── routes/ # API endpoints │ ├── routes/ # API endpoints
│ │ ├── auth.py # Authentication + holidays │ │ ├── auth.py # Authentication + holidays
│ │ ├── users.py # User management │ │ ├── users.py # User management
│ │ ├── offices.py # Office CRUD
│ │ ├── managers.py # Manager rules (closing days, guarantees) │ │ ├── managers.py # Manager rules (closing days, guarantees)
│ │ ├── presence.py # Presence marking │ │ ├── presence.py # Presence marking
│ │ └── parking.py # Parking assignments │ │ └── parking.py # Parking assignments
@@ -116,8 +115,8 @@ Group mapping (follows lldap naming convention):
| Role | Permissions | | Role | Permissions |
|------|-------------| |------|-------------|
| **admin** | Full access, manage users/offices | | **admin** | Full access, manage users and managers |
| **manager** | Manage assigned offices, set rules | | **manager** | Manage their team, set parking rules |
| **employee** | Mark own presence, view calendar | | **employee** | Mark own presence, view calendar |
## API Endpoints ## API Endpoints
@@ -137,12 +136,6 @@ Group mapping (follows lldap naming convention):
- `GET /api/users/me/profile` - Own profile - `GET /api/users/me/profile` - Own profile
- `PUT /api/users/me/settings` - Own settings - `PUT /api/users/me/settings` - Own settings
### Offices
- `GET /api/offices` - List offices
- `POST /api/offices` - Create office (admin)
- `PUT /api/offices/{id}` - Update office (admin)
- `DELETE /api/offices/{id}` - Delete office (admin)
### Managers ### Managers
- `GET /api/managers` - List managers - `GET /api/managers` - List managers
- `GET /api/managers/{id}` - Manager details - `GET /api/managers/{id}` - Manager details
@@ -169,7 +162,7 @@ Group mapping (follows lldap naming convention):
Parking spots are assigned based on a fairness ratio: Parking spots are assigned based on a fairness ratio:
``` ```
ratio = parking_days / office_days ratio = parking_days / presence_days
``` ```
Users with the lowest ratio get priority. Guaranteed users are always assigned first. Users with the lowest ratio get priority. Guaranteed users are always assigned first.

View File

@@ -28,9 +28,9 @@ AUTHELIA_HEADER_USER = os.getenv("AUTHELIA_HEADER_USER", "Remote-User")
AUTHELIA_HEADER_NAME = os.getenv("AUTHELIA_HEADER_NAME", "Remote-Name") AUTHELIA_HEADER_NAME = os.getenv("AUTHELIA_HEADER_NAME", "Remote-Name")
AUTHELIA_HEADER_EMAIL = os.getenv("AUTHELIA_HEADER_EMAIL", "Remote-Email") AUTHELIA_HEADER_EMAIL = os.getenv("AUTHELIA_HEADER_EMAIL", "Remote-Email")
AUTHELIA_HEADER_GROUPS = os.getenv("AUTHELIA_HEADER_GROUPS", "Remote-Groups") AUTHELIA_HEADER_GROUPS = os.getenv("AUTHELIA_HEADER_GROUPS", "Remote-Groups")
# Group to role mapping (follows lldap naming convention) # Only parking_admins group is synced from LLDAP -> admin role
# Manager role and user assignments are managed by admin in the app UI
AUTHELIA_ADMIN_GROUP = os.getenv("AUTHELIA_ADMIN_GROUP", "parking_admins") AUTHELIA_ADMIN_GROUP = os.getenv("AUTHELIA_ADMIN_GROUP", "parking_admins")
AUTHELIA_MANAGER_GROUP = os.getenv("AUTHELIA_MANAGER_GROUP", "managers")
# Email (optional) # Email (optional)
SMTP_HOST = os.getenv("SMTP_HOST", "") SMTP_HOST = os.getenv("SMTP_HOST", "")

View File

@@ -22,7 +22,7 @@ class RegisterRequest(BaseModel):
email: EmailStr email: EmailStr
password: str password: str
name: str name: str
office_id: str | None = None manager_id: str | None = None
class LoginRequest(BaseModel): class LoginRequest(BaseModel):
@@ -39,7 +39,7 @@ class UserResponse(BaseModel):
id: str id: str
email: str email: str
name: str | None name: str | None
office_id: str | None manager_id: str | None
role: str role: str
manager_parking_quota: int | None = None manager_parking_quota: int | None = None
week_start_day: int = 0 week_start_day: int = 0
@@ -71,7 +71,7 @@ def register(data: RegisterRequest, db: Session = Depends(get_db)):
email=data.email, email=data.email,
password=data.password, password=data.password,
name=data.name, name=data.name,
office_id=data.office_id manager_id=data.manager_id
) )
token = create_access_token(user.id, user.email) token = create_access_token(user.id, user.email)
@@ -116,7 +116,7 @@ def get_me(user=Depends(get_current_user)):
id=user.id, id=user.id,
email=user.email, email=user.email,
name=user.name, name=user.name,
office_id=user.office_id, manager_id=user.manager_id,
role=user.role, role=user.role,
manager_parking_quota=user.manager_parking_quota, manager_parking_quota=user.manager_parking_quota,
week_start_day=user.week_start_day or 0, week_start_day=user.week_start_day or 0,

View File

@@ -2,8 +2,8 @@
Manager Rules Routes Manager Rules Routes
Manager settings, closing days, guarantees, and exclusions Manager settings, closing days, guarantees, and exclusions
Key concept: Managers own parking spots and set rules for all their managed offices. Key concept: Managers own parking spots and set rules for their managed users.
Rules are set at manager level, not office level. Rules are set at manager level (users have manager_id pointing to their manager).
""" """
from datetime import datetime from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
@@ -13,7 +13,7 @@ import uuid
from database.connection import get_db from database.connection import get_db
from database.models import ( from database.models import (
Office, User, OfficeMembership, User,
ManagerClosingDay, ManagerWeeklyClosingDay, ManagerClosingDay, ManagerWeeklyClosingDay,
ParkingGuarantee, ParkingExclusion ParkingGuarantee, ParkingExclusion
) )
@@ -52,14 +52,12 @@ class ManagerSettingsUpdate(BaseModel):
# Manager listing and details # Manager listing and details
@router.get("") @router.get("")
def list_managers(db: Session = Depends(get_db), user=Depends(require_manager_or_admin)): def list_managers(db: Session = Depends(get_db), user=Depends(require_manager_or_admin)):
"""Get all managers with their managed offices and parking quota""" """Get all managers with their managed user count and parking quota"""
managers = db.query(User).filter(User.role == "manager").all() managers = db.query(User).filter(User.role == "manager").all()
result = [] result = []
for manager in managers: for manager in managers:
memberships = db.query(OfficeMembership).filter(OfficeMembership.user_id == manager.id).all() managed_user_count = db.query(User).filter(User.manager_id == manager.id).count()
office_ids = [m.office_id for m in memberships]
offices = db.query(Office).filter(Office.id.in_(office_ids)).all() if office_ids else []
result.append({ result.append({
"id": manager.id, "id": manager.id,
@@ -67,7 +65,7 @@ def list_managers(db: Session = Depends(get_db), user=Depends(require_manager_or
"email": manager.email, "email": manager.email,
"parking_quota": manager.manager_parking_quota or 0, "parking_quota": manager.manager_parking_quota or 0,
"spot_prefix": manager.manager_spot_prefix, "spot_prefix": manager.manager_spot_prefix,
"offices": [{"id": o.id, "name": o.name} for o in offices] "managed_user_count": managed_user_count
}) })
return result return result
@@ -75,14 +73,12 @@ def list_managers(db: Session = Depends(get_db), user=Depends(require_manager_or
@router.get("/{manager_id}") @router.get("/{manager_id}")
def get_manager_details(manager_id: str, db: Session = Depends(get_db), user=Depends(require_manager_or_admin)): def get_manager_details(manager_id: str, db: Session = Depends(get_db), user=Depends(require_manager_or_admin)):
"""Get manager details including offices and parking settings""" """Get manager details including managed users and parking settings"""
manager = db.query(User).filter(User.id == manager_id, User.role == "manager").first() manager = db.query(User).filter(User.id == manager_id, User.role == "manager").first()
if not manager: if not manager:
raise HTTPException(status_code=404, detail="Manager not found") raise HTTPException(status_code=404, detail="Manager not found")
memberships = db.query(OfficeMembership).filter(OfficeMembership.user_id == manager_id).all() managed_user_count = db.query(User).filter(User.manager_id == manager_id).count()
office_ids = [m.office_id for m in memberships]
offices = db.query(Office).filter(Office.id.in_(office_ids)).all() if office_ids else []
return { return {
"id": manager.id, "id": manager.id,
@@ -90,7 +86,7 @@ def get_manager_details(manager_id: str, db: Session = Depends(get_db), user=Dep
"email": manager.email, "email": manager.email,
"parking_quota": manager.manager_parking_quota or 0, "parking_quota": manager.manager_parking_quota or 0,
"spot_prefix": manager.manager_spot_prefix, "spot_prefix": manager.manager_spot_prefix,
"offices": [{"id": o.id, "name": o.name} for o in offices] "managed_user_count": managed_user_count
} }
@@ -131,19 +127,16 @@ def update_manager_settings(manager_id: str, data: ManagerSettingsUpdate, db: Se
@router.get("/{manager_id}/users") @router.get("/{manager_id}/users")
def get_manager_users(manager_id: str, db: Session = Depends(get_db), user=Depends(require_manager_or_admin)): def get_manager_users(manager_id: str, db: Session = Depends(get_db), user=Depends(require_manager_or_admin)):
"""Get all users from offices managed by this manager""" """Get all users in a manager's team (including the manager themselves)"""
manager = db.query(User).filter(User.id == manager_id, User.role == "manager").first() manager = db.query(User).filter(User.id == manager_id, User.role == "manager").first()
if not manager: if not manager:
raise HTTPException(status_code=404, detail="Manager not found") raise HTTPException(status_code=404, detail="Manager not found")
memberships = db.query(OfficeMembership).filter(OfficeMembership.user_id == manager_id).all() # Include users managed by this manager + the manager themselves
managed_office_ids = [m.office_id for m in memberships] users = db.query(User).filter(
(User.manager_id == manager_id) | (User.id == manager_id)
if not managed_office_ids: ).all()
return [] return [{"id": u.id, "name": u.name, "email": u.email, "role": u.role} for u in users]
users = db.query(User).filter(User.office_id.in_(managed_office_ids)).all()
return [{"id": u.id, "name": u.name, "email": u.email, "role": u.role, "office_id": u.office_id} for u in users]
# Closing days # Closing days

View File

@@ -1,197 +0,0 @@
"""
Office Management Routes
Admin CRUD for offices and manager-office memberships
"""
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 Office, User, OfficeMembership
from utils.auth_middleware import require_admin, require_manager_or_admin, get_current_user
router = APIRouter(prefix="/api/offices", tags=["offices"])
# Request/Response Models
class OfficeCreate(BaseModel):
name: str
location: str | None = None
class OfficeUpdate(BaseModel):
name: str | None = None
location: str | None = None
class OfficeResponse(BaseModel):
id: str
name: str
location: str | None = None
created_at: str | None
class Config:
from_attributes = True
class AddManagerRequest(BaseModel):
user_id: str
# Office CRUD Routes
@router.get("")
def list_offices(db: Session = Depends(get_db), user=Depends(get_current_user)):
"""List all offices with counts"""
offices = db.query(Office).all()
result = []
for office in offices:
manager_count = db.query(OfficeMembership).filter(OfficeMembership.office_id == office.id).count()
employee_count = db.query(User).filter(User.office_id == office.id).count()
result.append({
"id": office.id,
"name": office.name,
"location": office.location,
"created_at": office.created_at,
"manager_count": manager_count,
"employee_count": employee_count
})
return result
@router.get("/{office_id}", response_model=OfficeResponse)
def get_office(office_id: str, db: Session = Depends(get_db), user=Depends(get_current_user)):
"""Get office by ID"""
office = db.query(Office).filter(Office.id == office_id).first()
if not office:
raise HTTPException(status_code=404, detail="Office not found")
return office
@router.post("", response_model=OfficeResponse)
def create_office(data: OfficeCreate, db: Session = Depends(get_db), user=Depends(require_admin)):
"""Create new office (admin only)"""
office = Office(
id=str(uuid.uuid4()),
name=data.name,
location=data.location,
created_at=datetime.utcnow().isoformat()
)
db.add(office)
db.commit()
db.refresh(office)
return office
@router.put("/{office_id}", response_model=OfficeResponse)
def update_office(office_id: str, data: OfficeUpdate, db: Session = Depends(get_db), user=Depends(require_admin)):
"""Update 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")
if data.name is not None:
office.name = data.name
if data.location is not None:
office.location = data.location
office.updated_at = datetime.utcnow().isoformat()
db.commit()
db.refresh(office)
return office
@router.delete("/{office_id}")
def delete_office(office_id: str, db: Session = Depends(get_db), user=Depends(require_admin)):
"""Delete 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")
if db.query(User).filter(User.office_id == office_id).count() > 0:
raise HTTPException(status_code=400, detail="Cannot delete office with assigned users")
db.delete(office)
db.commit()
return {"message": "Office deleted"}
# Office membership routes (linking managers to offices)
@router.get("/{office_id}/managers")
def get_office_managers(office_id: str, db: Session = Depends(get_db), user=Depends(require_manager_or_admin)):
"""Get managers for an office"""
memberships = db.query(OfficeMembership).filter(OfficeMembership.office_id == office_id).all()
manager_ids = [m.user_id for m in memberships]
managers = db.query(User).filter(User.id.in_(manager_ids)).all()
return [{"id": m.id, "name": m.name, "email": m.email} for m in managers]
@router.post("/{office_id}/managers")
def add_office_manager(office_id: str, data: AddManagerRequest, db: Session = Depends(get_db), user=Depends(require_admin)):
"""Add manager to office (admin only)"""
if not db.query(Office).filter(Office.id == office_id).first():
raise HTTPException(status_code=404, detail="Office not found")
manager = db.query(User).filter(User.id == data.user_id).first()
if not manager:
raise HTTPException(status_code=404, detail="User not found")
if manager.role != "manager":
raise HTTPException(status_code=400, detail="User must have manager role")
existing = db.query(OfficeMembership).filter(
OfficeMembership.office_id == office_id,
OfficeMembership.user_id == data.user_id
).first()
if existing:
raise HTTPException(status_code=400, detail="Manager already assigned to office")
membership = OfficeMembership(
id=str(uuid.uuid4()),
office_id=office_id,
user_id=data.user_id,
created_at=datetime.utcnow().isoformat()
)
db.add(membership)
db.commit()
return {"message": "Manager added to office"}
@router.delete("/{office_id}/managers/{manager_id}")
def remove_office_manager(office_id: str, manager_id: str, db: Session = Depends(get_db), user=Depends(require_admin)):
"""Remove manager from office (admin only)"""
membership = db.query(OfficeMembership).filter(
OfficeMembership.office_id == office_id,
OfficeMembership.user_id == manager_id
).first()
if not membership:
raise HTTPException(status_code=404, detail="Manager not assigned to office")
db.delete(membership)
db.commit()
return {"message": "Manager removed from office"}
# Legacy redirect for /api/offices/managers/list -> /api/managers
@router.get("/managers/list")
def list_managers_legacy(db: Session = Depends(get_db), user=Depends(require_manager_or_admin)):
"""Get all managers with their managed offices and parking quota (legacy endpoint)"""
managers = db.query(User).filter(User.role == "manager").all()
result = []
for manager in managers:
memberships = db.query(OfficeMembership).filter(OfficeMembership.user_id == manager.id).all()
office_ids = [m.office_id for m in memberships]
offices = db.query(Office).filter(Office.id.in_(office_ids)).all() if office_ids else []
result.append({
"id": manager.id,
"name": manager.name,
"email": manager.email,
"parking_quota": manager.manager_parking_quota or 0,
"spot_prefix": manager.manager_spot_prefix,
"offices": [{"id": o.id, "name": o.name} for o in offices]
})
return result

View File

@@ -15,7 +15,7 @@ from sqlalchemy.orm import Session
import uuid import uuid
from database.connection import get_db from database.connection import get_db
from database.models import DailyParkingAssignment, User, OfficeMembership from database.models import DailyParkingAssignment, User
from utils.auth_middleware import get_current_user, require_manager_or_admin 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
from services.notifications import queue_parking_change_notification from services.notifications import queue_parking_change_notification
@@ -49,7 +49,6 @@ class AssignmentResponse(BaseModel):
manager_id: str manager_id: str
user_name: str | None = None user_name: str | None = None
user_email: str | None = None user_email: str | None = None
user_office_id: str | None = None
# Routes # Routes
@@ -102,7 +101,6 @@ def get_assignments(date: str, manager_id: str = None, db: Session = Depends(get
if user: if user:
result.user_name = user.name result.user_name = user.name
result.user_email = user.email result.user_email = user.email
result.user_office_id = user.office_id
results.append(result) results.append(result)
@@ -307,7 +305,7 @@ def reassign_spot(data: ReassignSpotRequest, db: Session = Depends(get_db), curr
@router.get("/eligible-users/{assignment_id}") @router.get("/eligible-users/{assignment_id}")
def get_eligible_users(assignment_id: str, db: Session = Depends(get_db), current_user=Depends(get_current_user)): def get_eligible_users(assignment_id: str, db: Session = Depends(get_db), current_user=Depends(get_current_user)):
"""Get users eligible for reassignment of a parking spot. """Get users eligible for reassignment of a parking spot.
Returns users in the same manager's offices. Returns users managed by the same manager.
""" """
assignment = db.query(DailyParkingAssignment).filter( assignment = db.query(DailyParkingAssignment).filter(
DailyParkingAssignment.id == assignment_id DailyParkingAssignment.id == assignment_id
@@ -324,16 +322,9 @@ def get_eligible_users(assignment_id: str, db: Session = Depends(get_db), curren
if not (is_admin or is_manager or is_spot_owner): if not (is_admin or is_manager or is_spot_owner):
raise HTTPException(status_code=403, detail="Not authorized") raise HTTPException(status_code=403, detail="Not authorized")
# Get all users belonging to offices managed by this manager # Get users in this manager's team (including the manager themselves)
# Get offices managed by this manager
managed_office_ids = db.query(OfficeMembership.office_id).filter(
OfficeMembership.user_id == assignment.manager_id
).all()
managed_office_ids = [o[0] for o in managed_office_ids]
# Get users in those offices
users = db.query(User).filter( users = db.query(User).filter(
User.office_id.in_(managed_office_ids), (User.manager_id == assignment.manager_id) | (User.id == assignment.manager_id),
User.id != assignment.user_id # Exclude current holder User.id != assignment.user_id # Exclude current holder
).all() ).all()
@@ -351,8 +342,7 @@ def get_eligible_users(assignment_id: str, db: Session = Depends(get_db), curren
result.append({ result.append({
"id": user.id, "id": user.id,
"name": user.name, "name": user.name,
"email": user.email, "email": user.email
"office_id": user.office_id
}) })
return result return result

View File

@@ -10,8 +10,8 @@ from sqlalchemy.orm import Session
import uuid import uuid
from database.connection import get_db from database.connection import get_db
from database.models import UserPresence, User, DailyParkingAssignment, OfficeMembership, Office from database.models import UserPresence, User, DailyParkingAssignment
from utils.auth_middleware import get_current_user, require_manager_or_admin, check_manager_access_to_user from utils.auth_middleware import get_current_user, require_manager_or_admin
from services.parking import handle_presence_change, get_spot_display_name from services.parking import handle_presence_change, get_spot_display_name
router = APIRouter(prefix="/api/presence", tags=["presence"]) router = APIRouter(prefix="/api/presence", tags=["presence"])
@@ -70,6 +70,20 @@ def parse_date(date_str: str) -> datetime:
raise HTTPException(status_code=400, detail="Invalid date format. Use YYYY-MM-DD") 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":
return True
if current_user.role == "manager":
# Manager can access users they manage
if target_user.manager_id == current_user.id:
return True
raise HTTPException(status_code=403, detail="User is not managed by you")
raise HTTPException(status_code=403, detail="Access denied")
def _mark_presence_for_user( def _mark_presence_for_user(
user_id: str, user_id: str,
date: str, date: str,
@@ -111,12 +125,18 @@ def _mark_presence_for_user(
db.refresh(presence) db.refresh(presence)
# Handle parking assignment # Handle parking assignment
if old_status != status and target_user.office_id: # 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:
try: try:
handle_presence_change( handle_presence_change(
user_id, date, user_id, date,
old_status or "absent", status, old_status or "absent", status,
target_user.office_id, db parking_manager_id, db
) )
except Exception as e: except Exception as e:
print(f"Warning: Parking handler failed: {e}") print(f"Warning: Parking handler failed: {e}")
@@ -177,12 +197,17 @@ def _bulk_mark_presence(
results.append(presence) results.append(presence)
# Handle parking for each date # Handle parking for each date
if old_status != status and target_user.office_id: # 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: try:
handle_presence_change( handle_presence_change(
user_id, date_str, user_id, date_str,
old_status or "absent", status, old_status or "absent", status,
target_user.office_id, db parking_manager_id, db
) )
except Exception: except Exception:
pass pass
@@ -216,12 +241,17 @@ def _delete_presence(
db.delete(presence) db.delete(presence)
db.commit() db.commit()
if target_user.office_id: # 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:
try: try:
handle_presence_change( handle_presence_change(
user_id, date, user_id, date,
old_status, "absent", old_status, "absent",
target_user.office_id, db parking_manager_id, db
) )
except Exception: except Exception:
pass pass
@@ -274,7 +304,7 @@ def admin_mark_presence(data: AdminPresenceMarkRequest, db: Session = Depends(ge
if not target_user: if not target_user:
raise HTTPException(status_code=404, detail="User not found") raise HTTPException(status_code=404, detail="User not found")
check_manager_access_to_user(current_user, target_user, db) check_manager_access(current_user, target_user, db)
return _mark_presence_for_user(data.user_id, data.date, data.status, db, target_user) return _mark_presence_for_user(data.user_id, data.date, data.status, db, target_user)
@@ -285,7 +315,7 @@ def admin_mark_bulk_presence(data: AdminBulkPresenceRequest, db: Session = Depen
if not target_user: if not target_user:
raise HTTPException(status_code=404, detail="User not found") raise HTTPException(status_code=404, detail="User not found")
check_manager_access_to_user(current_user, target_user, db) check_manager_access(current_user, target_user, db)
return _bulk_mark_presence( return _bulk_mark_presence(
data.user_id, data.start_date, data.end_date, data.user_id, data.start_date, data.end_date,
data.status, data.days, db, target_user data.status, data.days, db, target_user
@@ -299,34 +329,42 @@ def admin_delete_presence(user_id: str, date: str, db: Session = Depends(get_db)
if not target_user: if not target_user:
raise HTTPException(status_code=404, detail="User not found") raise HTTPException(status_code=404, detail="User not found")
check_manager_access_to_user(current_user, target_user, db) check_manager_access(current_user, target_user, db)
return _delete_presence(user_id, date, db, target_user) return _delete_presence(user_id, date, db, target_user)
@router.get("/team") @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(require_manager_or_admin)): 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 for managers/admins, filtered by manager""" """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)
"""
parse_date(start_date) parse_date(start_date)
parse_date(end_date) parse_date(end_date)
# Get users based on permissions and manager filter # Get users based on permissions and manager filter
if manager_id: # Note: Manager is part of their own team (for parking assignment purposes)
# Filter by specific manager's offices if current_user.role == "employee":
managed_office_ids = [m.office_id for m in db.query(OfficeMembership).filter( # Employees can only see their own team (users with same manager_id + the manager)
OfficeMembership.user_id == manager_id if not current_user.manager_id:
).all()] return [] # No manager assigned, no team to show
if not managed_office_ids: users = db.query(User).filter(
return [] (User.manager_id == current_user.manager_id) | (User.id == current_user.manager_id)
users = db.query(User).filter(User.office_id.in_(managed_office_ids)).all() ).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": elif current_user.role == "admin":
# Admin sees all users # Admin sees all users
users = db.query(User).all() users = db.query(User).all()
else: else:
# Manager sees only users in their managed offices # Manager sees their team + themselves
managed_ids = [m.office_id for m in current_user.managed_offices] users = db.query(User).filter(
if not managed_ids: (User.manager_id == current_user.id) | (User.id == current_user.id)
return [] ).all()
users = db.query(User).filter(User.office_id.in_(managed_ids)).all()
# Batch query presences and parking for all users # Batch query presences and parking for all users
user_ids = [u.id for u in users] user_ids = [u.id for u in users]
@@ -358,30 +396,21 @@ def get_team_presences(start_date: str, end_date: str, manager_id: str = None, d
"spot_display_name": spot_display_name "spot_display_name": spot_display_name
}) })
# Build office and managed offices lookups # Build manager lookup for display
offices_lookup = {o.id: o.name for o in db.query(Office).all()} manager_ids = list(set(u.manager_id for u in users if u.manager_id))
managed_offices_lookup = {} managers = db.query(User).filter(User.id.in_(manager_ids)).all() if manager_ids else []
for m in db.query(OfficeMembership).all(): manager_lookup = {m.id: m.name for m in managers}
if m.user_id not in managed_offices_lookup:
managed_offices_lookup[m.user_id] = []
managed_offices_lookup[m.user_id].append(offices_lookup.get(m.office_id, m.office_id))
# Build response # Build response
result = [] result = []
for user in users: for user in users:
user_presences = [p for p in presences if p.user_id == user.id] user_presences = [p for p in presences if p.user_id == user.id]
# For managers, show managed offices; for others, show their office
if user.role == "manager" and user.id in managed_offices_lookup:
office_display = ", ".join(managed_offices_lookup[user.id])
else:
office_display = offices_lookup.get(user.office_id)
result.append({ result.append({
"id": user.id, "id": user.id,
"name": user.name, "name": user.name,
"office_id": user.office_id, "manager_id": user.manager_id,
"office_name": office_display, "manager_name": manager_lookup.get(user.manager_id),
"presences": [{"date": p.date, "status": p.status} for p in user_presences], "presences": [{"date": p.date, "status": p.status} for p in user_presences],
"parking_dates": parking_lookup.get(user.id, []), "parking_dates": parking_lookup.get(user.id, []),
"parking_info": parking_info_lookup.get(user.id, []) "parking_info": parking_info_lookup.get(user.id, [])
@@ -397,7 +426,7 @@ def get_user_presences(user_id: str, start_date: str = None, end_date: str = Non
if not target_user: if not target_user:
raise HTTPException(status_code=404, detail="User not found") raise HTTPException(status_code=404, detail="User not found")
check_manager_access_to_user(current_user, target_user, db) check_manager_access(current_user, target_user, db)
query = db.query(UserPresence).filter(UserPresence.user_id == user_id) query = db.query(UserPresence).filter(UserPresence.user_id == user_id)

View File

@@ -11,9 +11,10 @@ import uuid
import re import re
from database.connection import get_db from database.connection import get_db
from database.models import User, Office, OfficeMembership from database.models import User
from utils.auth_middleware import get_current_user, require_admin from utils.auth_middleware import get_current_user, require_admin
from services.auth import hash_password, verify_password from services.auth import hash_password, verify_password
from app import config
router = APIRouter(prefix="/api/users", tags=["users"]) router = APIRouter(prefix="/api/users", tags=["users"])
@@ -24,21 +25,19 @@ class UserCreate(BaseModel):
password: str password: str
name: str | None = None name: str | None = None
role: str = "employee" role: str = "employee"
office_id: str | None = None manager_id: str | None = None
class UserUpdate(BaseModel): class UserUpdate(BaseModel):
email: EmailStr | None = None
name: str | None = None name: str | None = None
role: str | None = None role: str | None = None
office_id: str | None = None manager_id: str | None = None
manager_parking_quota: int | None = None manager_parking_quota: int | None = None
manager_spot_prefix: str | None = None manager_spot_prefix: str | None = None
class ProfileUpdate(BaseModel): class ProfileUpdate(BaseModel):
name: str | None = None name: str | None = None
office_id: str | None = None
class SettingsUpdate(BaseModel): class SettingsUpdate(BaseModel):
@@ -61,44 +60,86 @@ class UserResponse(BaseModel):
email: str email: str
name: str | None name: str | None
role: str role: str
office_id: str | None manager_id: str | None = None
manager_name: str | None = None
manager_parking_quota: int | None = None manager_parking_quota: int | None = None
manager_spot_prefix: str | None = None manager_spot_prefix: str | None = None
managed_user_count: int | None = None
is_ldap_user: bool = False
is_ldap_admin: bool = False
created_at: str | None created_at: str | None
class Config: class Config:
from_attributes = True 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
manager_name = None
if user.manager_id:
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"
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,
"is_ldap_user": is_ldap_user,
"is_ldap_admin": is_ldap_admin,
"created_at": user.created_at
}
# Admin Routes # Admin Routes
@router.get("", response_model=List[UserResponse]) @router.get("")
def list_users(db: Session = Depends(get_db), user=Depends(require_admin)): def list_users(db: Session = Depends(get_db), user=Depends(require_admin)):
"""List all users (admin only)""" """List all users (admin only)"""
users = db.query(User).all() users = db.query(User).all()
return users return [user_to_response(u, db) for u in users]
@router.get("/{user_id}", response_model=UserResponse) @router.get("/{user_id}")
def get_user(user_id: str, db: Session = Depends(get_db), user=Depends(require_admin)): def get_user(user_id: str, db: Session = Depends(get_db), user=Depends(require_admin)):
"""Get user by ID (admin only)""" """Get user by ID (admin only)"""
target = db.query(User).filter(User.id == user_id).first() target = db.query(User).filter(User.id == user_id).first()
if not target: if not target:
raise HTTPException(status_code=404, detail="User not found") raise HTTPException(status_code=404, detail="User not found")
return target return user_to_response(target, db)
@router.post("", response_model=UserResponse) @router.post("")
def create_user(data: UserCreate, db: Session = Depends(get_db), user=Depends(require_admin)): def create_user(data: UserCreate, db: Session = Depends(get_db), user=Depends(require_admin)):
"""Create new user (admin only)""" """Create new user (admin only) - only for non-LDAP mode"""
if config.AUTHELIA_ENABLED:
raise HTTPException(status_code=400, detail="User creation disabled in LDAP mode. Users are created on first login.")
if db.query(User).filter(User.email == data.email).first(): if db.query(User).filter(User.email == data.email).first():
raise HTTPException(status_code=400, detail="Email already registered") raise HTTPException(status_code=400, detail="Email already registered")
if data.role not in ["admin", "manager", "employee"]: if data.role not in ["admin", "manager", "employee"]:
raise HTTPException(status_code=400, detail="Invalid role") raise HTTPException(status_code=400, detail="Invalid role")
if data.office_id: if data.manager_id:
if not db.query(Office).filter(Office.id == data.office_id).first(): manager = db.query(User).filter(User.id == data.manager_id).first()
raise HTTPException(status_code=404, detail="Office not found") if not manager or manager.role != "manager":
raise HTTPException(status_code=400, detail="Invalid manager")
new_user = User( new_user = User(
id=str(uuid.uuid4()), id=str(uuid.uuid4()),
@@ -106,42 +147,61 @@ def create_user(data: UserCreate, db: Session = Depends(get_db), user=Depends(re
password_hash=hash_password(data.password), password_hash=hash_password(data.password),
name=data.name, name=data.name,
role=data.role, role=data.role,
office_id=data.office_id, manager_id=data.manager_id,
created_at=datetime.utcnow().isoformat() created_at=datetime.utcnow().isoformat()
) )
db.add(new_user) db.add(new_user)
db.commit() db.commit()
db.refresh(new_user) db.refresh(new_user)
return new_user return user_to_response(new_user, db)
@router.put("/{user_id}", response_model=UserResponse) @router.put("/{user_id}")
def update_user(user_id: str, data: UserUpdate, db: Session = Depends(get_db), user=Depends(require_admin)): def update_user(user_id: str, data: UserUpdate, db: Session = Depends(get_db), user=Depends(require_admin)):
"""Update user (admin only)""" """Update user (admin only)"""
target = db.query(User).filter(User.id == user_id).first() target = db.query(User).filter(User.id == user_id).first()
if not target: if not target:
raise HTTPException(status_code=404, detail="User not found") raise HTTPException(status_code=404, detail="User not found")
if data.email is not None: # Check if user is LDAP-managed
existing = db.query(User).filter(User.email == data.email, User.id != user_id).first() is_ldap_user = config.AUTHELIA_ENABLED and target.password_hash is None
if existing: is_ldap_admin = is_ldap_user and target.role == "admin"
raise HTTPException(status_code=400, detail="Email already in use")
target.email = data.email
# Name update - blocked for LDAP users
if data.name is not None: if data.name is not None:
if is_ldap_user:
raise HTTPException(status_code=400, detail="Name is managed by LDAP")
target.name = data.name target.name = data.name
# Role update
if data.role is not None: if data.role is not None:
if data.role not in ["admin", "manager", "employee"]: if data.role not in ["admin", "manager", "employee"]:
raise HTTPException(status_code=400, detail="Invalid role") 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":
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 target.role = data.role
if data.office_id is not None: # Manager assignment (any user including admins can be assigned to a manager)
if data.office_id and not db.query(Office).filter(Office.id == data.office_id).first(): if data.manager_id is not None:
raise HTTPException(status_code=404, detail="Office not found") if data.manager_id:
target.office_id = data.office_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
# Manager-specific fields
if data.manager_parking_quota is not None: if data.manager_parking_quota is not None:
if target.role != "manager": if target.role != "manager":
raise HTTPException(status_code=400, detail="Parking quota only for managers") raise HTTPException(status_code=400, detail="Parking quota only for managers")
@@ -166,7 +226,7 @@ def update_user(user_id: str, data: UserUpdate, db: Session = Depends(get_db), u
target.updated_at = datetime.utcnow().isoformat() target.updated_at = datetime.utcnow().isoformat()
db.commit() db.commit()
db.refresh(target) db.refresh(target)
return target return user_to_response(target, db)
@router.delete("/{user_id}") @router.delete("/{user_id}")
@@ -179,60 +239,53 @@ def delete_user(user_id: str, db: Session = Depends(get_db), current_user=Depend
if not target: if not target:
raise HTTPException(status_code=404, detail="User not found") 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.delete(target)
db.commit() db.commit()
return {"message": "User deleted"} return {"message": "User deleted"}
# Self-service Routes # Self-service Routes
@router.get("/me/managed-offices")
def get_managed_offices(db: Session = Depends(get_db), current_user=Depends(get_current_user)):
"""Get offices the current user manages"""
if current_user.role == "admin":
offices = db.query(Office).all()
return {"role": "admin", "offices": [{"id": o.id, "name": o.name} for o in offices]}
if current_user.role == "manager":
memberships = db.query(OfficeMembership).filter(
OfficeMembership.user_id == current_user.id
).all()
office_ids = [m.office_id for m in memberships]
offices = db.query(Office).filter(Office.id.in_(office_ids)).all()
return {"role": "manager", "offices": [{"id": o.id, "name": o.name} for o in offices]}
if current_user.office_id:
office = db.query(Office).filter(Office.id == current_user.office_id).first()
if office:
return {"role": "employee", "offices": [{"id": office.id, "name": office.name}]}
return {"role": current_user.role, "offices": []}
@router.get("/me/profile") @router.get("/me/profile")
def get_profile(current_user=Depends(get_current_user)): def get_profile(db: Session = Depends(get_db), current_user=Depends(get_current_user)):
"""Get current user's profile""" """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:
manager = db.query(User).filter(User.id == current_user.manager_id).first()
if manager:
manager_name = manager.name
return { return {
"id": current_user.id, "id": current_user.id,
"email": current_user.email, "email": current_user.email,
"name": current_user.name, "name": current_user.name,
"role": current_user.role, "role": current_user.role,
"office_id": current_user.office_id "manager_id": current_user.manager_id,
"manager_name": manager_name,
"is_ldap_user": is_ldap_user
} }
@router.put("/me/profile") @router.put("/me/profile")
def update_profile(data: ProfileUpdate, db: Session = Depends(get_db), current_user=Depends(get_current_user)): def update_profile(data: ProfileUpdate, db: Session = Depends(get_db), current_user=Depends(get_current_user)):
"""Update current user's profile""" """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 data.name is not None:
if is_ldap_user:
raise HTTPException(status_code=400, detail="Name is managed by LDAP")
current_user.name = data.name current_user.name = data.name
current_user.updated_at = datetime.utcnow().isoformat()
db.commit()
if data.office_id is not None:
if data.office_id and not db.query(Office).filter(Office.id == data.office_id).first():
raise HTTPException(status_code=404, detail="Office not found")
current_user.office_id = data.office_id if data.office_id else None
current_user.updated_at = datetime.utcnow().isoformat()
db.commit()
return {"message": "Profile updated"} return {"message": "Profile updated"}
@@ -292,7 +345,10 @@ def update_settings(data: SettingsUpdate, db: Session = Depends(get_db), current
@router.post("/me/change-password") @router.post("/me/change-password")
def change_password(data: ChangePasswordRequest, db: Session = Depends(get_db), current_user=Depends(get_current_user)): def change_password(data: ChangePasswordRequest, db: Session = Depends(get_db), current_user=Depends(get_current_user)):
"""Change current user's password""" """Change current user's password (not available in LDAP mode)"""
if config.AUTHELIA_ENABLED and current_user.password_hash is None:
raise HTTPException(status_code=400, detail="Password is managed by LDAP")
if not verify_password(data.current_password, current_user.password_hash): if not verify_password(data.current_password, current_user.password_hash):
raise HTTPException(status_code=400, detail="Current password is incorrect") raise HTTPException(status_code=400, detail="Current password is incorrect")

View File

@@ -1,7 +1,7 @@
services: services:
parking: parking:
build: . build: .
container_name: parking-manager container_name: parking
restart: unless-stopped restart: unless-stopped
ports: ports:
- "8000:8000" - "8000:8000"
@@ -26,9 +26,10 @@ services:
timeout: 10s timeout: 10s
retries: 3 retries: 3
start_period: 10s start_period: 10s
networks:
- org-network
# For production with external network (Caddy/Authelia): networks:
# networks: org-network:
# default: external: true
# external: true name: org-stack_org-network
# name: proxy

View File

@@ -3,19 +3,14 @@ Create test database with sample data
Run: .venv/bin/python create_test_db.py Run: .venv/bin/python create_test_db.py
Manager-centric model: Manager-centric model:
- Users have a manager_id pointing to their manager
- Managers own parking spots (manager_parking_quota) - Managers own parking spots (manager_parking_quota)
- Each manager has a spot prefix (A, B, C...) for display names - Each manager has a spot prefix (A, B, C...) for display names
- Offices are containers for employees (like LDAP groups)
LDAP Group Simulation:
- parking_admin: admin role
- parking_manager: manager role
- office groups (presales, design, operations): employee office assignment
""" """
import uuid import uuid
from datetime import datetime, timezone from datetime import datetime, timezone
from database.connection import engine, SessionLocal from database.connection import engine, SessionLocal
from database.models import Base, User, Office, OfficeMembership from database.models import Base, User
from services.auth import hash_password from services.auth import hash_password
# Drop and recreate all tables for clean slate # Drop and recreate all tables for clean slate
@@ -27,29 +22,8 @@ db = SessionLocal()
now = datetime.now(timezone.utc).isoformat() now = datetime.now(timezone.utc).isoformat()
password_hash = hash_password("password123") password_hash = hash_password("password123")
# Create offices (representing LDAP groups) # Create users with manager-centric model
offices = [ # manager_id points to the user's manager
Office(id="presales", name="Presales", location="Building A", created_at=now),
Office(id="design", name="Design", location="Building B", created_at=now),
Office(id="operations", name="Operations", location="Building C", created_at=now),
]
for office in offices:
db.add(office)
print(f"Created office: {office.name}")
db.commit()
# Create users
# LDAP groups simulation:
# admin: parking_admin, presales
# manager1: parking_manager, presales, design
# manager2: parking_manager, operations
# user1: presales
# user2: design
# user3: design
# user4: operations
# user5: operations
users_data = [ users_data = [
{ {
@@ -57,15 +31,15 @@ users_data = [
"email": "admin@example.com", "email": "admin@example.com",
"name": "Admin User", "name": "Admin User",
"role": "admin", "role": "admin",
"office_id": "presales", # Primary office from LDAP groups "manager_id": None, # Admins don't have managers
}, },
{ {
"id": "manager1", "id": "manager1",
"email": "manager1@example.com", "email": "manager1@example.com",
"name": "Alice Manager", "name": "Alice Manager",
"role": "manager", "role": "manager",
"office_id": "presales", "manager_id": None, # Managers don't have managers
"manager_parking_quota": 3, # For 5 users: admin, alice, user1, user2, user3 "manager_parking_quota": 3,
"manager_spot_prefix": "A", "manager_spot_prefix": "A",
}, },
{ {
@@ -73,8 +47,8 @@ users_data = [
"email": "manager2@example.com", "email": "manager2@example.com",
"name": "Bob Manager", "name": "Bob Manager",
"role": "manager", "role": "manager",
"office_id": "operations", "manager_id": None,
"manager_parking_quota": 2, # For 3 users: bob, user4, user5 "manager_parking_quota": 2,
"manager_spot_prefix": "B", "manager_spot_prefix": "B",
}, },
{ {
@@ -82,35 +56,35 @@ users_data = [
"email": "user1@example.com", "email": "user1@example.com",
"name": "User One", "name": "User One",
"role": "employee", "role": "employee",
"office_id": "presales", "manager_id": "manager1", # Managed by Alice
}, },
{ {
"id": "user2", "id": "user2",
"email": "user2@example.com", "email": "user2@example.com",
"name": "User Two", "name": "User Two",
"role": "employee", "role": "employee",
"office_id": "design", "manager_id": "manager1", # Managed by Alice
}, },
{ {
"id": "user3", "id": "user3",
"email": "user3@example.com", "email": "user3@example.com",
"name": "User Three", "name": "User Three",
"role": "employee", "role": "employee",
"office_id": "design", "manager_id": "manager1", # Managed by Alice
}, },
{ {
"id": "user4", "id": "user4",
"email": "user4@example.com", "email": "user4@example.com",
"name": "User Four", "name": "User Four",
"role": "employee", "role": "employee",
"office_id": "operations", "manager_id": "manager2", # Managed by Bob
}, },
{ {
"id": "user5", "id": "user5",
"email": "user5@example.com", "email": "user5@example.com",
"name": "User Five", "name": "User Five",
"role": "employee", "role": "employee",
"office_id": "operations", "manager_id": "manager2", # Managed by Bob
}, },
] ]
@@ -121,7 +95,7 @@ for data in users_data:
password_hash=password_hash, password_hash=password_hash,
name=data["name"], name=data["name"],
role=data["role"], role=data["role"],
office_id=data.get("office_id"), manager_id=data.get("manager_id"),
manager_parking_quota=data.get("manager_parking_quota", 0), manager_parking_quota=data.get("manager_parking_quota", 0),
manager_spot_prefix=data.get("manager_spot_prefix"), manager_spot_prefix=data.get("manager_spot_prefix"),
created_at=now created_at=now
@@ -129,30 +103,6 @@ for data in users_data:
db.add(user) db.add(user)
print(f"Created user: {user.email} ({user.role})") print(f"Created user: {user.email} ({user.role})")
db.commit()
# Create office memberships for managers
# Manager 1 (Alice) manages Presales and Design
for office_id in ["presales", "design"]:
membership = OfficeMembership(
id=str(uuid.uuid4()),
user_id="manager1",
office_id=office_id,
created_at=now
)
db.add(membership)
print(f"Created membership: Alice -> {office_id}")
# Manager 2 (Bob) manages Operations
membership = OfficeMembership(
id=str(uuid.uuid4()),
user_id="manager2",
office_id="operations",
created_at=now
)
db.add(membership)
print(f"Created membership: Bob -> operations")
db.commit() db.commit()
db.close() db.close()
@@ -161,23 +111,22 @@ print("Test database created successfully!")
print("="*60) print("="*60)
print("\nTest accounts (all use password: password123):") print("\nTest accounts (all use password: password123):")
print("-"*60) print("-"*60)
print(f"{'Email':<25} {'Role':<10} {'Office':<12} {'Spots'}") print(f"{'Email':<25} {'Role':<10} {'Manager':<15}")
print("-"*60) print("-"*60)
print(f"{'admin@example.com':<25} {'admin':<10} {'presales':<12}") print(f"{'admin@example.com':<25} {'admin':<10} {'-':<15}")
print(f"{'manager1@example.com':<25} {'manager':<10} {'presales':<12}") print(f"{'manager1@example.com':<25} {'manager':<10} {'-':<15}")
print(f"{'manager2@example.com':<25} {'manager':<10} {'operations':<12}") print(f"{'manager2@example.com':<25} {'manager':<10} {'-':<15}")
print(f"{'user1@example.com':<25} {'employee':<10} {'presales':<12}") print(f"{'user1@example.com':<25} {'employee':<10} {'Alice':<15}")
print(f"{'user2@example.com':<25} {'employee':<10} {'design':<12}") print(f"{'user2@example.com':<25} {'employee':<10} {'Alice':<15}")
print(f"{'user3@example.com':<25} {'employee':<10} {'design':<12}") print(f"{'user3@example.com':<25} {'employee':<10} {'Alice':<15}")
print(f"{'user4@example.com':<25} {'employee':<10} {'operations':<12}") print(f"{'user4@example.com':<25} {'employee':<10} {'Bob':<15}")
print(f"{'user5@example.com':<25} {'employee':<10} {'operations':<12}") print(f"{'user5@example.com':<25} {'employee':<10} {'Bob':<15}")
print("-"*60) print("-"*60)
print("\nParking pools:") print("\nParking pools:")
print(" Alice (manager1): 3 spots (A1,A2,A3)") print(" Alice (manager1): 3 spots (A1,A2,A3)")
print(" -> presales: admin, alice, user1") print(" -> manages: user1, user2, user3")
print(" -> design: user2, user3") print(" -> 3 users, 3 spots = 100% ratio target")
print(" -> 5 users, 3 spots = 60% ratio target")
print() print()
print(" Bob (manager2): 2 spots (B1,B2)") print(" Bob (manager2): 2 spots (B1,B2)")
print(" -> operations: bob, user4, user5") print(" -> manages: user4, user5")
print(" -> 3 users, 2 spots = 67% ratio target") print(" -> 2 users, 2 spots = 100% ratio target")

View File

@@ -36,4 +36,7 @@ def get_db_session():
def init_db(): def init_db():
"""Create all tables""" """Create all tables"""
from database.models import Base from database.models import Base
print(f"[init_db] Initializing database at {config.DATABASE_URL}")
Base.metadata.create_all(bind=engine) Base.metadata.create_all(bind=engine)
print(f"[init_db] Tables created: {list(Base.metadata.tables.keys())}")

View File

@@ -17,7 +17,7 @@ class User(Base):
password_hash = Column(Text) password_hash = Column(Text)
name = Column(Text) name = Column(Text)
role = Column(Text, nullable=False, default="employee") # admin, manager, employee role = Column(Text, nullable=False, default="employee") # admin, manager, employee
office_id = Column(Text, ForeignKey("offices.id")) 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-specific fields (only relevant for role='manager')
manager_parking_quota = Column(Integer, default=0) # Number of spots this manager controls manager_parking_quota = Column(Integer, default=0) # Number of spots this manager controls
@@ -37,51 +37,13 @@ class User(Base):
updated_at = Column(Text) updated_at = Column(Text)
# Relationships # Relationships
office = relationship("Office", back_populates="users") manager = relationship("User", remote_side=[id], backref="managed_users")
presences = relationship("UserPresence", back_populates="user", cascade="all, delete-orphan") presences = relationship("UserPresence", back_populates="user", cascade="all, delete-orphan")
assignments = relationship("DailyParkingAssignment", back_populates="user", foreign_keys="DailyParkingAssignment.user_id") assignments = relationship("DailyParkingAssignment", back_populates="user", foreign_keys="DailyParkingAssignment.user_id")
managed_offices = relationship("OfficeMembership", back_populates="user", cascade="all, delete-orphan")
__table_args__ = ( __table_args__ = (
Index('idx_user_email', 'email'), Index('idx_user_email', 'email'),
Index('idx_user_office', 'office_id'), Index('idx_user_manager', 'manager_id'),
)
class Office(Base):
"""Office locations - containers for grouping employees"""
__tablename__ = "offices"
id = Column(Text, primary_key=True)
name = Column(Text, nullable=False)
location = Column(Text)
# Note: parking_spots removed - spots are now managed at manager level
created_at = Column(Text)
updated_at = Column(Text)
# Relationships
users = relationship("User", back_populates="office")
managers = relationship("OfficeMembership", back_populates="office", cascade="all, delete-orphan")
class OfficeMembership(Base):
"""Manager-Office relationship (which managers manage which offices)"""
__tablename__ = "office_memberships"
id = Column(Text, primary_key=True)
user_id = Column(Text, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
office_id = Column(Text, ForeignKey("offices.id", ondelete="CASCADE"), nullable=False)
created_at = Column(Text)
# Relationships
user = relationship("User", back_populates="managed_offices")
office = relationship("Office", back_populates="managers")
__table_args__ = (
Index('idx_membership_user', 'user_id'),
Index('idx_membership_office', 'office_id'),
Index('idx_membership_unique', 'user_id', 'office_id', unique=True),
) )
@@ -128,7 +90,7 @@ class DailyParkingAssignment(Base):
class ManagerClosingDay(Base): class ManagerClosingDay(Base):
"""Specific date closing days for a manager's offices (holidays, special closures)""" """Specific date closing days for a manager's parking pool (holidays, special closures)"""
__tablename__ = "manager_closing_days" __tablename__ = "manager_closing_days"
id = Column(Text, primary_key=True) id = Column(Text, primary_key=True)
@@ -145,7 +107,7 @@ class ManagerClosingDay(Base):
class ManagerWeeklyClosingDay(Base): class ManagerWeeklyClosingDay(Base):
"""Weekly recurring closing days for a manager's offices (e.g., Saturday and Sunday)""" """Weekly recurring closing days for a manager's parking pool (e.g., Saturday and Sunday)"""
__tablename__ = "manager_weekly_closing_days" __tablename__ = "manager_weekly_closing_days"
id = Column(Text, primary_key=True) id = Column(Text, primary_key=True)

View File

@@ -3,55 +3,52 @@
## Prerequisites ## Prerequisites
- org-stack running on rocky@rocketscale.it - org-stack running on rocky@rocketscale.it
- Git repository on git.rocketscale.it - Git repository on git.rocketscale.it (optional, can use rsync)
## Step 1: Push to Git ## Directory Structure
```bash Parking is deployed as a **separate directory** alongside org-stack:
# On development machine
cd /mnt/code/boilerplate/org-parking ```
git init ~/
git add . ├── org-stack/ # Main stack (Caddy, Authelia, LLDAP, etc.)
git commit -m "Initial commit: Parking Manager" │ ├── compose.yml
git remote add origin git@git.rocketscale.it:rocky/parking-manager.git │ ├── Caddyfile
git push -u origin main │ ├── 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 2: Clone on Server ## 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 ```bash
# SSH to server
ssh rocky@rocketscale.it ssh rocky@rocketscale.it
cd ~
# Clone into org-stack git clone git@git.rocketscale.it:rocky/parking-manager.git org-parking
cd ~/org-stack
git clone git@git.rocketscale.it:rocky/parking-manager.git parking
``` ```
## Step 3: Add to .env ## Step 2: Create Production compose.yml
Add to `~/org-stack/.env`: Create `~/org-parking/compose.yml` on the server:
```bash
# Parking Manager
PARKING_SECRET_KEY=your-random-secret-key-here
```
Generate a secret key:
```bash
python3 -c "import secrets; print(secrets.token_hex(32))"
```
## Step 4: Add to compose.yml
Add the parking service to `~/org-stack/compose.yml`:
```yaml ```yaml
# =========================================================================== services:
# Parking Manager - Parking Spot Management
# ===========================================================================
parking: parking:
build: ./parking build: .
container_name: parking container_name: parking
restart: unless-stopped restart: unless-stopped
volumes: volumes:
@@ -68,18 +65,34 @@ Add the parking service to `~/org-stack/compose.yml`:
- SMTP_FROM=${SMTP_FROM:-} - SMTP_FROM=${SMTP_FROM:-}
networks: networks:
- org-network - org-network
depends_on: healthcheck:
- authelia 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
``` ```
Add to volumes section: ## Step 3: Create .env File
```yaml
parking_data: # Parking SQLite database 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
``` ```
Add `parking` to Caddy's depends_on list. **Note**: Each directory needs its own `.env` file since docker compose only reads from the current directory.
## Step 5: Add to Caddyfile ## Step 4: Add to Caddyfile
Add to `~/org-stack/Caddyfile`: Add to `~/org-stack/Caddyfile`:
@@ -91,27 +104,57 @@ parking.rocketscale.it {
} }
``` ```
## Step 6: Create LLDAP Groups ## 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): In lldap (https://ldap.rocketscale.it):
1. Create group: `parking_admins` (follows lldap naming convention) 1. Create group: `parking_admins`
2. Create group: `managers` (reusable across apps) 2. Add yourself (or whoever should be admin) to this group
3. Add yourself to `parking_admins`
## Step 7: Deploy **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 ```bash
cd ~/org-stack
# Build and start parking service # Build and start parking service
cd ~/org-parking
docker compose build parking docker compose build parking
docker compose up -d parking docker compose up -d
# Reload Caddy to pick up new domain # Reload Caddy to pick up new domain (from org-stack)
cd ~/org-stack
docker compose exec caddy caddy reload --config /etc/caddy/Caddyfile docker compose exec caddy caddy reload --config /etc/caddy/Caddyfile
# Check logs # Check logs
cd ~/org-parking
docker compose logs -f parking docker compose logs -f parking
``` ```
@@ -124,14 +167,53 @@ docker compose logs -f parking
## Troubleshooting ## 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 ### 401 Unauthorized
- Check Authelia headers are being passed - Check Authelia headers are being passed
- Check `docker compose logs authelia` - 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 ### User has wrong role
- Verify LLDAP group membership - **Admin role**: Verify user is in `parking_admins` LLDAP group (synced on each login)
- Roles sync 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 ### Database errors
- Check volume mount: `docker compose exec parking ls -la /app/data` - Check volume mount: `docker compose exec parking ls -la /app/data`
- Check permissions: `docker compose exec parking id` - 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

View File

@@ -1,141 +1,138 @@
/** /**
* Admin Users Page * Admin Users Page
* Manage all users in the system * Manage users with LDAP-aware editing
*/ */
let currentUser = null; let currentUser = null;
let users = []; let users = [];
let offices = []; let managers = [];
let managedOfficesMap = {}; // user_id -> [office_ids]
document.addEventListener('DOMContentLoaded', async () => { document.addEventListener('DOMContentLoaded', async () => {
if (!api.requireAuth()) return; currentUser = await api.requireAuth();
currentUser = await api.getCurrentUser();
if (!currentUser) return; if (!currentUser) return;
// Only admins can access
if (currentUser.role !== 'admin') { if (currentUser.role !== 'admin') {
window.location.href = '/presence'; window.location.href = '/presence';
return; return;
} }
await Promise.all([loadUsers(), loadOffices()]); await loadManagers();
await loadManagedOffices(); await loadUsers();
renderUsers();
setupEventListeners(); setupEventListeners();
}); });
async function loadManagers() {
const response = await api.get('/api/managers');
if (response && response.ok) {
managers = await response.json();
}
}
async function loadUsers() { async function loadUsers() {
const response = await api.get('/api/users'); const response = await api.get('/api/users');
if (response && response.ok) { if (response && response.ok) {
users = await response.json(); users = await response.json();
renderUsers();
} }
} }
async function loadOffices() {
const response = await api.get('/api/offices');
if (response && response.ok) {
offices = await response.json();
const select = document.getElementById('editOffice');
offices.forEach(office => {
const option = document.createElement('option');
option.value = office.id;
option.textContent = office.name;
select.appendChild(option);
});
}
}
async function loadManagedOffices() {
// Load managed offices for all managers
const response = await api.get('/api/offices/managers/list');
if (response && response.ok) {
const managers = await response.json();
managedOfficesMap = {};
managers.forEach(m => {
managedOfficesMap[m.id] = m.offices.map(o => o.id);
});
}
}
function getManagedOfficeNames(userId) {
const officeIds = managedOfficesMap[userId] || [];
if (officeIds.length === 0) return '-';
return officeIds.map(id => {
const office = offices.find(o => o.id === id);
return office ? office.name : id;
}).join(', ');
}
function renderUsers(filter = '') { function renderUsers(filter = '') {
const tbody = document.getElementById('usersBody'); const tbody = document.getElementById('usersBody');
const filtered = filter const filterLower = filter.toLowerCase();
? users.filter(u =>
u.name.toLowerCase().includes(filter.toLowerCase()) || let filtered = users;
u.email.toLowerCase().includes(filter.toLowerCase()) if (filter) {
) filtered = users.filter(u =>
: users; (u.name || '').toLowerCase().includes(filterLower) ||
(u.email || '').toLowerCase().includes(filterLower) ||
(u.role || '').toLowerCase().includes(filterLower) ||
(u.manager_name || '').toLowerCase().includes(filterLower)
);
}
if (filtered.length === 0) { if (filtered.length === 0) {
tbody.innerHTML = '<tr><td colspan="8" class="text-center">No users found</td></tr>'; tbody.innerHTML = '<tr><td colspan="5" class="text-center">No users found</td></tr>';
return; return;
} }
tbody.innerHTML = filtered.map(user => { tbody.innerHTML = filtered.map(user => {
const office = offices.find(o => o.id === user.office_id); const ldapBadge = user.is_ldap_user ? '<span class="badge badge-info">LDAP</span>' : '';
const isManager = user.role === 'manager'; const managerInfo = user.role === 'manager'
const managedOffices = isManager ? getManagedOfficeNames(user.id) : '-'; ? `<span class="badge badge-success">${user.managed_user_count || 0} users</span>`
: (user.manager_name || '-');
return ` return `
<tr> <tr>
<td>${user.name}</td> <td>${user.name || '-'} ${ldapBadge}</td>
<td>${user.email}</td> <td>${user.email}</td>
<td><span class="badge badge-${user.role}">${user.role}</span></td> <td><span class="badge badge-${getRoleBadgeClass(user.role)}">${user.role}</span></td>
<td>${office ? office.name : '-'}</td> <td>${managerInfo}</td>
<td>${managedOffices}</td>
<td>${isManager ? (user.manager_parking_quota || 0) : '-'}</td>
<td>${isManager ? (user.manager_spot_prefix || '-') : '-'}</td>
<td> <td>
<button class="btn btn-sm btn-secondary" onclick="editUser('${user.id}')">Edit</button> <button class="btn btn-sm btn-secondary" onclick="editUser('${user.id}')">Edit</button>
${user.id !== currentUser.id ? ` <button class="btn btn-sm btn-danger" onclick="deleteUser('${user.id}')" ${user.id === currentUser.id ? 'disabled' : ''}>Delete</button>
<button class="btn btn-sm btn-danger" onclick="deleteUser('${user.id}')">Delete</button>
` : ''}
</td> </td>
</tr> </tr>
`; `;
}).join(''); }).join('');
} }
function editUser(userId) { function getRoleBadgeClass(role) {
switch (role) {
case 'admin': return 'danger';
case 'manager': return 'warning';
default: return 'secondary';
}
}
async function editUser(userId) {
const user = users.find(u => u.id === userId); const user = users.find(u => u.id === userId);
if (!user) return; if (!user) return;
// Populate form
document.getElementById('userId').value = user.id; document.getElementById('userId').value = user.id;
document.getElementById('editName').value = user.name; document.getElementById('editName').value = user.name || '';
document.getElementById('editEmail').value = user.email; document.getElementById('editEmail').value = user.email;
document.getElementById('editRole').value = user.role; document.getElementById('editRole').value = user.role;
document.getElementById('editOffice').value = user.office_id || '';
document.getElementById('editQuota').value = user.manager_parking_quota || 0; document.getElementById('editQuota').value = user.manager_parking_quota || 0;
document.getElementById('editPrefix').value = user.manager_spot_prefix || ''; document.getElementById('editPrefix').value = user.manager_spot_prefix || '';
const isManager = user.role === 'manager'; // Populate manager dropdown
const managerSelect = document.getElementById('editManager');
managerSelect.innerHTML = '<option value="">No manager</option>';
managers.forEach(m => {
if (m.id !== userId) { // Can't be own manager
const option = document.createElement('option');
option.value = m.id;
option.textContent = m.name;
if (m.id === user.manager_id) option.selected = true;
managerSelect.appendChild(option);
}
});
// Show/hide manager fields based on role // Handle LDAP restrictions
document.getElementById('quotaGroup').style.display = isManager ? 'block' : 'none'; const isLdap = user.is_ldap_user;
document.getElementById('prefixGroup').style.display = isManager ? 'block' : 'none'; const isLdapAdmin = user.is_ldap_admin;
document.getElementById('managedOfficesGroup').style.display = isManager ? 'block' : 'none';
// Build managed offices checkboxes // LDAP notice
const checkboxContainer = document.getElementById('managedOfficesCheckboxes'); document.getElementById('ldapNotice').style.display = isLdap ? 'block' : 'none';
const userManagedOffices = managedOfficesMap[userId] || [];
checkboxContainer.innerHTML = offices.map(office => `
<label class="checkbox-label">
<input type="checkbox" name="managedOffice" value="${office.id}"
${userManagedOffices.includes(office.id) ? 'checked' : ''}>
${office.name}
</label>
`).join('');
// Name field - disabled for LDAP users
const nameInput = document.getElementById('editName');
nameInput.disabled = isLdap;
document.getElementById('nameHelp').style.display = isLdap ? 'block' : 'none';
// Role field - admin option disabled for LDAP admins (they can't be demoted)
const roleSelect = document.getElementById('editRole');
roleSelect.disabled = isLdapAdmin;
document.getElementById('roleHelp').style.display = isLdapAdmin ? 'block' : 'none';
// Manager group - show for all users (admins can also be assigned to a manager)
document.getElementById('managerGroup').style.display = 'block';
// Manager fields - show only for managers
document.getElementById('managerFields').style.display = user.role === 'manager' ? 'block' : 'none';
document.getElementById('userModalTitle').textContent = 'Edit User';
document.getElementById('userModal').style.display = 'flex'; document.getElementById('userModal').style.display = 'flex';
} }
@@ -143,14 +140,12 @@ async function deleteUser(userId) {
const user = users.find(u => u.id === userId); const user = users.find(u => u.id === userId);
if (!user) return; if (!user) return;
if (!confirm(`Delete user "${user.name}"? This cannot be undone.`)) return; if (!confirm(`Delete user "${user.name || user.email}"?`)) return;
const response = await api.delete(`/api/users/${userId}`); const response = await api.delete(`/api/users/${userId}`);
if (response && response.ok) { if (response && response.ok) {
await loadUsers();
await loadManagedOffices();
renderUsers();
utils.showMessage('User deleted', 'success'); utils.showMessage('User deleted', 'success');
await loadUsers();
} else { } else {
const error = await response.json(); const error = await response.json();
utils.showMessage(error.detail || 'Failed to delete user', 'error'); utils.showMessage(error.detail || 'Failed to delete user', 'error');
@@ -163,22 +158,21 @@ function setupEventListeners() {
renderUsers(e.target.value); renderUsers(e.target.value);
}); });
// Modal // Role change - toggle manager fields (manager group always visible since any user can have a manager)
document.getElementById('editRole').addEventListener('change', (e) => {
const role = e.target.value;
document.getElementById('managerFields').style.display = role === 'manager' ? 'block' : 'none';
// Manager group stays visible - any user (including admins) can have a manager assigned
});
// Modal close
document.getElementById('closeUserModal').addEventListener('click', () => { document.getElementById('closeUserModal').addEventListener('click', () => {
document.getElementById('userModal').style.display = 'none'; document.getElementById('userModal').style.display = 'none';
}); });
document.getElementById('cancelUser').addEventListener('click', () => { document.getElementById('cancelUser').addEventListener('click', () => {
document.getElementById('userModal').style.display = 'none'; document.getElementById('userModal').style.display = 'none';
}); });
utils.setupModalClose('userModal');
// Role change shows/hides manager fields
document.getElementById('editRole').addEventListener('change', (e) => {
const isManager = e.target.value === 'manager';
document.getElementById('quotaGroup').style.display = isManager ? 'block' : 'none';
document.getElementById('prefixGroup').style.display = isManager ? 'block' : 'none';
document.getElementById('managedOfficesGroup').style.display = isManager ? 'block' : 'none';
});
// Form submit // Form submit
document.getElementById('userForm').addEventListener('submit', async (e) => { document.getElementById('userForm').addEventListener('submit', async (e) => {
@@ -188,60 +182,35 @@ function setupEventListeners() {
const role = document.getElementById('editRole').value; const role = document.getElementById('editRole').value;
const data = { const data = {
name: document.getElementById('editName').value,
role: role, role: role,
office_id: document.getElementById('editOffice').value || null manager_id: document.getElementById('editManager').value || null
}; };
// Only include name if not disabled (LDAP users can't change name)
const nameInput = document.getElementById('editName');
if (!nameInput.disabled) {
data.name = nameInput.value;
}
// Manager-specific fields
if (role === 'manager') { if (role === 'manager') {
data.manager_parking_quota = parseInt(document.getElementById('editQuota').value) || 0; data.manager_parking_quota = parseInt(document.getElementById('editQuota').value) || 0;
data.manager_spot_prefix = document.getElementById('editPrefix').value.toUpperCase() || null; data.manager_spot_prefix = document.getElementById('editPrefix').value || null;
} }
const response = await api.put(`/api/users/${userId}`, data); const response = await api.put(`/api/users/${userId}`, data);
if (response && response.ok) { if (response && response.ok) {
// Update managed offices if manager
if (role === 'manager') {
await updateManagedOffices(userId);
}
document.getElementById('userModal').style.display = 'none'; document.getElementById('userModal').style.display = 'none';
await loadUsers();
await loadManagedOffices();
renderUsers();
utils.showMessage('User updated', 'success'); utils.showMessage('User updated', 'success');
await loadManagers(); // Reload in case role changed
await loadUsers();
} else { } else {
const error = await response.json(); const error = await response.json();
utils.showMessage(error.detail || 'Failed to update user', 'error'); utils.showMessage(error.detail || 'Failed to update user', 'error');
} }
}); });
utils.setupModalClose('userModal');
} }
async function updateManagedOffices(userId) { // Make functions available globally for onclick handlers
// Get currently selected offices
const checkboxes = document.querySelectorAll('input[name="managedOffice"]:checked');
const selectedOfficeIds = Array.from(checkboxes).map(cb => cb.value);
// Get current managed offices
const currentOfficeIds = managedOfficesMap[userId] || [];
// Find offices to add and remove
const toAdd = selectedOfficeIds.filter(id => !currentOfficeIds.includes(id));
const toRemove = currentOfficeIds.filter(id => !selectedOfficeIds.includes(id));
// Add new memberships
for (const officeId of toAdd) {
await api.post(`/api/offices/${officeId}/managers`, { user_id: userId });
}
// Remove old memberships
for (const officeId of toRemove) {
await api.delete(`/api/offices/${officeId}/managers/${userId}`);
}
}
// Make functions globally accessible
window.editUser = editUser; window.editUser = editUser;
window.deleteUser = deleteUser; window.deleteUser = deleteUser;

View File

@@ -26,21 +26,42 @@ const api = {
}, },
/** /**
* Check if user is authenticated * Check if user is authenticated (token or Authelia)
*/ */
isAuthenticated() { isAuthenticated() {
return !!this.getToken(); return !!this.getToken() || this._autheliaAuth;
},
/**
* Check authentication - works with both JWT and Authelia
* Call this on page load to verify auth status
*/
async checkAuth() {
// Try to get current user - works with Authelia headers or JWT
const response = await fetch('/api/auth/me', {
headers: this.getToken() ? { 'Authorization': `Bearer ${this.getToken()}` } : {}
});
if (response.ok) {
this._autheliaAuth = true;
return await response.json();
}
this._autheliaAuth = false;
return null;
}, },
/** /**
* Redirect to login if not authenticated * Redirect to login if not authenticated
* Returns user object if authenticated, null otherwise
*/ */
requireAuth() { async requireAuth() {
if (!this.isAuthenticated()) { const user = await this.checkAuth();
if (!user) {
window.location.href = '/login'; window.location.href = '/login';
return false; return null;
} }
return true; return user;
}, },
/** /**
@@ -143,11 +164,11 @@ const api = {
/** /**
* Register * Register
*/ */
async register(email, password, name, officeId = null) { async register(email, password, name) {
const response = await fetch('/api/auth/register', { const response = await fetch('/api/auth/register', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password, name, office_id: officeId }) body: JSON.stringify({ email, password, name })
}); });
if (response.ok) { if (response.ok) {

View File

@@ -43,8 +43,8 @@ const ICONS = {
const NAV_ITEMS = [ const NAV_ITEMS = [
{ href: '/presence', icon: 'calendar', label: 'My Presence' }, { href: '/presence', icon: 'calendar', label: 'My Presence' },
{ href: '/team-calendar', icon: 'users', label: 'Team Calendar', roles: ['admin', 'manager'] }, { href: '/team-calendar', icon: 'users', label: 'Team Calendar' },
{ href: '/office-rules', icon: 'rules', label: 'Office Rules', roles: ['admin', 'manager'] }, { href: '/team-rules', icon: 'rules', label: 'Team Rules', roles: ['admin', 'manager'] },
{ href: '/admin/users', icon: 'user', label: 'Manage Users', roles: ['admin'] } { href: '/admin/users', icon: 'user', label: 'Manage Users', roles: ['admin'] }
]; ];
@@ -77,26 +77,19 @@ async function initNav() {
if (!navContainer) return; if (!navContainer) return;
const currentPath = window.location.pathname; const currentPath = window.location.pathname;
let userRole = null;
let currentUser = null;
// Get user info // Get user info (works with both JWT and Authelia)
if (api.isAuthenticated()) { const currentUser = await api.checkAuth();
currentUser = await api.getCurrentUser();
if (currentUser) {
userRole = currentUser.role;
}
}
// Render navigation // Render navigation
navContainer.innerHTML = renderNav(currentPath, userRole); navContainer.innerHTML = renderNav(currentPath, currentUser?.role);
// Update user info in sidebar // Update user info in sidebar
if (currentUser) { if (currentUser) {
const userName = document.getElementById('userName'); const userNameEl = document.getElementById('userName');
const userRole = document.getElementById('userRole'); const userRoleEl = document.getElementById('userRole');
if (userName) userName.textContent = currentUser.name || 'User'; if (userNameEl) userNameEl.textContent = currentUser.name || 'User';
if (userRole) userRole.textContent = currentUser.role || '-'; if (userRoleEl) userRoleEl.textContent = currentUser.role || '-';
} }
// Setup user menu // Setup user menu

View File

@@ -10,9 +10,7 @@ let parkingData = {};
let currentAssignmentId = null; let currentAssignmentId = null;
document.addEventListener('DOMContentLoaded', async () => { document.addEventListener('DOMContentLoaded', async () => {
if (!api.requireAuth()) return; currentUser = await api.requireAuth();
currentUser = await api.getCurrentUser();
if (!currentUser) return; if (!currentUser) return;
await Promise.all([loadPresences(), loadParkingAssignments()]); await Promise.all([loadPresences(), loadParkingAssignments()]);

View File

@@ -16,17 +16,9 @@ let selectedDate = null;
let currentAssignmentId = null; let currentAssignmentId = null;
document.addEventListener('DOMContentLoaded', async () => { document.addEventListener('DOMContentLoaded', async () => {
if (!api.requireAuth()) return; currentUser = await api.requireAuth();
currentUser = await api.getCurrentUser();
if (!currentUser) return; if (!currentUser) return;
// Only managers and admins can view
if (currentUser.role === 'employee') {
window.location.href = '/presence';
return;
}
// Initialize start date based on week start preference // Initialize start date based on week start preference
const weekStartDay = currentUser.week_start_day || 0; const weekStartDay = currentUser.week_start_day || 0;
currentStartDate = utils.getWeekStart(new Date(), weekStartDay); currentStartDate = utils.getWeekStart(new Date(), weekStartDay);
@@ -38,7 +30,7 @@ document.addEventListener('DOMContentLoaded', async () => {
}); });
async function loadManagers() { async function loadManagers() {
const response = await api.get('/api/offices/managers/list'); const response = await api.get('/api/managers');
if (response && response.ok) { if (response && response.ok) {
managers = await response.json(); managers = await response.json();
const select = document.getElementById('managerFilter'); const select = document.getElementById('managerFilter');
@@ -48,20 +40,32 @@ async function loadManagers() {
if (currentUser.role === 'manager') { if (currentUser.role === 'manager') {
// Manager only sees themselves // Manager only sees themselves
filteredManagers = managers.filter(m => m.id === currentUser.id); filteredManagers = managers.filter(m => m.id === currentUser.id);
} else if (currentUser.role === 'employee') {
// Employee only sees their own manager
if (currentUser.manager_id) {
filteredManagers = managers.filter(m => m.id === currentUser.manager_id);
} else {
filteredManagers = [];
}
} }
filteredManagers.forEach(manager => { filteredManagers.forEach(manager => {
const option = document.createElement('option'); const option = document.createElement('option');
option.value = manager.id; option.value = manager.id;
const officeNames = manager.offices.map(o => o.name).join(', '); const userCount = manager.managed_user_count || 0;
option.textContent = `${manager.name} (${officeNames})`; option.textContent = `${manager.name} (${userCount} users)`;
select.appendChild(option); select.appendChild(option);
}); });
// Auto-select if only one manager (for manager role) // Auto-select for managers and employees (they only see their team)
if (filteredManagers.length === 1) { if (filteredManagers.length === 1) {
select.value = filteredManagers[0].id; select.value = filteredManagers[0].id;
} }
// Hide manager filter for employees (they can only see their team)
if (currentUser.role === 'employee') {
select.style.display = 'none';
}
} }
} }
@@ -129,7 +133,7 @@ function renderCalendar() {
// Build header row // Build header row
const dayNames = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; const dayNames = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
let headerHtml = '<th>Name</th><th>Office</th>'; let headerHtml = '<th>Name</th><th>Manager</th>';
for (let i = 0; i < dayCount; i++) { for (let i = 0; i < dayCount; i++) {
const date = new Date(startDate); const date = new Date(startDate);
@@ -161,7 +165,7 @@ function renderCalendar() {
teamData.forEach(member => { teamData.forEach(member => {
bodyHtml += `<tr> bodyHtml += `<tr>
<td class="member-name">${member.name || 'Unknown'}</td> <td class="member-name">${member.name || 'Unknown'}</td>
<td class="member-office">${member.office_name || '-'}</td>`; <td class="member-manager">${member.manager_name || '-'}</td>`;
for (let i = 0; i < dayCount; i++) { for (let i = 0; i < dayCount; i++) {
const date = new Date(startDate); const date = new Date(startDate);
@@ -197,16 +201,18 @@ function renderCalendar() {
}); });
body.innerHTML = bodyHtml; body.innerHTML = bodyHtml;
// Add click handlers to cells // Add click handlers to cells (only for admins and managers)
body.querySelectorAll('.calendar-cell').forEach(cell => { if (currentUser.role === 'admin' || currentUser.role === 'manager') {
cell.style.cursor = 'pointer'; body.querySelectorAll('.calendar-cell').forEach(cell => {
cell.addEventListener('click', () => { cell.style.cursor = 'pointer';
const userId = cell.dataset.userId; cell.addEventListener('click', () => {
const date = cell.dataset.date; const userId = cell.dataset.userId;
const userName = cell.dataset.userName; const date = cell.dataset.date;
openDayModal(userId, date, userName); const userName = cell.dataset.userName;
openDayModal(userId, date, userName);
});
}); });
}); }
} }
function openDayModal(userId, dateStr, userName) { function openDayModal(userId, dateStr, userName) {

View File

@@ -1,8 +1,8 @@
/** /**
* Office Rules Page * Team Rules Page
* Manage closing days, parking guarantees, and exclusions * Manage closing days, parking guarantees, and exclusions
* *
* Rules are set at manager level and apply to all offices managed by that manager. * Rules are set at manager level for their parking pool.
*/ */
let currentUser = null; let currentUser = null;
@@ -10,9 +10,7 @@ let selectedManagerId = null;
let managerUsers = []; let managerUsers = [];
document.addEventListener('DOMContentLoaded', async () => { document.addEventListener('DOMContentLoaded', async () => {
if (!api.requireAuth()) return; currentUser = await api.requireAuth();
currentUser = await api.getCurrentUser();
if (!currentUser) return; if (!currentUser) return;
// Only managers and admins can access // Only managers and admins can access
@@ -26,10 +24,10 @@ document.addEventListener('DOMContentLoaded', async () => {
}); });
async function loadManagers() { async function loadManagers() {
const response = await api.get('/api/offices/managers/list'); const response = await api.get('/api/managers');
if (response && response.ok) { if (response && response.ok) {
const managers = await response.json(); const managers = await response.json();
const select = document.getElementById('officeSelect'); const select = document.getElementById('managerSelect');
// Filter to managers this user can see // Filter to managers this user can see
let filteredManagers = managers; let filteredManagers = managers;
@@ -45,14 +43,10 @@ async function loadManagers() {
filteredManagers.forEach(manager => { filteredManagers.forEach(manager => {
const option = document.createElement('option'); const option = document.createElement('option');
option.value = manager.id; option.value = manager.id;
// Show manager name and count of offices // Show manager name with user count and parking quota
const officeCount = manager.offices.length; const userCount = manager.managed_user_count || 0;
const officeNames = manager.offices.map(o => o.name).join(', '); const quota = manager.parking_quota || 0;
if (officeCount > 0) { option.textContent = `${manager.name} (${userCount} users, ${quota} spots)`;
option.textContent = `${manager.name} (${officeNames})`;
} else {
option.textContent = `${manager.name} (no offices)`;
}
select.appendChild(option); select.appendChild(option);
totalManagers++; totalManagers++;
if (!firstManagerId) firstManagerId = manager.id; if (!firstManagerId) firstManagerId = manager.id;
@@ -71,12 +65,12 @@ async function selectManager(managerId) {
if (!managerId) { if (!managerId) {
document.getElementById('rulesContent').style.display = 'none'; document.getElementById('rulesContent').style.display = 'none';
document.getElementById('noOfficeMessage').style.display = 'block'; document.getElementById('noManagerMessage').style.display = 'block';
return; return;
} }
document.getElementById('rulesContent').style.display = 'block'; document.getElementById('rulesContent').style.display = 'block';
document.getElementById('noOfficeMessage').style.display = 'none'; document.getElementById('noManagerMessage').style.display = 'none';
await Promise.all([ await Promise.all([
loadWeeklyClosingDays(), loadWeeklyClosingDays(),
@@ -88,7 +82,7 @@ async function selectManager(managerId) {
} }
async function loadWeeklyClosingDays() { async function loadWeeklyClosingDays() {
const response = await api.get(`/api/offices/managers/${selectedManagerId}/weekly-closing-days`); const response = await api.get(`/api/managers/${selectedManagerId}/weekly-closing-days`);
if (response && response.ok) { if (response && response.ok) {
const days = await response.json(); const days = await response.json();
const weekdays = days.map(d => d.weekday); const weekdays = days.map(d => d.weekday);
@@ -102,7 +96,7 @@ async function loadWeeklyClosingDays() {
} }
async function loadManagerUsers() { async function loadManagerUsers() {
const response = await api.get(`/api/offices/managers/${selectedManagerId}/users`); const response = await api.get(`/api/managers/${selectedManagerId}/users`);
if (response && response.ok) { if (response && response.ok) {
managerUsers = await response.json(); managerUsers = await response.json();
updateUserSelects(); updateUserSelects();
@@ -123,7 +117,7 @@ function updateUserSelects() {
} }
async function loadClosingDays() { async function loadClosingDays() {
const response = await api.get(`/api/offices/managers/${selectedManagerId}/closing-days`); const response = await api.get(`/api/managers/${selectedManagerId}/closing-days`);
const container = document.getElementById('closingDaysList'); const container = document.getElementById('closingDaysList');
if (response && response.ok) { if (response && response.ok) {
@@ -158,7 +152,7 @@ function formatDateRange(startDate, endDate) {
} }
async function loadGuarantees() { async function loadGuarantees() {
const response = await api.get(`/api/offices/managers/${selectedManagerId}/guarantees`); const response = await api.get(`/api/managers/${selectedManagerId}/guarantees`);
const container = document.getElementById('guaranteesList'); const container = document.getElementById('guaranteesList');
if (response && response.ok) { if (response && response.ok) {
@@ -188,7 +182,7 @@ async function loadGuarantees() {
} }
async function loadExclusions() { async function loadExclusions() {
const response = await api.get(`/api/offices/managers/${selectedManagerId}/exclusions`); const response = await api.get(`/api/managers/${selectedManagerId}/exclusions`);
const container = document.getElementById('exclusionsList'); const container = document.getElementById('exclusionsList');
if (response && response.ok) { if (response && response.ok) {
@@ -220,7 +214,7 @@ async function loadExclusions() {
// Delete functions // Delete functions
async function deleteClosingDay(id) { async function deleteClosingDay(id) {
if (!confirm('Delete this closing day?')) return; if (!confirm('Delete this closing day?')) return;
const response = await api.delete(`/api/offices/managers/${selectedManagerId}/closing-days/${id}`); const response = await api.delete(`/api/managers/${selectedManagerId}/closing-days/${id}`);
if (response && response.ok) { if (response && response.ok) {
await loadClosingDays(); await loadClosingDays();
} }
@@ -228,7 +222,7 @@ async function deleteClosingDay(id) {
async function deleteGuarantee(id) { async function deleteGuarantee(id) {
if (!confirm('Remove this parking guarantee?')) return; if (!confirm('Remove this parking guarantee?')) return;
const response = await api.delete(`/api/offices/managers/${selectedManagerId}/guarantees/${id}`); const response = await api.delete(`/api/managers/${selectedManagerId}/guarantees/${id}`);
if (response && response.ok) { if (response && response.ok) {
await loadGuarantees(); await loadGuarantees();
} }
@@ -236,7 +230,7 @@ async function deleteGuarantee(id) {
async function deleteExclusion(id) { async function deleteExclusion(id) {
if (!confirm('Remove this parking exclusion?')) return; if (!confirm('Remove this parking exclusion?')) return;
const response = await api.delete(`/api/offices/managers/${selectedManagerId}/exclusions/${id}`); const response = await api.delete(`/api/managers/${selectedManagerId}/exclusions/${id}`);
if (response && response.ok) { if (response && response.ok) {
await loadExclusions(); await loadExclusions();
} }
@@ -244,7 +238,7 @@ async function deleteExclusion(id) {
function setupEventListeners() { function setupEventListeners() {
// Manager selection // Manager selection
document.getElementById('officeSelect').addEventListener('change', (e) => { document.getElementById('managerSelect').addEventListener('change', (e) => {
selectManager(e.target.value); selectManager(e.target.value);
}); });
@@ -255,7 +249,7 @@ function setupEventListeners() {
if (e.target.checked) { if (e.target.checked) {
// Add weekly closing day // Add weekly closing day
const response = await api.post(`/api/offices/managers/${selectedManagerId}/weekly-closing-days`, { weekday }); const response = await api.post(`/api/managers/${selectedManagerId}/weekly-closing-days`, { weekday });
if (!response || !response.ok) { if (!response || !response.ok) {
e.target.checked = false; e.target.checked = false;
const error = await response.json(); const error = await response.json();
@@ -263,12 +257,12 @@ function setupEventListeners() {
} }
} else { } else {
// Remove weekly closing day - need to find the ID first // Remove weekly closing day - need to find the ID first
const getResponse = await api.get(`/api/offices/managers/${selectedManagerId}/weekly-closing-days`); const getResponse = await api.get(`/api/managers/${selectedManagerId}/weekly-closing-days`);
if (getResponse && getResponse.ok) { if (getResponse && getResponse.ok) {
const days = await getResponse.json(); const days = await getResponse.json();
const day = days.find(d => d.weekday === weekday); const day = days.find(d => d.weekday === weekday);
if (day) { if (day) {
const deleteResponse = await api.delete(`/api/offices/managers/${selectedManagerId}/weekly-closing-days/${day.id}`); const deleteResponse = await api.delete(`/api/managers/${selectedManagerId}/weekly-closing-days/${day.id}`);
if (!deleteResponse || !deleteResponse.ok) { if (!deleteResponse || !deleteResponse.ok) {
e.target.checked = true; e.target.checked = true;
} }
@@ -320,7 +314,7 @@ function setupEventListeners() {
date: document.getElementById('closingDate').value, date: document.getElementById('closingDate').value,
reason: document.getElementById('closingReason').value || null reason: document.getElementById('closingReason').value || null
}; };
const response = await api.post(`/api/offices/managers/${selectedManagerId}/closing-days`, data); const response = await api.post(`/api/managers/${selectedManagerId}/closing-days`, data);
if (response && response.ok) { if (response && response.ok) {
document.getElementById('closingDayModal').style.display = 'none'; document.getElementById('closingDayModal').style.display = 'none';
await loadClosingDays(); await loadClosingDays();
@@ -337,7 +331,7 @@ function setupEventListeners() {
start_date: document.getElementById('guaranteeStartDate').value || null, start_date: document.getElementById('guaranteeStartDate').value || null,
end_date: document.getElementById('guaranteeEndDate').value || null end_date: document.getElementById('guaranteeEndDate').value || null
}; };
const response = await api.post(`/api/offices/managers/${selectedManagerId}/guarantees`, data); const response = await api.post(`/api/managers/${selectedManagerId}/guarantees`, data);
if (response && response.ok) { if (response && response.ok) {
document.getElementById('guaranteeModal').style.display = 'none'; document.getElementById('guaranteeModal').style.display = 'none';
await loadGuarantees(); await loadGuarantees();
@@ -354,7 +348,7 @@ function setupEventListeners() {
start_date: document.getElementById('exclusionStartDate').value || null, start_date: document.getElementById('exclusionStartDate').value || null,
end_date: document.getElementById('exclusionEndDate').value || null end_date: document.getElementById('exclusionEndDate').value || null
}; };
const response = await api.post(`/api/offices/managers/${selectedManagerId}/exclusions`, data); const response = await api.post(`/api/managers/${selectedManagerId}/exclusions`, data);
if (response && response.ok) { if (response && response.ok) {
document.getElementById('exclusionModal').style.display = 'none'; document.getElementById('exclusionModal').style.display = 'none';
await loadExclusions(); await loadExclusions();

View File

@@ -54,10 +54,7 @@
<th>Name</th> <th>Name</th>
<th>Email</th> <th>Email</th>
<th>Role</th> <th>Role</th>
<th>Office</th> <th>Manager</th>
<th>Managed Offices</th>
<th>Parking Quota</th>
<th>Spot Prefix</th>
<th>Actions</th> <th>Actions</th>
</tr> </tr>
</thead> </thead>
@@ -78,9 +75,16 @@
<div class="modal-body"> <div class="modal-body">
<form id="userForm"> <form id="userForm">
<input type="hidden" id="userId"> <input type="hidden" id="userId">
<!-- LDAP notice -->
<div id="ldapNotice" class="form-notice" style="display: none;">
<small>This user is managed by LDAP. Some fields cannot be edited.</small>
</div>
<div class="form-group"> <div class="form-group">
<label for="editName">Name</label> <label for="editName">Name</label>
<input type="text" id="editName" required> <input type="text" id="editName" required>
<small id="nameHelp" class="text-muted" style="display: none;">Managed by LDAP</small>
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="editEmail">Email</label> <label for="editEmail">Email</label>
@@ -93,28 +97,32 @@
<option value="manager">Manager</option> <option value="manager">Manager</option>
<option value="admin">Admin</option> <option value="admin">Admin</option>
</select> </select>
<small id="roleHelp" class="text-muted" style="display: none;">Admin role is managed by LDAP group</small>
</div> </div>
<div class="form-group"> <div class="form-group" id="managerGroup">
<label for="editOffice">Office</label> <label for="editManager">Manager</label>
<select id="editOffice"> <select id="editManager">
<option value="">No office</option> <option value="">No manager</option>
</select> </select>
<small class="text-muted">Who manages this user</small>
</div> </div>
<div class="form-group" id="quotaGroup" style="display: none;">
<label for="editQuota">Parking Quota</label> <!-- Manager-specific fields -->
<input type="number" id="editQuota" min="0" value="0"> <div id="managerFields" style="display: none;">
<small class="text-muted">Number of parking spots this manager can assign</small> <hr>
</div> <h4>Manager Settings</h4>
<div class="form-group" id="prefixGroup" style="display: none;"> <div class="form-group">
<label for="editPrefix">Spot Prefix</label> <label for="editQuota">Parking Quota</label>
<input type="text" id="editPrefix" maxlength="2" placeholder="A, B, C..."> <input type="number" id="editQuota" min="0" value="0">
<small class="text-muted">Letter prefix for parking spots (e.g., A for spots A1, A2...)</small> <small class="text-muted">Number of parking spots this manager controls</small>
</div> </div>
<div class="form-group" id="managedOfficesGroup" style="display: none;"> <div class="form-group">
<label>Managed Offices</label> <label for="editPrefix">Spot Prefix</label>
<div id="managedOfficesCheckboxes" class="checkbox-group"></div> <input type="text" id="editPrefix" maxlength="2" placeholder="A, B, C...">
<small class="text-muted">Select offices this manager controls</small> <small class="text-muted">Letter prefix for parking spots (e.g., A for spots A1, A2...)</small>
</div>
</div> </div>
<div class="form-actions"> <div class="form-actions">
<button type="button" class="btn btn-secondary" id="cancelUser">Cancel</button> <button type="button" class="btn btn-secondary" id="cancelUser">Cancel</button>
<button type="submit" class="btn btn-dark">Save</button> <button type="submit" class="btn btn-dark">Save</button>

View File

@@ -12,7 +12,7 @@
<div class="auth-card"> <div class="auth-card">
<div class="auth-header"> <div class="auth-header">
<h1>Parking Manager</h1> <h1>Parking Manager</h1>
<p>Manage office presence and parking assignments</p> <p>Manage team presence and parking assignments</p>
</div> </div>
<div style="display: flex; flex-direction: column; gap: 1rem;"> <div style="display: flex; flex-direction: column; gap: 1rem;">
@@ -23,10 +23,25 @@
</div> </div>
<script> <script>
// Redirect if already logged in // Redirect if already logged in (JWT token or Authelia)
if (localStorage.getItem('access_token')) { async function checkAndRedirect() {
window.location.href = '/presence'; // Check JWT token first
if (localStorage.getItem('access_token')) {
window.location.href = '/presence';
return;
}
// Check Authelia (backend will read headers)
try {
const response = await fetch('/api/auth/me');
if (response.ok) {
window.location.href = '/presence';
}
} catch (e) {
// Not authenticated, stay on landing
}
} }
checkAndRedirect();
</script> </script>
</body> </body>
</html> </html>

View File

@@ -48,10 +48,16 @@
<h3>Personal Information</h3> <h3>Personal Information</h3>
</div> </div>
<div class="card-body"> <div class="card-body">
<!-- LDAP Notice -->
<div id="ldapNotice" class="form-notice" style="display: none; margin-bottom: 1rem;">
<small>Your account is managed by LDAP. Some information cannot be changed here.</small>
</div>
<form id="profileForm"> <form id="profileForm">
<div class="form-group"> <div class="form-group">
<label for="name">Full Name</label> <label for="name">Full Name</label>
<input type="text" id="name" required> <input type="text" id="name" required>
<small id="nameHelp" class="text-muted" style="display: none;">Managed by LDAP</small>
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="email">Email</label> <label for="email">Email</label>
@@ -59,19 +65,24 @@
<small class="text-muted">Email cannot be changed</small> <small class="text-muted">Email cannot be changed</small>
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="office">Office</label> <label for="role">Role</label>
<select id="office"> <input type="text" id="role" disabled>
<option value="">No office</option> <small class="text-muted">Role is assigned by your administrator</small>
</select>
</div> </div>
<div class="form-actions"> <div class="form-group">
<label for="manager">Manager</label>
<input type="text" id="manager" disabled>
<small class="text-muted">Your manager is assigned by the administrator</small>
</div>
<div class="form-actions" id="profileActions">
<button type="submit" class="btn btn-dark">Save Changes</button> <button type="submit" class="btn btn-dark">Save Changes</button>
</div> </div>
</form> </form>
</div> </div>
</div> </div>
<div class="card"> <!-- Password section - hidden for LDAP users -->
<div class="card" id="passwordCard">
<div class="card-header"> <div class="card-header">
<h3>Change Password</h3> <h3>Change Password</h3>
</div> </div>
@@ -104,36 +115,37 @@
<script src="/js/nav.js"></script> <script src="/js/nav.js"></script>
<script> <script>
let currentUser = null; let currentUser = null;
let isLdapUser = false;
document.addEventListener('DOMContentLoaded', async () => { document.addEventListener('DOMContentLoaded', async () => {
if (!api.requireAuth()) return; currentUser = await api.requireAuth();
currentUser = await api.getCurrentUser();
if (!currentUser) return; if (!currentUser) return;
await loadOffices(); await loadProfile();
populateForm();
setupEventListeners(); setupEventListeners();
}); });
async function loadOffices() { async function loadProfile() {
const response = await api.get('/api/offices'); const response = await api.get('/api/users/me/profile');
if (response && response.ok) { if (response && response.ok) {
const offices = await response.json(); const profile = await response.json();
const select = document.getElementById('office'); isLdapUser = profile.is_ldap_user;
offices.forEach(office => {
const option = document.createElement('option');
option.value = office.id;
option.textContent = office.name;
select.appendChild(option);
});
}
}
function populateForm() { // Populate form
document.getElementById('name').value = currentUser.name || ''; document.getElementById('name').value = profile.name || '';
document.getElementById('email').value = currentUser.email; document.getElementById('email').value = profile.email;
document.getElementById('office').value = currentUser.office_id || ''; document.getElementById('role').value = profile.role;
document.getElementById('manager').value = profile.manager_name || 'None';
// LDAP mode adjustments
if (isLdapUser) {
document.getElementById('ldapNotice').style.display = 'block';
document.getElementById('name').disabled = true;
document.getElementById('nameHelp').style.display = 'block';
document.getElementById('profileActions').style.display = 'none';
document.getElementById('passwordCard').style.display = 'none';
}
}
} }
function setupEventListeners() { function setupEventListeners() {
@@ -141,15 +153,21 @@
document.getElementById('profileForm').addEventListener('submit', async (e) => { document.getElementById('profileForm').addEventListener('submit', async (e) => {
e.preventDefault(); e.preventDefault();
if (isLdapUser) {
utils.showMessage('Profile is managed by LDAP', 'error');
return;
}
const data = { const data = {
name: document.getElementById('name').value, name: document.getElementById('name').value
office_id: document.getElementById('office').value || null
}; };
const response = await api.put('/api/users/me/profile', data); const response = await api.put('/api/users/me/profile', data);
if (response && response.ok) { if (response && response.ok) {
utils.showMessage('Profile updated successfully', 'success'); utils.showMessage('Profile updated successfully', 'success');
currentUser = await api.getCurrentUser(); // Update nav display
const nameEl = document.getElementById('userName');
if (nameEl) nameEl.textContent = data.name;
} else { } else {
const error = await response.json(); const error = await response.json();
utils.showMessage(error.detail || 'Failed to update profile', 'error'); utils.showMessage(error.detail || 'Failed to update profile', 'error');

View File

@@ -31,12 +31,6 @@
<input type="password" id="password" required autocomplete="new-password" minlength="8"> <input type="password" id="password" required autocomplete="new-password" minlength="8">
<small>Minimum 8 characters</small> <small>Minimum 8 characters</small>
</div> </div>
<div class="form-group">
<label for="office">Office (optional)</label>
<select id="office">
<option value="">Select an office...</option>
</select>
</div>
<button type="submit" class="btn btn-dark btn-full">Create Account</button> <button type="submit" class="btn btn-dark btn-full">Create Account</button>
</form> </form>
@@ -53,39 +47,17 @@
window.location.href = '/presence'; window.location.href = '/presence';
} }
// Load offices
async function loadOffices() {
try {
const response = await fetch('/api/offices');
if (response.ok) {
const offices = await response.json();
const select = document.getElementById('office');
offices.forEach(office => {
const option = document.createElement('option');
option.value = office.id;
option.textContent = office.name;
select.appendChild(option);
});
}
} catch (error) {
console.error('Failed to load offices:', error);
}
}
loadOffices();
document.getElementById('registerForm').addEventListener('submit', async (e) => { document.getElementById('registerForm').addEventListener('submit', async (e) => {
e.preventDefault(); e.preventDefault();
const name = document.getElementById('name').value; const name = document.getElementById('name').value;
const email = document.getElementById('email').value; const email = document.getElementById('email').value;
const password = document.getElementById('password').value; const password = document.getElementById('password').value;
const officeId = document.getElementById('office').value || null;
const errorDiv = document.getElementById('errorMessage'); const errorDiv = document.getElementById('errorMessage');
errorDiv.innerHTML = ''; errorDiv.innerHTML = '';
const result = await api.register(email, password, name, officeId); const result = await api.register(email, password, name);
if (result.success) { if (result.success) {
window.location.href = '/presence'; window.location.href = '/presence';

View File

@@ -3,7 +3,7 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Office Rules - Parking Manager</title> <title>Team Rules - Parking Manager</title>
<link rel="icon" type="image/svg+xml" href="/favicon.svg"> <link rel="icon" type="image/svg+xml" href="/favicon.svg">
<link rel="stylesheet" href="/css/styles.css"> <link rel="stylesheet" href="/css/styles.css">
</head> </head>
@@ -39,9 +39,9 @@
<main class="main-content"> <main class="main-content">
<header class="page-header"> <header class="page-header">
<h2>Office Rules</h2> <h2>Team Rules</h2>
<div class="header-actions"> <div class="header-actions">
<select id="officeSelect" class="form-select"> <select id="managerSelect" class="form-select">
<option value="">Select Manager</option> <option value="">Select Manager</option>
</select> </select>
</div> </div>
@@ -54,7 +54,7 @@
<h3>Weekly Closing Days</h3> <h3>Weekly Closing Days</h3>
</div> </div>
<div class="card-body"> <div class="card-body">
<p class="text-muted" style="margin-bottom: 1rem;">Days of the week the office is regularly closed</p> <p class="text-muted" style="margin-bottom: 1rem;">Days of the week parking is unavailable</p>
<div class="weekday-checkboxes" id="weeklyClosingDays"> <div class="weekday-checkboxes" id="weeklyClosingDays">
<label><input type="checkbox" data-weekday="0"> Sunday</label> <label><input type="checkbox" data-weekday="0"> Sunday</label>
<label><input type="checkbox" data-weekday="1"> Monday</label> <label><input type="checkbox" data-weekday="1"> Monday</label>
@@ -74,7 +74,7 @@
<button class="btn btn-secondary btn-sm" id="addClosingDayBtn">Add</button> <button class="btn btn-secondary btn-sm" id="addClosingDayBtn">Add</button>
</div> </div>
<div class="card-body"> <div class="card-body">
<p class="text-muted">Specific dates when the office is closed (holidays, etc.)</p> <p class="text-muted">Specific dates when parking is unavailable (holidays, etc.)</p>
<div id="closingDaysList" class="rules-list"></div> <div id="closingDaysList" class="rules-list"></div>
</div> </div>
</div> </div>
@@ -104,10 +104,10 @@
</div> </div>
</div> </div>
<div class="content-wrapper" id="noOfficeMessage"> <div class="content-wrapper" id="noManagerMessage">
<div class="card"> <div class="card">
<div class="card-body text-center"> <div class="card-body text-center">
<p>Select a manager to manage their office rules</p> <p>Select a manager to manage their parking rules</p>
</div> </div>
</div> </div>
</div> </div>
@@ -210,6 +210,6 @@
<script src="/js/api.js"></script> <script src="/js/api.js"></script>
<script src="/js/utils.js"></script> <script src="/js/utils.js"></script>
<script src="/js/nav.js"></script> <script src="/js/nav.js"></script>
<script src="/js/office-rules.js"></script> <script src="/js/team-rules.js"></script>
</body> </body>
</html> </html>

10
main.py
View File

@@ -11,7 +11,6 @@ from contextlib import asynccontextmanager
from app import config from app import config
from app.routes.auth import router as auth_router from app.routes.auth import router as auth_router
from app.routes.users import router as users_router from app.routes.users import router as users_router
from app.routes.offices import router as offices_router
from app.routes.managers import router as managers_router from app.routes.managers import router as managers_router
from app.routes.presence import router as presence_router from app.routes.presence import router as presence_router
from app.routes.parking import router as parking_router from app.routes.parking import router as parking_router
@@ -39,7 +38,6 @@ app.add_middleware(
# API Routes # API Routes
app.include_router(auth_router) app.include_router(auth_router)
app.include_router(users_router) app.include_router(users_router)
app.include_router(offices_router)
app.include_router(managers_router) app.include_router(managers_router)
app.include_router(presence_router) app.include_router(presence_router)
app.include_router(parking_router) app.include_router(parking_router)
@@ -86,10 +84,10 @@ async def team_calendar_page():
return FileResponse(config.FRONTEND_DIR / "pages" / "team-calendar.html") return FileResponse(config.FRONTEND_DIR / "pages" / "team-calendar.html")
@app.get("/office-rules") @app.get("/team-rules")
async def office_rules_page(): async def team_rules_page():
"""Office Rules page""" """Team Rules page"""
return FileResponse(config.FRONTEND_DIR / "pages" / "office-rules.html") return FileResponse(config.FRONTEND_DIR / "pages" / "team-rules.html")
@app.get("/admin/users") @app.get("/admin/users")

View File

@@ -61,14 +61,14 @@ def authenticate_user(db: Session, email: str, password: str) -> User | None:
return user return user
def create_user(db: Session, email: str, password: str, name: str, office_id: str = None, role: str = "employee") -> User: def create_user(db: Session, email: str, password: str, name: str, manager_id: str = None, role: str = "employee") -> User:
"""Create a new user""" """Create a new user"""
user = User( user = User(
id=str(uuid.uuid4()), id=str(uuid.uuid4()),
email=email, email=email,
password_hash=hash_password(password), password_hash=hash_password(password),
name=name, name=name,
office_id=office_id, manager_id=manager_id,
role=role, role=role,
created_at=datetime.utcnow().isoformat(), created_at=datetime.utcnow().isoformat(),
updated_at=datetime.utcnow().isoformat() updated_at=datetime.utcnow().isoformat()

View File

@@ -21,7 +21,7 @@ import uuid
from database.models import ( from database.models import (
User, UserPresence, DailyParkingAssignment, User, UserPresence, DailyParkingAssignment,
NotificationLog, NotificationQueue, OfficeMembership NotificationLog, NotificationQueue
) )
from services.parking import get_spot_display_name from services.parking import get_spot_display_name

View File

@@ -6,31 +6,19 @@ Key concepts:
- Managers own parking spots (defined by manager_parking_quota) - Managers own parking spots (defined by manager_parking_quota)
- Each manager has a spot prefix (A, B, C...) for display names - Each manager has a spot prefix (A, B, C...) for display names
- Spots are named like A1, A2, B1, B2 based on manager prefix - Spots are named like A1, A2, B1, B2 based on manager prefix
- Fairness: users with lowest parking_days/office_days ratio get priority - Fairness: users with lowest parking_days/presence_days ratio get priority
""" """
import uuid import uuid
from datetime import datetime, timezone from datetime import datetime, timezone
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from sqlalchemy import func from sqlalchemy import func, or_
from database.models import ( from database.models import (
DailyParkingAssignment, User, OfficeMembership, UserPresence, DailyParkingAssignment, User, UserPresence,
ParkingGuarantee, ParkingExclusion, ManagerClosingDay, ManagerWeeklyClosingDay ParkingGuarantee, ParkingExclusion, ManagerClosingDay, ManagerWeeklyClosingDay
) )
def get_manager_for_office(office_id: str, db: Session) -> User | None:
"""Find the manager responsible for an office"""
membership = db.query(OfficeMembership).filter(
OfficeMembership.office_id == office_id
).first()
if not membership:
return None
return db.query(User).filter(User.id == membership.user_id).first()
def get_spot_prefix(manager: User, db: Session) -> str: def get_spot_prefix(manager: User, db: Session) -> str:
"""Get the spot prefix for a manager (from manager_spot_prefix or auto-assign)""" """Get the spot prefix for a manager (from manager_spot_prefix or auto-assign)"""
if manager.manager_spot_prefix: if manager.manager_spot_prefix:
@@ -136,23 +124,16 @@ def initialize_parking_pool(manager_id: str, quota: int, date: str, db: Session)
def get_user_parking_ratio(user_id: str, manager_id: str, db: Session) -> float: def get_user_parking_ratio(user_id: str, manager_id: str, db: Session) -> float:
""" """
Calculate user's parking ratio: parking_days / office_days Calculate user's parking ratio: parking_days / presence_days
Lower ratio = higher priority for next parking spot Lower ratio = higher priority for next parking spot
""" """
# Get offices managed by this manager # Count days user was present
managed_office_ids = [ presence_days = db.query(UserPresence).filter(
m.office_id for m in db.query(OfficeMembership).filter(
OfficeMembership.user_id == manager_id
).all()
]
# Count days user was present (office_days)
office_days = db.query(UserPresence).filter(
UserPresence.user_id == user_id, UserPresence.user_id == user_id,
UserPresence.status == "present" UserPresence.status == "present"
).count() ).count()
if office_days == 0: if presence_days == 0:
return 0.0 # New user, highest priority return 0.0 # New user, highest priority
# Count days user got parking # Count days user got parking
@@ -161,7 +142,7 @@ def get_user_parking_ratio(user_id: str, manager_id: str, db: Session) -> float:
DailyParkingAssignment.manager_id == manager_id DailyParkingAssignment.manager_id == manager_id
).count() ).count()
return parking_days / office_days return parking_days / presence_days
def is_user_excluded(user_id: str, manager_id: str, date: str, db: Session) -> bool: def is_user_excluded(user_id: str, manager_id: str, date: str, db: Session) -> bool:
@@ -206,19 +187,16 @@ def get_users_wanting_parking(manager_id: str, date: str, db: Session) -> list[d
""" """
Get all users who want parking for this date, sorted by fairness priority. Get all users who want parking for this date, sorted by fairness priority.
Returns list of {user_id, has_guarantee, ratio} Returns list of {user_id, has_guarantee, ratio}
"""
# Get offices managed by this manager
managed_office_ids = [
m.office_id for m in db.query(OfficeMembership).filter(
OfficeMembership.user_id == manager_id
).all()
]
# Get users who marked "present" for this date and belong to managed offices Note: Manager is part of their own team and can get parking from their pool.
"""
# Get users who marked "present" for this date:
# - Users managed by this manager (User.manager_id == manager_id)
# - The manager themselves (User.id == manager_id)
present_users = db.query(UserPresence).join(User).filter( present_users = db.query(UserPresence).join(User).filter(
UserPresence.date == date, UserPresence.date == date,
UserPresence.status == "present", UserPresence.status == "present",
User.office_id.in_(managed_office_ids) or_(User.manager_id == manager_id, User.id == manager_id)
).all() ).all()
candidates = [] candidates = []
@@ -316,18 +294,19 @@ def release_user_spot(manager_id: str, user_id: str, date: str, db: Session) ->
return True return True
def handle_presence_change(user_id: str, date: str, old_status: str, new_status: str, office_id: str, db: Session): def handle_presence_change(user_id: str, date: str, old_status: str, new_status: str, manager_id: str, db: Session):
""" """
Handle presence status change and update parking accordingly. Handle presence status change and update parking accordingly.
Uses fairness algorithm for assignment. Uses fairness algorithm for assignment.
manager_id is the user's manager (from User.manager_id).
""" """
# Don't process past dates # Don't process past dates
target_date = datetime.strptime(date, "%Y-%m-%d").date() target_date = datetime.strptime(date, "%Y-%m-%d").date()
if target_date < datetime.now().date(): if target_date < datetime.now().date():
return return
# Find manager for this office # Get manager
manager = get_manager_for_office(office_id, db) manager = db.query(User).filter(User.id == manager_id, User.role == "manager").first()
if not manager or not manager.manager_parking_quota: if not manager or not manager.manager_parking_quota:
return return

View File

@@ -16,13 +16,9 @@ from app import config
security = HTTPBearer(auto_error=False) security = HTTPBearer(auto_error=False)
def get_role_from_groups(groups: list[str]) -> str: def is_admin_from_groups(groups: list[str]) -> bool:
"""Map Authelia groups to application roles""" """Check if user is admin based on Authelia groups"""
if config.AUTHELIA_ADMIN_GROUP in groups: return config.AUTHELIA_ADMIN_GROUP in groups
return "admin"
if config.AUTHELIA_MANAGER_GROUP in groups:
return "manager"
return "employee"
def get_or_create_authelia_user( def get_or_create_authelia_user(
@@ -31,14 +27,28 @@ def get_or_create_authelia_user(
groups: list[str], groups: list[str],
db: Session db: Session
) -> User: ) -> User:
"""Get existing user or create from Authelia headers""" """
Get existing user or create from Authelia headers.
Role logic:
- If user is in parking_admins group -> admin role (always synced from LLDAP)
- Otherwise -> keep existing role, or 'employee' for new users
Admin assigns manager role and user assignments via the UI.
"""
user = get_user_by_email(db, email) user = get_user_by_email(db, email)
role = get_role_from_groups(groups) is_admin = is_admin_from_groups(groups)
if user: if user:
# Update role if changed in LLDAP # Only sync admin status from LLDAP, other roles managed by app admin
if user.role != role: if is_admin and user.role != "admin":
user.role = role user.role = "admin"
user.updated_at = datetime.utcnow().isoformat()
db.commit()
db.refresh(user)
elif not is_admin and user.role == "admin":
# Removed from parking_admins group -> demote to employee
user.role = "employee"
user.updated_at = datetime.utcnow().isoformat() user.updated_at = datetime.utcnow().isoformat()
db.commit() db.commit()
db.refresh(user) db.refresh(user)
@@ -55,7 +65,7 @@ def get_or_create_authelia_user(
id=str(uuid.uuid4()), id=str(uuid.uuid4()),
email=email, email=email,
name=name or email.split("@")[0], name=name or email.split("@")[0],
role=role, role="admin" if is_admin else "employee",
password_hash=None, # No password for Authelia users password_hash=None, # No password for Authelia users
created_at=datetime.utcnow().isoformat(), created_at=datetime.utcnow().isoformat(),
updated_at=datetime.utcnow().isoformat() updated_at=datetime.utcnow().isoformat()
@@ -82,6 +92,8 @@ def get_current_user(
remote_name = request.headers.get(config.AUTHELIA_HEADER_NAME, "") remote_name = request.headers.get(config.AUTHELIA_HEADER_NAME, "")
remote_groups = request.headers.get(config.AUTHELIA_HEADER_GROUPS, "") remote_groups = request.headers.get(config.AUTHELIA_HEADER_GROUPS, "")
print(f"[Authelia] Headers: user={remote_user}, email={remote_email}, name={remote_name}, groups={remote_groups}")
if not remote_user: if not remote_user:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, status_code=status.HTTP_401_UNAUTHORIZED,
@@ -149,18 +161,17 @@ def require_manager_or_admin(user=Depends(get_current_user)):
def check_manager_access_to_user(current_user, target_user, db: Session) -> bool: def check_manager_access_to_user(current_user, target_user, db: Session) -> bool:
""" """
Check if current_user (manager) has access to target_user. Check if current_user (manager) has access to target_user.
Admins always have access. Managers can only access users in their managed offices. Admins always have access. Managers can only access users they manage.
Returns True if access granted, raises HTTPException if not. Returns True if access granted, raises HTTPException if not.
""" """
if current_user.role == "admin": if current_user.role == "admin":
return True return True
if current_user.role == "manager": if current_user.role == "manager":
managed_office_ids = [m.office_id for m in current_user.managed_offices] if target_user.manager_id != current_user.id:
if target_user.office_id not in managed_office_ids:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, status_code=status.HTTP_403_FORBIDDEN,
detail="User not in your managed offices" detail="User is not managed by you"
) )
return True return True