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
│ │ ├── auth.py # Authentication + holidays
│ │ ├── users.py # User management
│ │ ├── offices.py # Office CRUD
│ │ ├── managers.py # Manager rules (closing days, guarantees)
│ │ ├── presence.py # Presence marking
│ │ └── parking.py # Parking assignments
@@ -116,8 +115,8 @@ Group mapping (follows lldap naming convention):
| Role | Permissions |
|------|-------------|
| **admin** | Full access, manage users/offices |
| **manager** | Manage assigned offices, set rules |
| **admin** | Full access, manage users and managers |
| **manager** | Manage their team, set parking rules |
| **employee** | Mark own presence, view calendar |
## API Endpoints
@@ -137,12 +136,6 @@ Group mapping (follows lldap naming convention):
- `GET /api/users/me/profile` - Own profile
- `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
- `GET /api/managers` - List managers
- `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:
```
ratio = parking_days / office_days
ratio = parking_days / presence_days
```
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_EMAIL = os.getenv("AUTHELIA_HEADER_EMAIL", "Remote-Email")
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_MANAGER_GROUP = os.getenv("AUTHELIA_MANAGER_GROUP", "managers")
# Email (optional)
SMTP_HOST = os.getenv("SMTP_HOST", "")

View File

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

View File

@@ -2,8 +2,8 @@
Manager Rules Routes
Manager settings, closing days, guarantees, and exclusions
Key concept: Managers own parking spots and set rules for all their managed offices.
Rules are set at manager level, not office level.
Key concept: Managers own parking spots and set rules for their managed users.
Rules are set at manager level (users have manager_id pointing to their manager).
"""
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException
@@ -13,7 +13,7 @@ import uuid
from database.connection import get_db
from database.models import (
Office, User, OfficeMembership,
User,
ManagerClosingDay, ManagerWeeklyClosingDay,
ParkingGuarantee, ParkingExclusion
)
@@ -52,14 +52,12 @@ class ManagerSettingsUpdate(BaseModel):
# Manager listing and details
@router.get("")
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()
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 []
managed_user_count = db.query(User).filter(User.manager_id == manager.id).count()
result.append({
"id": manager.id,
@@ -67,7 +65,7 @@ def list_managers(db: Session = Depends(get_db), user=Depends(require_manager_or
"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]
"managed_user_count": managed_user_count
})
return result
@@ -75,14 +73,12 @@ def list_managers(db: Session = Depends(get_db), user=Depends(require_manager_or
@router.get("/{manager_id}")
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()
if not manager:
raise HTTPException(status_code=404, detail="Manager not found")
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 []
managed_user_count = db.query(User).filter(User.manager_id == manager_id).count()
return {
"id": manager.id,
@@ -90,7 +86,7 @@ def get_manager_details(manager_id: str, db: Session = Depends(get_db), user=Dep
"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]
"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")
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()
if not manager:
raise HTTPException(status_code=404, detail="Manager not found")
memberships = db.query(OfficeMembership).filter(OfficeMembership.user_id == manager_id).all()
managed_office_ids = [m.office_id for m in memberships]
if not managed_office_ids:
return []
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]
# Include users managed by this manager + the manager themselves
users = db.query(User).filter(
(User.manager_id == manager_id) | (User.id == manager_id)
).all()
return [{"id": u.id, "name": u.name, "email": u.email, "role": u.role} for u in users]
# 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
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 services.parking import initialize_parking_pool, get_spot_display_name
from services.notifications import queue_parking_change_notification
@@ -49,7 +49,6 @@ class AssignmentResponse(BaseModel):
manager_id: str
user_name: str | None = None
user_email: str | None = None
user_office_id: str | None = None
# Routes
@@ -102,7 +101,6 @@ def get_assignments(date: str, manager_id: str = None, db: Session = Depends(get
if user:
result.user_name = user.name
result.user_email = user.email
result.user_office_id = user.office_id
results.append(result)
@@ -307,7 +305,7 @@ def reassign_spot(data: ReassignSpotRequest, db: Session = Depends(get_db), curr
@router.get("/eligible-users/{assignment_id}")
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.
Returns users in the same manager's offices.
Returns users managed by the same manager.
"""
assignment = db.query(DailyParkingAssignment).filter(
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):
raise HTTPException(status_code=403, detail="Not authorized")
# Get all users belonging to offices managed by this manager
# 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
# Get users in this manager's team (including the manager themselves)
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
).all()
@@ -351,8 +342,7 @@ def get_eligible_users(assignment_id: str, db: Session = Depends(get_db), curren
result.append({
"id": user.id,
"name": user.name,
"email": user.email,
"office_id": user.office_id
"email": user.email
})
return result

View File

@@ -10,8 +10,8 @@ from sqlalchemy.orm import Session
import uuid
from database.connection import get_db
from database.models import UserPresence, User, DailyParkingAssignment, OfficeMembership, Office
from utils.auth_middleware import get_current_user, require_manager_or_admin, check_manager_access_to_user
from database.models import UserPresence, User, DailyParkingAssignment
from utils.auth_middleware import get_current_user, require_manager_or_admin
from services.parking import handle_presence_change, get_spot_display_name
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")
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(
user_id: str,
date: str,
@@ -111,12 +125,18 @@ def _mark_presence_for_user(
db.refresh(presence)
# 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:
handle_presence_change(
user_id, date,
old_status or "absent", status,
target_user.office_id, db
parking_manager_id, db
)
except Exception as e:
print(f"Warning: Parking handler failed: {e}")
@@ -177,12 +197,17 @@ def _bulk_mark_presence(
results.append(presence)
# 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:
handle_presence_change(
user_id, date_str,
old_status or "absent", status,
target_user.office_id, db
parking_manager_id, db
)
except Exception:
pass
@@ -216,12 +241,17 @@ def _delete_presence(
db.delete(presence)
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:
handle_presence_change(
user_id, date,
old_status, "absent",
target_user.office_id, db
parking_manager_id, db
)
except Exception:
pass
@@ -274,7 +304,7 @@ def admin_mark_presence(data: AdminPresenceMarkRequest, db: Session = Depends(ge
if not target_user:
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)
@@ -285,7 +315,7 @@ def admin_mark_bulk_presence(data: AdminBulkPresenceRequest, db: Session = Depen
if not target_user:
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(
data.user_id, data.start_date, data.end_date,
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:
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)
@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)):
"""Get team presences with parking info for managers/admins, filtered by manager"""
def get_team_presences(start_date: str, end_date: str, manager_id: str = None, db: Session = Depends(get_db), current_user=Depends(get_current_user)):
"""Get team presences with parking info, filtered by manager.
- Admins can see all teams
- Managers see their own team
- Employees can only see their own team (read-only view)
"""
parse_date(start_date)
parse_date(end_date)
# Get users based on permissions and manager filter
if manager_id:
# Filter by specific manager's offices
managed_office_ids = [m.office_id for m in db.query(OfficeMembership).filter(
OfficeMembership.user_id == manager_id
).all()]
if not managed_office_ids:
return []
users = db.query(User).filter(User.office_id.in_(managed_office_ids)).all()
# Note: Manager is part of their own team (for parking assignment purposes)
if current_user.role == "employee":
# Employees can only see their own team (users with same manager_id + the manager)
if not current_user.manager_id:
return [] # No manager assigned, no team to show
users = db.query(User).filter(
(User.manager_id == current_user.manager_id) | (User.id == current_user.manager_id)
).all()
elif manager_id:
# Filter by specific manager (for admins/managers) - include the manager themselves
users = db.query(User).filter(
(User.manager_id == manager_id) | (User.id == manager_id)
).all()
elif current_user.role == "admin":
# Admin sees all users
users = db.query(User).all()
else:
# Manager sees only users in their managed offices
managed_ids = [m.office_id for m in current_user.managed_offices]
if not managed_ids:
return []
users = db.query(User).filter(User.office_id.in_(managed_ids)).all()
# Manager sees their team + themselves
users = db.query(User).filter(
(User.manager_id == current_user.id) | (User.id == current_user.id)
).all()
# Batch query presences and parking for all 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
})
# Build office and managed offices lookups
offices_lookup = {o.id: o.name for o in db.query(Office).all()}
managed_offices_lookup = {}
for m in db.query(OfficeMembership).all():
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 manager lookup for display
manager_ids = list(set(u.manager_id for u in users if u.manager_id))
managers = db.query(User).filter(User.id.in_(manager_ids)).all() if manager_ids else []
manager_lookup = {m.id: m.name for m in managers}
# Build response
result = []
for user in users:
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({
"id": user.id,
"name": user.name,
"office_id": user.office_id,
"office_name": office_display,
"manager_id": user.manager_id,
"manager_name": manager_lookup.get(user.manager_id),
"presences": [{"date": p.date, "status": p.status} for p in user_presences],
"parking_dates": parking_lookup.get(user.id, []),
"parking_info": parking_info_lookup.get(user.id, [])
@@ -397,7 +426,7 @@ def get_user_presences(user_id: str, start_date: str = None, end_date: str = Non
if not target_user:
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)

View File

@@ -11,9 +11,10 @@ import uuid
import re
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 services.auth import hash_password, verify_password
from app import config
router = APIRouter(prefix="/api/users", tags=["users"])
@@ -24,21 +25,19 @@ class UserCreate(BaseModel):
password: str
name: str | None = None
role: str = "employee"
office_id: str | None = None
manager_id: str | None = None
class UserUpdate(BaseModel):
email: EmailStr | None = None
name: str | None = None
role: str | None = None
office_id: str | None = None
manager_id: str | None = None
manager_parking_quota: int | None = None
manager_spot_prefix: str | None = None
class ProfileUpdate(BaseModel):
name: str | None = None
office_id: str | None = None
class SettingsUpdate(BaseModel):
@@ -61,44 +60,86 @@ class UserResponse(BaseModel):
email: str
name: str | None
role: str
office_id: str | None
manager_id: str | None = None
manager_name: str | None = None
manager_parking_quota: int | None = None
manager_spot_prefix: str | None = None
managed_user_count: int | None = None
is_ldap_user: bool = False
is_ldap_admin: bool = False
created_at: str | None
class Config:
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
@router.get("", response_model=List[UserResponse])
@router.get("")
def list_users(db: Session = Depends(get_db), user=Depends(require_admin)):
"""List all users (admin only)"""
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)):
"""Get user by ID (admin only)"""
target = db.query(User).filter(User.id == user_id).first()
if not target:
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)):
"""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():
raise HTTPException(status_code=400, detail="Email already registered")
if data.role not in ["admin", "manager", "employee"]:
raise HTTPException(status_code=400, detail="Invalid role")
if data.office_id:
if not db.query(Office).filter(Office.id == data.office_id).first():
raise HTTPException(status_code=404, detail="Office not found")
if data.manager_id:
manager = db.query(User).filter(User.id == data.manager_id).first()
if not manager or manager.role != "manager":
raise HTTPException(status_code=400, detail="Invalid manager")
new_user = User(
id=str(uuid.uuid4()),
@@ -106,42 +147,61 @@ def create_user(data: UserCreate, db: Session = Depends(get_db), user=Depends(re
password_hash=hash_password(data.password),
name=data.name,
role=data.role,
office_id=data.office_id,
manager_id=data.manager_id,
created_at=datetime.utcnow().isoformat()
)
db.add(new_user)
db.commit()
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)):
"""Update user (admin only)"""
target = db.query(User).filter(User.id == user_id).first()
if not target:
raise HTTPException(status_code=404, detail="User not found")
if data.email is not None:
existing = db.query(User).filter(User.email == data.email, User.id != user_id).first()
if existing:
raise HTTPException(status_code=400, detail="Email already in use")
target.email = data.email
# Check if user is LDAP-managed
is_ldap_user = config.AUTHELIA_ENABLED and target.password_hash is None
is_ldap_admin = is_ldap_user and target.role == "admin"
# Name update - blocked for LDAP users
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
# Role update
if data.role is not None:
if data.role not in ["admin", "manager", "employee"]:
raise HTTPException(status_code=400, detail="Invalid role")
# Can't change admin role for LDAP admins (they get admin from parking_admins group)
if 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
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")
target.office_id = data.office_id
# Manager assignment (any user including admins can be assigned to a manager)
if data.manager_id is not None:
if data.manager_id:
manager = db.query(User).filter(User.id == data.manager_id).first()
if not manager or manager.role != "manager":
raise HTTPException(status_code=400, detail="Invalid manager")
if data.manager_id == user_id:
raise HTTPException(status_code=400, detail="User cannot be their own manager")
target.manager_id = data.manager_id if data.manager_id else None
# Manager-specific fields
if data.manager_parking_quota is not None:
if target.role != "manager":
raise HTTPException(status_code=400, detail="Parking quota only for managers")
@@ -166,7 +226,7 @@ def update_user(user_id: str, data: UserUpdate, db: Session = Depends(get_db), u
target.updated_at = datetime.utcnow().isoformat()
db.commit()
db.refresh(target)
return target
return user_to_response(target, db)
@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:
raise HTTPException(status_code=404, detail="User not found")
# Check if user is a manager with managed users
if target.role == "manager":
managed_count = db.query(User).filter(User.manager_id == user_id).count()
if managed_count > 0:
raise HTTPException(status_code=400, detail=f"Cannot delete: {managed_count} users are assigned to this manager")
db.delete(target)
db.commit()
return {"message": "User deleted"}
# 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")
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"""
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 {
"id": current_user.id,
"email": current_user.email,
"name": current_user.name,
"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")
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 is_ldap_user:
raise HTTPException(status_code=400, detail="Name is managed by LDAP")
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"}
@@ -292,7 +345,10 @@ def update_settings(data: SettingsUpdate, db: Session = Depends(get_db), current
@router.post("/me/change-password")
def change_password(data: ChangePasswordRequest, db: Session = Depends(get_db), current_user=Depends(get_current_user)):
"""Change current user's password"""
"""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):
raise HTTPException(status_code=400, detail="Current password is incorrect")

View File

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

View File

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

View File

@@ -36,4 +36,7 @@ def get_db_session():
def init_db():
"""Create all tables"""
from database.models import Base
print(f"[init_db] Initializing database at {config.DATABASE_URL}")
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)
name = Column(Text)
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_parking_quota = Column(Integer, default=0) # Number of spots this manager controls
@@ -37,51 +37,13 @@ class User(Base):
updated_at = Column(Text)
# 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")
assignments = relationship("DailyParkingAssignment", back_populates="user", foreign_keys="DailyParkingAssignment.user_id")
managed_offices = relationship("OfficeMembership", back_populates="user", cascade="all, delete-orphan")
__table_args__ = (
Index('idx_user_email', 'email'),
Index('idx_user_office', 'office_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),
Index('idx_user_manager', 'manager_id'),
)
@@ -128,7 +90,7 @@ class DailyParkingAssignment(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"
id = Column(Text, primary_key=True)
@@ -145,7 +107,7 @@ class ManagerClosingDay(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"
id = Column(Text, primary_key=True)

View File

@@ -3,55 +3,52 @@
## Prerequisites
- 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
# On development machine
cd /mnt/code/boilerplate/org-parking
git init
git add .
git commit -m "Initial commit: Parking Manager"
git remote add origin git@git.rocketscale.it:rocky/parking-manager.git
git push -u origin main
Parking is deployed as a **separate directory** alongside org-stack:
```
~/
├── org-stack/ # Main stack (Caddy, Authelia, LLDAP, etc.)
│ ├── compose.yml
│ ├── Caddyfile
│ ├── authelia/
│ └── .env
└── org-parking/ # Parking app (separate)
├── compose.yml # Production compose (connects to org-stack network)
├── .env # Own .env with PARKING_SECRET_KEY
├── Dockerfile
└── ...
```
## Step 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
# SSH to server
ssh rocky@rocketscale.it
# Clone into org-stack
cd ~/org-stack
git clone git@git.rocketscale.it:rocky/parking-manager.git parking
cd ~
git clone git@git.rocketscale.it:rocky/parking-manager.git org-parking
```
## Step 3: Add to .env
## Step 2: Create Production compose.yml
Add to `~/org-stack/.env`:
```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`:
Create `~/org-parking/compose.yml` on the server:
```yaml
# ===========================================================================
# Parking Manager - Parking Spot Management
# ===========================================================================
services:
parking:
build: ./parking
build: .
container_name: parking
restart: unless-stopped
volumes:
@@ -68,18 +65,34 @@ Add the parking service to `~/org-stack/compose.yml`:
- SMTP_FROM=${SMTP_FROM:-}
networks:
- org-network
depends_on:
- authelia
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"]
interval: 30s
timeout: 10s
retries: 3
start_period: 10s
volumes:
parking_data:
networks:
org-network:
external: true
name: org-stack_org-network
```
Add to volumes section:
```yaml
parking_data: # Parking SQLite database
## Step 3: Create .env File
Create `~/org-parking/.env` with a secret key:
```bash
cd ~/org-parking
python3 -c "import secrets; print(f'PARKING_SECRET_KEY={secrets.token_hex(32)}')" > .env
```
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`:
@@ -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):
1. Create group: `parking_admins` (follows lldap naming convention)
2. Create group: `managers` (reusable across apps)
3. Add yourself to `parking_admins`
1. Create group: `parking_admins`
2. Add yourself (or whoever should be admin) to this group
## 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
cd ~/org-stack
# Build and start parking service
cd ~/org-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
# Check logs
cd ~/org-parking
docker compose logs -f parking
```
@@ -124,14 +167,53 @@ docker compose logs -f parking
## Troubleshooting
### 403 Forbidden from Authelia
- Authelia's `access_control` doesn't have a rule for parking.rocketscale.it
- Add the domain to `~/org-stack/authelia/configuration.yml` (see Step 6)
- Restart Authelia: `docker compose restart authelia`
### 401 Unauthorized
- Check Authelia headers are being passed
- Check `docker compose logs authelia`
### Login redirect loop (keeps redirecting to /login)
- Frontend JS must use async auth checking for Authelia mode
- The `api.requireAuth()` must call `/api/auth/me` endpoint (not check localStorage)
- Ensure all page JS files use: `currentUser = await api.requireAuth();`
### User has wrong role
- Verify LLDAP group membership
- Roles sync on each login
- **Admin role**: Verify user is in `parking_admins` LLDAP group (synced on each login)
- **Manager role**: Must be assigned by admin via Manage Users page (not from LLDAP)
- **Employee role**: Default for users not in `parking_admins` group
### Database errors
- Check volume mount: `docker compose exec parking ls -la /app/data`
- Check permissions: `docker compose exec parking id`
## Architecture Notes
### Authelia Integration
The app supports two authentication modes:
1. **JWT mode** (standalone): Users login via `/login`, get JWT token stored in localStorage
2. **Authelia mode** (SSO): Authelia handles login, passes headers to backend
When `AUTHELIA_ENABLED=true`:
- Backend reads user info from headers: `Remote-User`, `Remote-Email`, `Remote-Name`, `Remote-Groups`
- Users are auto-created on first login
- Roles are synced from LLDAP groups on each request
- Frontend calls `/api/auth/me` to get user info (backend reads headers)
### Role Management
Only the **admin** role is synced from LLDAP:
```python
AUTHELIA_ADMIN_GROUP = "parking_admins" # → role: admin
```
Other roles are managed within the app:
- **manager**: Assigned by admin via Manage Users page
- **employee**: Default role for all non-admin users
This separation allows:
- LLDAP to control who has admin access
- App admin to assign manager roles and office assignments without LLDAP changes

View File

@@ -1,141 +1,138 @@
/**
* Admin Users Page
* Manage all users in the system
* Manage users with LDAP-aware editing
*/
let currentUser = null;
let users = [];
let offices = [];
let managedOfficesMap = {}; // user_id -> [office_ids]
let managers = [];
document.addEventListener('DOMContentLoaded', async () => {
if (!api.requireAuth()) return;
currentUser = await api.getCurrentUser();
currentUser = await api.requireAuth();
if (!currentUser) return;
// Only admins can access
if (currentUser.role !== 'admin') {
window.location.href = '/presence';
return;
}
await Promise.all([loadUsers(), loadOffices()]);
await loadManagedOffices();
renderUsers();
await loadManagers();
await loadUsers();
setupEventListeners();
});
async function loadManagers() {
const response = await api.get('/api/managers');
if (response && response.ok) {
managers = await response.json();
}
}
async function loadUsers() {
const response = await api.get('/api/users');
if (response && response.ok) {
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 = '') {
const tbody = document.getElementById('usersBody');
const filtered = filter
? users.filter(u =>
u.name.toLowerCase().includes(filter.toLowerCase()) ||
u.email.toLowerCase().includes(filter.toLowerCase())
)
: users;
const filterLower = filter.toLowerCase();
let filtered = users;
if (filter) {
filtered = users.filter(u =>
(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) {
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;
}
tbody.innerHTML = filtered.map(user => {
const office = offices.find(o => o.id === user.office_id);
const isManager = user.role === 'manager';
const managedOffices = isManager ? getManagedOfficeNames(user.id) : '-';
const ldapBadge = user.is_ldap_user ? '<span class="badge badge-info">LDAP</span>' : '';
const managerInfo = user.role === 'manager'
? `<span class="badge badge-success">${user.managed_user_count || 0} users</span>`
: (user.manager_name || '-');
return `
<tr>
<td>${user.name}</td>
<td>${user.name || '-'} ${ldapBadge}</td>
<td>${user.email}</td>
<td><span class="badge badge-${user.role}">${user.role}</span></td>
<td>${office ? office.name : '-'}</td>
<td>${managedOffices}</td>
<td>${isManager ? (user.manager_parking_quota || 0) : '-'}</td>
<td>${isManager ? (user.manager_spot_prefix || '-') : '-'}</td>
<td><span class="badge badge-${getRoleBadgeClass(user.role)}">${user.role}</span></td>
<td>${managerInfo}</td>
<td>
<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}')">Delete</button>
` : ''}
<button class="btn btn-sm btn-danger" onclick="deleteUser('${user.id}')" ${user.id === currentUser.id ? 'disabled' : ''}>Delete</button>
</td>
</tr>
`;
}).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);
if (!user) return;
// Populate form
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('editRole').value = user.role;
document.getElementById('editOffice').value = user.office_id || '';
document.getElementById('editQuota').value = user.manager_parking_quota || 0;
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
document.getElementById('quotaGroup').style.display = isManager ? 'block' : 'none';
document.getElementById('prefixGroup').style.display = isManager ? 'block' : 'none';
document.getElementById('managedOfficesGroup').style.display = isManager ? 'block' : 'none';
// Handle LDAP restrictions
const isLdap = user.is_ldap_user;
const isLdapAdmin = user.is_ldap_admin;
// Build managed offices checkboxes
const checkboxContainer = document.getElementById('managedOfficesCheckboxes');
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('');
// LDAP notice
document.getElementById('ldapNotice').style.display = isLdap ? 'block' : 'none';
// 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';
}
@@ -143,14 +140,12 @@ async function deleteUser(userId) {
const user = users.find(u => u.id === userId);
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}`);
if (response && response.ok) {
await loadUsers();
await loadManagedOffices();
renderUsers();
utils.showMessage('User deleted', 'success');
await loadUsers();
} else {
const error = await response.json();
utils.showMessage(error.detail || 'Failed to delete user', 'error');
@@ -163,22 +158,21 @@ function setupEventListeners() {
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('userModal').style.display = 'none';
});
document.getElementById('cancelUser').addEventListener('click', () => {
document.getElementById('userModal').style.display = 'none';
});
// 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';
});
utils.setupModalClose('userModal');
// Form submit
document.getElementById('userForm').addEventListener('submit', async (e) => {
@@ -188,60 +182,35 @@ function setupEventListeners() {
const role = document.getElementById('editRole').value;
const data = {
name: document.getElementById('editName').value,
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') {
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);
if (response && response.ok) {
// Update managed offices if manager
if (role === 'manager') {
await updateManagedOffices(userId);
}
document.getElementById('userModal').style.display = 'none';
await loadUsers();
await loadManagedOffices();
renderUsers();
utils.showMessage('User updated', 'success');
await loadManagers(); // Reload in case role changed
await loadUsers();
} else {
const error = await response.json();
utils.showMessage(error.detail || 'Failed to update user', 'error');
}
});
utils.setupModalClose('userModal');
}
async function updateManagedOffices(userId) {
// 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
// Make functions available globally for onclick handlers
window.editUser = editUser;
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() {
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
* Returns user object if authenticated, null otherwise
*/
requireAuth() {
if (!this.isAuthenticated()) {
async requireAuth() {
const user = await this.checkAuth();
if (!user) {
window.location.href = '/login';
return false;
return null;
}
return true;
return user;
},
/**
@@ -143,11 +164,11 @@ const api = {
/**
* Register
*/
async register(email, password, name, officeId = null) {
async register(email, password, name) {
const response = await fetch('/api/auth/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password, name, office_id: officeId })
body: JSON.stringify({ email, password, name })
});
if (response.ok) {

View File

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

View File

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

View File

@@ -16,17 +16,9 @@ let selectedDate = null;
let currentAssignmentId = null;
document.addEventListener('DOMContentLoaded', async () => {
if (!api.requireAuth()) return;
currentUser = await api.getCurrentUser();
currentUser = await api.requireAuth();
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
const weekStartDay = currentUser.week_start_day || 0;
currentStartDate = utils.getWeekStart(new Date(), weekStartDay);
@@ -38,7 +30,7 @@ document.addEventListener('DOMContentLoaded', async () => {
});
async function loadManagers() {
const response = await api.get('/api/offices/managers/list');
const response = await api.get('/api/managers');
if (response && response.ok) {
managers = await response.json();
const select = document.getElementById('managerFilter');
@@ -48,20 +40,32 @@ async function loadManagers() {
if (currentUser.role === 'manager') {
// Manager only sees themselves
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 => {
const option = document.createElement('option');
option.value = manager.id;
const officeNames = manager.offices.map(o => o.name).join(', ');
option.textContent = `${manager.name} (${officeNames})`;
const userCount = manager.managed_user_count || 0;
option.textContent = `${manager.name} (${userCount} users)`;
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) {
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
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++) {
const date = new Date(startDate);
@@ -161,7 +165,7 @@ function renderCalendar() {
teamData.forEach(member => {
bodyHtml += `<tr>
<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++) {
const date = new Date(startDate);
@@ -197,16 +201,18 @@ function renderCalendar() {
});
body.innerHTML = bodyHtml;
// Add click handlers to cells
body.querySelectorAll('.calendar-cell').forEach(cell => {
cell.style.cursor = 'pointer';
cell.addEventListener('click', () => {
const userId = cell.dataset.userId;
const date = cell.dataset.date;
const userName = cell.dataset.userName;
openDayModal(userId, date, userName);
// Add click handlers to cells (only for admins and managers)
if (currentUser.role === 'admin' || currentUser.role === 'manager') {
body.querySelectorAll('.calendar-cell').forEach(cell => {
cell.style.cursor = 'pointer';
cell.addEventListener('click', () => {
const userId = cell.dataset.userId;
const date = cell.dataset.date;
const userName = cell.dataset.userName;
openDayModal(userId, date, 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
*
* 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;
@@ -10,9 +10,7 @@ let selectedManagerId = null;
let managerUsers = [];
document.addEventListener('DOMContentLoaded', async () => {
if (!api.requireAuth()) return;
currentUser = await api.getCurrentUser();
currentUser = await api.requireAuth();
if (!currentUser) return;
// Only managers and admins can access
@@ -26,10 +24,10 @@ document.addEventListener('DOMContentLoaded', async () => {
});
async function loadManagers() {
const response = await api.get('/api/offices/managers/list');
const response = await api.get('/api/managers');
if (response && response.ok) {
const managers = await response.json();
const select = document.getElementById('officeSelect');
const select = document.getElementById('managerSelect');
// Filter to managers this user can see
let filteredManagers = managers;
@@ -45,14 +43,10 @@ async function loadManagers() {
filteredManagers.forEach(manager => {
const option = document.createElement('option');
option.value = manager.id;
// Show manager name and count of offices
const officeCount = manager.offices.length;
const officeNames = manager.offices.map(o => o.name).join(', ');
if (officeCount > 0) {
option.textContent = `${manager.name} (${officeNames})`;
} else {
option.textContent = `${manager.name} (no offices)`;
}
// Show manager name with user count and parking quota
const userCount = manager.managed_user_count || 0;
const quota = manager.parking_quota || 0;
option.textContent = `${manager.name} (${userCount} users, ${quota} spots)`;
select.appendChild(option);
totalManagers++;
if (!firstManagerId) firstManagerId = manager.id;
@@ -71,12 +65,12 @@ async function selectManager(managerId) {
if (!managerId) {
document.getElementById('rulesContent').style.display = 'none';
document.getElementById('noOfficeMessage').style.display = 'block';
document.getElementById('noManagerMessage').style.display = 'block';
return;
}
document.getElementById('rulesContent').style.display = 'block';
document.getElementById('noOfficeMessage').style.display = 'none';
document.getElementById('noManagerMessage').style.display = 'none';
await Promise.all([
loadWeeklyClosingDays(),
@@ -88,7 +82,7 @@ async function selectManager(managerId) {
}
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) {
const days = await response.json();
const weekdays = days.map(d => d.weekday);
@@ -102,7 +96,7 @@ async function loadWeeklyClosingDays() {
}
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) {
managerUsers = await response.json();
updateUserSelects();
@@ -123,7 +117,7 @@ function updateUserSelects() {
}
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');
if (response && response.ok) {
@@ -158,7 +152,7 @@ function formatDateRange(startDate, endDate) {
}
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');
if (response && response.ok) {
@@ -188,7 +182,7 @@ async function loadGuarantees() {
}
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');
if (response && response.ok) {
@@ -220,7 +214,7 @@ async function loadExclusions() {
// Delete functions
async function deleteClosingDay(id) {
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) {
await loadClosingDays();
}
@@ -228,7 +222,7 @@ async function deleteClosingDay(id) {
async function deleteGuarantee(id) {
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) {
await loadGuarantees();
}
@@ -236,7 +230,7 @@ async function deleteGuarantee(id) {
async function deleteExclusion(id) {
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) {
await loadExclusions();
}
@@ -244,7 +238,7 @@ async function deleteExclusion(id) {
function setupEventListeners() {
// Manager selection
document.getElementById('officeSelect').addEventListener('change', (e) => {
document.getElementById('managerSelect').addEventListener('change', (e) => {
selectManager(e.target.value);
});
@@ -255,7 +249,7 @@ function setupEventListeners() {
if (e.target.checked) {
// 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) {
e.target.checked = false;
const error = await response.json();
@@ -263,12 +257,12 @@ function setupEventListeners() {
}
} else {
// 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) {
const days = await getResponse.json();
const day = days.find(d => d.weekday === weekday);
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) {
e.target.checked = true;
}
@@ -320,7 +314,7 @@ function setupEventListeners() {
date: document.getElementById('closingDate').value,
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) {
document.getElementById('closingDayModal').style.display = 'none';
await loadClosingDays();
@@ -337,7 +331,7 @@ function setupEventListeners() {
start_date: document.getElementById('guaranteeStartDate').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) {
document.getElementById('guaranteeModal').style.display = 'none';
await loadGuarantees();
@@ -354,7 +348,7 @@ function setupEventListeners() {
start_date: document.getElementById('exclusionStartDate').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) {
document.getElementById('exclusionModal').style.display = 'none';
await loadExclusions();

View File

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

View File

@@ -12,7 +12,7 @@
<div class="auth-card">
<div class="auth-header">
<h1>Parking Manager</h1>
<p>Manage office presence and parking assignments</p>
<p>Manage team presence and parking assignments</p>
</div>
<div style="display: flex; flex-direction: column; gap: 1rem;">
@@ -23,10 +23,25 @@
</div>
<script>
// Redirect if already logged in
if (localStorage.getItem('access_token')) {
window.location.href = '/presence';
// Redirect if already logged in (JWT token or Authelia)
async function checkAndRedirect() {
// 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>
</body>
</html>

View File

@@ -48,10 +48,16 @@
<h3>Personal Information</h3>
</div>
<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">
<div class="form-group">
<label for="name">Full Name</label>
<input type="text" id="name" required>
<small id="nameHelp" class="text-muted" style="display: none;">Managed by LDAP</small>
</div>
<div class="form-group">
<label for="email">Email</label>
@@ -59,19 +65,24 @@
<small class="text-muted">Email cannot be changed</small>
</div>
<div class="form-group">
<label for="office">Office</label>
<select id="office">
<option value="">No office</option>
</select>
<label for="role">Role</label>
<input type="text" id="role" disabled>
<small class="text-muted">Role is assigned by your administrator</small>
</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>
</div>
</form>
</div>
</div>
<div class="card">
<!-- Password section - hidden for LDAP users -->
<div class="card" id="passwordCard">
<div class="card-header">
<h3>Change Password</h3>
</div>
@@ -104,36 +115,37 @@
<script src="/js/nav.js"></script>
<script>
let currentUser = null;
let isLdapUser = false;
document.addEventListener('DOMContentLoaded', async () => {
if (!api.requireAuth()) return;
currentUser = await api.getCurrentUser();
currentUser = await api.requireAuth();
if (!currentUser) return;
await loadOffices();
populateForm();
await loadProfile();
setupEventListeners();
});
async function loadOffices() {
const response = await api.get('/api/offices');
async function loadProfile() {
const response = await api.get('/api/users/me/profile');
if (response && 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);
});
}
}
const profile = await response.json();
isLdapUser = profile.is_ldap_user;
function populateForm() {
document.getElementById('name').value = currentUser.name || '';
document.getElementById('email').value = currentUser.email;
document.getElementById('office').value = currentUser.office_id || '';
// Populate form
document.getElementById('name').value = profile.name || '';
document.getElementById('email').value = profile.email;
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() {
@@ -141,15 +153,21 @@
document.getElementById('profileForm').addEventListener('submit', async (e) => {
e.preventDefault();
if (isLdapUser) {
utils.showMessage('Profile is managed by LDAP', 'error');
return;
}
const data = {
name: document.getElementById('name').value,
office_id: document.getElementById('office').value || null
name: document.getElementById('name').value
};
const response = await api.put('/api/users/me/profile', data);
if (response && response.ok) {
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 {
const error = await response.json();
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">
<small>Minimum 8 characters</small>
</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>
</form>
@@ -53,39 +47,17 @@
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) => {
e.preventDefault();
const name = document.getElementById('name').value;
const email = document.getElementById('email').value;
const password = document.getElementById('password').value;
const officeId = document.getElementById('office').value || null;
const errorDiv = document.getElementById('errorMessage');
errorDiv.innerHTML = '';
const result = await api.register(email, password, name, officeId);
const result = await api.register(email, password, name);
if (result.success) {
window.location.href = '/presence';

View File

@@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8">
<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="stylesheet" href="/css/styles.css">
</head>
@@ -39,9 +39,9 @@
<main class="main-content">
<header class="page-header">
<h2>Office Rules</h2>
<h2>Team Rules</h2>
<div class="header-actions">
<select id="officeSelect" class="form-select">
<select id="managerSelect" class="form-select">
<option value="">Select Manager</option>
</select>
</div>
@@ -54,7 +54,7 @@
<h3>Weekly Closing Days</h3>
</div>
<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">
<label><input type="checkbox" data-weekday="0"> Sunday</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>
</div>
<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>
</div>
@@ -104,10 +104,10 @@
</div>
</div>
<div class="content-wrapper" id="noOfficeMessage">
<div class="content-wrapper" id="noManagerMessage">
<div class="card">
<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>
@@ -210,6 +210,6 @@
<script src="/js/api.js"></script>
<script src="/js/utils.js"></script>
<script src="/js/nav.js"></script>
<script src="/js/office-rules.js"></script>
<script src="/js/team-rules.js"></script>
</body>
</html>

10
main.py
View File

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

View File

@@ -61,14 +61,14 @@ def authenticate_user(db: Session, email: str, password: str) -> User | None:
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"""
user = User(
id=str(uuid.uuid4()),
email=email,
password_hash=hash_password(password),
name=name,
office_id=office_id,
manager_id=manager_id,
role=role,
created_at=datetime.utcnow().isoformat(),
updated_at=datetime.utcnow().isoformat()

View File

@@ -21,7 +21,7 @@ import uuid
from database.models import (
User, UserPresence, DailyParkingAssignment,
NotificationLog, NotificationQueue, OfficeMembership
NotificationLog, NotificationQueue
)
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)
- Each manager has a spot prefix (A, B, C...) for display names
- 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
from datetime import datetime, timezone
from sqlalchemy.orm import Session
from sqlalchemy import func
from sqlalchemy import func, or_
from database.models import (
DailyParkingAssignment, User, OfficeMembership, UserPresence,
DailyParkingAssignment, User, UserPresence,
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:
"""Get the spot prefix for a manager (from manager_spot_prefix or auto-assign)"""
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:
"""
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
"""
# 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()
]
# Count days user was present (office_days)
office_days = db.query(UserPresence).filter(
# Count days user was present
presence_days = db.query(UserPresence).filter(
UserPresence.user_id == user_id,
UserPresence.status == "present"
).count()
if office_days == 0:
if presence_days == 0:
return 0.0 # New user, highest priority
# 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
).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:
@@ -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.
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(
UserPresence.date == date,
UserPresence.status == "present",
User.office_id.in_(managed_office_ids)
or_(User.manager_id == manager_id, User.id == manager_id)
).all()
candidates = []
@@ -316,18 +294,19 @@ def release_user_spot(manager_id: str, user_id: str, date: str, db: Session) ->
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.
Uses fairness algorithm for assignment.
manager_id is the user's manager (from User.manager_id).
"""
# Don't process past dates
target_date = datetime.strptime(date, "%Y-%m-%d").date()
if target_date < datetime.now().date():
return
# Find manager for this office
manager = get_manager_for_office(office_id, db)
# Get manager
manager = db.query(User).filter(User.id == manager_id, User.role == "manager").first()
if not manager or not manager.manager_parking_quota:
return

View File

@@ -16,13 +16,9 @@ from app import config
security = HTTPBearer(auto_error=False)
def get_role_from_groups(groups: list[str]) -> str:
"""Map Authelia groups to application roles"""
if config.AUTHELIA_ADMIN_GROUP in groups:
return "admin"
if config.AUTHELIA_MANAGER_GROUP in groups:
return "manager"
return "employee"
def is_admin_from_groups(groups: list[str]) -> bool:
"""Check if user is admin based on Authelia groups"""
return config.AUTHELIA_ADMIN_GROUP in groups
def get_or_create_authelia_user(
@@ -31,14 +27,28 @@ def get_or_create_authelia_user(
groups: list[str],
db: Session
) -> 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)
role = get_role_from_groups(groups)
is_admin = is_admin_from_groups(groups)
if user:
# Update role if changed in LLDAP
if user.role != role:
user.role = role
# Only sync admin status from LLDAP, other roles managed by app admin
if is_admin and user.role != "admin":
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()
db.commit()
db.refresh(user)
@@ -55,7 +65,7 @@ def get_or_create_authelia_user(
id=str(uuid.uuid4()),
email=email,
name=name or email.split("@")[0],
role=role,
role="admin" if is_admin else "employee",
password_hash=None, # No password for Authelia users
created_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_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:
raise HTTPException(
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:
"""
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.
"""
if current_user.role == "admin":
return True
if current_user.role == "manager":
managed_office_ids = [m.office_id for m in current_user.managed_offices]
if target_user.office_id not in managed_office_ids:
if target_user.manager_id != current_user.id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="User not in your managed offices"
detail="User is not managed by you"
)
return True