Initial commit: Parking Manager

Features:
- Manager-centric parking spot management
- Fair assignment algorithm (parking/presence ratio)
- Presence tracking calendar
- Closing days (specific & weekly recurring)
- Guarantees and exclusions
- Authelia/LLDAP integration for SSO

Stack:
- FastAPI backend
- SQLite database
- Vanilla JS frontend
- Docker deployment
This commit is contained in:
Stefano Manfredi
2025-11-26 23:37:50 +00:00
commit c74a0ed350
49 changed files with 9094 additions and 0 deletions

0
database/__init__.py Normal file
View File

39
database/connection.py Normal file
View File

@@ -0,0 +1,39 @@
"""
Database Connection Management
"""
from contextlib import contextmanager
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from app import config
engine = create_engine(
config.DATABASE_URL,
connect_args={"check_same_thread": False} if "sqlite" in config.DATABASE_URL else {}
)
SessionLocal = sessionmaker(bind=engine)
def get_db():
"""Database session for FastAPI dependency injection"""
db = SessionLocal()
try:
yield db
finally:
db.close()
@contextmanager
def get_db_session():
"""Database session for regular Python code"""
db = SessionLocal()
try:
yield db
finally:
db.close()
def init_db():
"""Create all tables"""
from database.models import Base
Base.metadata.create_all(bind=engine)

232
database/models.py Normal file
View File

@@ -0,0 +1,232 @@
"""
SQLAlchemy ORM Models
Clean, focused data models for parking management
"""
from sqlalchemy import Column, String, Integer, Text, ForeignKey, Index
from sqlalchemy.orm import relationship, declarative_base
Base = declarative_base()
class User(Base):
"""Application users"""
__tablename__ = "users"
id = Column(Text, primary_key=True)
email = Column(Text, unique=True, nullable=False)
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-specific fields (only relevant for role='manager')
manager_parking_quota = Column(Integer, default=0) # Number of spots this manager controls
manager_spot_prefix = Column(Text) # Letter prefix for spots: A, B, C, etc.
# User preferences
week_start_day = Column(Integer, default=0) # 0=Sunday, 1=Monday, ..., 6=Saturday
# Notification preferences
notify_weekly_parking = Column(Integer, default=1) # Weekly parking summary (Friday at 12)
notify_daily_parking = Column(Integer, default=1) # Daily parking reminder
notify_daily_parking_hour = Column(Integer, default=8) # Hour for daily reminder (0-23)
notify_daily_parking_minute = Column(Integer, default=0) # Minute for daily reminder (0-59)
notify_parking_changes = Column(Integer, default=1) # Immediate notification on assignment changes
created_at = Column(Text)
updated_at = Column(Text)
# Relationships
office = relationship("Office", back_populates="users")
presences = relationship("UserPresence", back_populates="user", cascade="all, delete-orphan")
assignments = relationship("DailyParkingAssignment", back_populates="user", foreign_keys="DailyParkingAssignment.user_id")
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),
)
class UserPresence(Base):
"""Daily presence records"""
__tablename__ = "user_presences"
id = Column(Text, primary_key=True)
user_id = Column(Text, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
date = Column(Text, nullable=False) # YYYY-MM-DD
status = Column(Text, nullable=False) # present, remote, absent
created_at = Column(Text)
updated_at = Column(Text)
# Relationships
user = relationship("User", back_populates="presences")
__table_args__ = (
Index('idx_presence_user_date', 'user_id', 'date', unique=True),
Index('idx_presence_date', 'date'),
)
class DailyParkingAssignment(Base):
"""Parking spot assignments per day - spots belong to managers"""
__tablename__ = "daily_parking_assignments"
id = Column(Text, primary_key=True)
date = Column(Text, nullable=False) # YYYY-MM-DD
spot_id = Column(Text, nullable=False) # A1, A2, B1, B2, etc. (prefix from manager)
user_id = Column(Text, ForeignKey("users.id", ondelete="SET NULL"))
manager_id = Column(Text, ForeignKey("users.id", ondelete="CASCADE"), nullable=False) # Manager who owns the spot
created_at = Column(Text)
# Relationships
user = relationship("User", back_populates="assignments", foreign_keys=[user_id])
manager = relationship("User", foreign_keys=[manager_id])
__table_args__ = (
Index('idx_assignment_manager_date', 'manager_id', 'date'),
Index('idx_assignment_user', 'user_id'),
Index('idx_assignment_date_spot', 'date', 'spot_id'),
)
class ManagerClosingDay(Base):
"""Specific date closing days for a manager's offices (holidays, special closures)"""
__tablename__ = "manager_closing_days"
id = Column(Text, primary_key=True)
manager_id = Column(Text, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
date = Column(Text, nullable=False) # YYYY-MM-DD
reason = Column(Text)
# Relationships
manager = relationship("User")
__table_args__ = (
Index('idx_closing_manager_date', 'manager_id', 'date', unique=True),
)
class ManagerWeeklyClosingDay(Base):
"""Weekly recurring closing days for a manager's offices (e.g., Saturday and Sunday)"""
__tablename__ = "manager_weekly_closing_days"
id = Column(Text, primary_key=True)
manager_id = Column(Text, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
weekday = Column(Integer, nullable=False) # 0=Sunday, 1=Monday, ..., 6=Saturday
# Relationships
manager = relationship("User")
__table_args__ = (
Index('idx_weekly_closing_manager_day', 'manager_id', 'weekday', unique=True),
)
class ParkingGuarantee(Base):
"""Users guaranteed a parking spot when present (set by manager)"""
__tablename__ = "parking_guarantees"
id = Column(Text, primary_key=True)
manager_id = Column(Text, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
user_id = Column(Text, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
start_date = Column(Text) # Optional: YYYY-MM-DD (null = no start limit)
end_date = Column(Text) # Optional: YYYY-MM-DD (null = no end limit)
created_at = Column(Text)
# Relationships
manager = relationship("User", foreign_keys=[manager_id])
user = relationship("User", foreign_keys=[user_id])
__table_args__ = (
Index('idx_guarantee_manager_user', 'manager_id', 'user_id', unique=True),
)
class ParkingExclusion(Base):
"""Users excluded from parking assignment (set by manager)"""
__tablename__ = "parking_exclusions"
id = Column(Text, primary_key=True)
manager_id = Column(Text, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
user_id = Column(Text, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
start_date = Column(Text) # Optional: YYYY-MM-DD (null = no start limit)
end_date = Column(Text) # Optional: YYYY-MM-DD (null = no end limit)
created_at = Column(Text)
# Relationships
manager = relationship("User", foreign_keys=[manager_id])
user = relationship("User", foreign_keys=[user_id])
__table_args__ = (
Index('idx_exclusion_manager_user', 'manager_id', 'user_id', unique=True),
)
class NotificationLog(Base):
"""Log of sent notifications to prevent duplicates"""
__tablename__ = "notification_logs"
id = Column(Text, primary_key=True)
user_id = Column(Text, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
notification_type = Column(Text, nullable=False) # presence_reminder, weekly_parking, daily_parking, parking_change
reference_date = Column(Text) # The date/week this notification refers to (YYYY-MM-DD or YYYY-Www)
sent_at = Column(Text, nullable=False)
__table_args__ = (
Index('idx_notification_user_type_date', 'user_id', 'notification_type', 'reference_date'),
)
class NotificationQueue(Base):
"""Queue for pending notifications (for immediate parking change notifications)"""
__tablename__ = "notification_queue"
id = Column(Text, primary_key=True)
user_id = Column(Text, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
notification_type = Column(Text, nullable=False) # parking_change
subject = Column(Text, nullable=False)
body = Column(Text, nullable=False)
created_at = Column(Text, nullable=False)
sent_at = Column(Text) # null = not sent yet
__table_args__ = (
Index('idx_queue_pending', 'sent_at'),
)