Primo commit
This commit is contained in:
@@ -61,17 +61,16 @@ def authenticate_user(db: Session, email: str, password: str) -> User | None:
|
||||
return user
|
||||
|
||||
|
||||
def create_user(db: Session, email: str, password: str, name: str, manager_id: str = None, role: str = "employee") -> User:
|
||||
def create_user(db: Session, email: str, password: str, name: str, role: str = "employee") -> User:
|
||||
"""Create a new user"""
|
||||
user = User(
|
||||
id=str(uuid.uuid4()),
|
||||
email=email,
|
||||
password_hash=hash_password(password),
|
||||
name=name,
|
||||
manager_id=manager_id,
|
||||
role=role,
|
||||
created_at=datetime.utcnow().isoformat(),
|
||||
updated_at=datetime.utcnow().isoformat()
|
||||
created_at=datetime.utcnow(),
|
||||
updated_at=datetime.utcnow()
|
||||
)
|
||||
db.add(user)
|
||||
db.commit()
|
||||
|
||||
@@ -6,11 +6,12 @@ Follows org-stack pattern: direct SMTP send with file fallback when disabled.
|
||||
import smtplib
|
||||
from email.mime.text import MIMEText
|
||||
from email.mime.multipart import MIMEMultipart
|
||||
from datetime import datetime, timedelta
|
||||
from typing import TYPE_CHECKING
|
||||
from datetime import datetime, timedelta, date
|
||||
from typing import TYPE_CHECKING, List
|
||||
|
||||
from app import config
|
||||
from utils.helpers import generate_uuid
|
||||
from database.models import NotificationType
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from sqlalchemy.orm import Session
|
||||
@@ -72,35 +73,34 @@ def send_email(to_email: str, subject: str, body_html: str, body_text: str = Non
|
||||
return False
|
||||
|
||||
|
||||
def get_week_dates(reference_date: datetime) -> list[datetime]:
|
||||
def get_week_dates(reference_date: date) -> list[date]:
|
||||
"""Get Monday-Sunday dates for the week containing reference_date"""
|
||||
monday = reference_date - timedelta(days=reference_date.weekday())
|
||||
return [monday + timedelta(days=i) for i in range(7)]
|
||||
|
||||
|
||||
def get_next_week_dates(reference_date: datetime) -> list[datetime]:
|
||||
def get_next_week_dates(reference_date: date) -> list[date]:
|
||||
"""Get Monday-Sunday dates for the week after reference_date"""
|
||||
days_until_next_monday = 7 - reference_date.weekday()
|
||||
next_monday = reference_date + timedelta(days=days_until_next_monday)
|
||||
return [next_monday + timedelta(days=i) for i in range(7)]
|
||||
|
||||
|
||||
def get_week_reference(date: datetime) -> str:
|
||||
def get_week_reference(date_obj: date) -> str:
|
||||
"""Get ISO week reference string (e.g., 2024-W48)"""
|
||||
return date.strftime("%Y-W%W")
|
||||
return date_obj.strftime("%Y-W%W")
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Notification sending functions
|
||||
# =============================================================================
|
||||
|
||||
def notify_parking_assigned(user: "User", date: str, spot_name: str):
|
||||
def notify_parking_assigned(user: "User", assignment_date: date, spot_name: str):
|
||||
"""Send notification when parking spot is assigned"""
|
||||
if not user.notify_parking_changes:
|
||||
return
|
||||
|
||||
date_obj = datetime.strptime(date, "%Y-%m-%d")
|
||||
day_name = date_obj.strftime("%A, %B %d")
|
||||
day_name = assignment_date.strftime("%A, %B %d")
|
||||
|
||||
subject = f"Parking spot assigned for {day_name}"
|
||||
body_html = f"""
|
||||
@@ -117,13 +117,12 @@ def notify_parking_assigned(user: "User", date: str, spot_name: str):
|
||||
send_email(user.email, subject, body_html)
|
||||
|
||||
|
||||
def notify_parking_released(user: "User", date: str, spot_name: str):
|
||||
def notify_parking_released(user: "User", assignment_date: date, spot_name: str):
|
||||
"""Send notification when parking spot is released"""
|
||||
if not user.notify_parking_changes:
|
||||
return
|
||||
|
||||
date_obj = datetime.strptime(date, "%Y-%m-%d")
|
||||
day_name = date_obj.strftime("%A, %B %d")
|
||||
day_name = assignment_date.strftime("%A, %B %d")
|
||||
|
||||
subject = f"Parking spot released for {day_name}"
|
||||
body_html = f"""
|
||||
@@ -139,13 +138,12 @@ def notify_parking_released(user: "User", date: str, spot_name: str):
|
||||
send_email(user.email, subject, body_html)
|
||||
|
||||
|
||||
def notify_parking_reassigned(user: "User", date: str, spot_name: str, new_user_name: str):
|
||||
def notify_parking_reassigned(user: "User", assignment_date: date, spot_name: str, new_user_name: str):
|
||||
"""Send notification when parking spot is reassigned to someone else"""
|
||||
if not user.notify_parking_changes:
|
||||
return
|
||||
|
||||
date_obj = datetime.strptime(date, "%Y-%m-%d")
|
||||
day_name = date_obj.strftime("%A, %B %d")
|
||||
day_name = assignment_date.strftime("%A, %B %d")
|
||||
|
||||
subject = f"Parking spot reassigned for {day_name}"
|
||||
body_html = f"""
|
||||
@@ -161,29 +159,29 @@ def notify_parking_reassigned(user: "User", date: str, spot_name: str, new_user_
|
||||
send_email(user.email, subject, body_html)
|
||||
|
||||
|
||||
def send_presence_reminder(user: "User", next_week_dates: list[datetime], db: "Session") -> bool:
|
||||
def send_presence_reminder(user: "User", next_week_dates: List[date], db: "Session") -> bool:
|
||||
"""Send presence compilation reminder for next week"""
|
||||
from database.models import UserPresence, NotificationLog
|
||||
|
||||
week_ref = get_week_reference(next_week_dates[0])
|
||||
|
||||
# Check if already sent today for this week
|
||||
today = datetime.now().strftime("%Y-%m-%d")
|
||||
today = datetime.now().date()
|
||||
existing = db.query(NotificationLog).filter(
|
||||
NotificationLog.user_id == user.id,
|
||||
NotificationLog.notification_type == "presence_reminder",
|
||||
NotificationLog.notification_type == NotificationType.PRESENCE_REMINDER,
|
||||
NotificationLog.reference_date == week_ref,
|
||||
NotificationLog.sent_at >= today
|
||||
NotificationLog.sent_at >= datetime.combine(today, datetime.min.time())
|
||||
).first()
|
||||
|
||||
if existing:
|
||||
return False
|
||||
|
||||
# Check if week is compiled (at least 5 days marked)
|
||||
date_strs = [d.strftime("%Y-%m-%d") for d in next_week_dates]
|
||||
# DB stores dates as Date objects now
|
||||
presences = db.query(UserPresence).filter(
|
||||
UserPresence.user_id == user.id,
|
||||
UserPresence.date.in_(date_strs)
|
||||
UserPresence.date.in_(next_week_dates)
|
||||
).all()
|
||||
|
||||
if len(presences) >= 5:
|
||||
@@ -211,9 +209,9 @@ def send_presence_reminder(user: "User", next_week_dates: list[datetime], db: "S
|
||||
log = NotificationLog(
|
||||
id=generate_uuid(),
|
||||
user_id=user.id,
|
||||
notification_type="presence_reminder",
|
||||
notification_type=NotificationType.PRESENCE_REMINDER,
|
||||
reference_date=week_ref,
|
||||
sent_at=datetime.now().isoformat()
|
||||
sent_at=datetime.utcnow()
|
||||
)
|
||||
db.add(log)
|
||||
db.commit()
|
||||
@@ -222,7 +220,7 @@ def send_presence_reminder(user: "User", next_week_dates: list[datetime], db: "S
|
||||
return False
|
||||
|
||||
|
||||
def send_weekly_parking_summary(user: "User", next_week_dates: list[datetime], db: "Session") -> bool:
|
||||
def send_weekly_parking_summary(user: "User", next_week_dates: List[date], db: "Session") -> bool:
|
||||
"""Send weekly parking assignment summary for next week (Friday at 12)"""
|
||||
from database.models import DailyParkingAssignment, NotificationLog
|
||||
from services.parking import get_spot_display_name
|
||||
@@ -235,7 +233,7 @@ def send_weekly_parking_summary(user: "User", next_week_dates: list[datetime], d
|
||||
# Check if already sent for this week
|
||||
existing = db.query(NotificationLog).filter(
|
||||
NotificationLog.user_id == user.id,
|
||||
NotificationLog.notification_type == "weekly_parking",
|
||||
NotificationLog.notification_type == NotificationType.WEEKLY_PARKING,
|
||||
NotificationLog.reference_date == week_ref
|
||||
).first()
|
||||
|
||||
@@ -243,10 +241,9 @@ def send_weekly_parking_summary(user: "User", next_week_dates: list[datetime], d
|
||||
return False
|
||||
|
||||
# Get parking assignments for next week
|
||||
date_strs = [d.strftime("%Y-%m-%d") for d in next_week_dates]
|
||||
assignments = db.query(DailyParkingAssignment).filter(
|
||||
DailyParkingAssignment.user_id == user.id,
|
||||
DailyParkingAssignment.date.in_(date_strs)
|
||||
DailyParkingAssignment.date.in_(next_week_dates)
|
||||
).all()
|
||||
|
||||
if not assignments:
|
||||
@@ -254,11 +251,11 @@ def send_weekly_parking_summary(user: "User", next_week_dates: list[datetime], d
|
||||
|
||||
# Build assignment list
|
||||
assignment_lines = []
|
||||
# a.date is now a date object
|
||||
for a in sorted(assignments, key=lambda x: x.date):
|
||||
date_obj = datetime.strptime(a.date, "%Y-%m-%d")
|
||||
day_name = date_obj.strftime("%A")
|
||||
spot_name = get_spot_display_name(a.spot_id, a.manager_id, db)
|
||||
assignment_lines.append(f"<li>{day_name}, {date_obj.strftime('%B %d')}: Spot {spot_name}</li>")
|
||||
day_name = a.date.strftime("%A")
|
||||
spot_name = get_spot_display_name(a.spot_id, a.office_id, db)
|
||||
assignment_lines.append(f"<li>{day_name}, {a.date.strftime('%B %d')}: Spot {spot_name}</li>")
|
||||
|
||||
start_date = next_week_dates[0].strftime("%B %d")
|
||||
end_date = next_week_dates[-1].strftime("%B %d, %Y")
|
||||
@@ -283,9 +280,9 @@ def send_weekly_parking_summary(user: "User", next_week_dates: list[datetime], d
|
||||
log = NotificationLog(
|
||||
id=generate_uuid(),
|
||||
user_id=user.id,
|
||||
notification_type="weekly_parking",
|
||||
notification_type=NotificationType.WEEKLY_PARKING,
|
||||
reference_date=week_ref,
|
||||
sent_at=datetime.now().isoformat()
|
||||
sent_at=datetime.utcnow()
|
||||
)
|
||||
db.add(log)
|
||||
db.commit()
|
||||
@@ -294,7 +291,7 @@ def send_weekly_parking_summary(user: "User", next_week_dates: list[datetime], d
|
||||
return False
|
||||
|
||||
|
||||
def send_daily_parking_reminder(user: "User", date: datetime, db: "Session") -> bool:
|
||||
def send_daily_parking_reminder(user: "User", date_obj: datetime, db: "Session") -> bool:
|
||||
"""Send daily parking reminder for a specific date"""
|
||||
from database.models import DailyParkingAssignment, NotificationLog
|
||||
from services.parking import get_spot_display_name
|
||||
@@ -302,12 +299,13 @@ def send_daily_parking_reminder(user: "User", date: datetime, db: "Session") ->
|
||||
if not user.notify_daily_parking:
|
||||
return False
|
||||
|
||||
date_str = date.strftime("%Y-%m-%d")
|
||||
date_str = date_obj.strftime("%Y-%m-%d")
|
||||
assignment_date = date_obj.date()
|
||||
|
||||
# Check if already sent for this date
|
||||
existing = db.query(NotificationLog).filter(
|
||||
NotificationLog.user_id == user.id,
|
||||
NotificationLog.notification_type == "daily_parking",
|
||||
NotificationLog.notification_type == NotificationType.DAILY_PARKING,
|
||||
NotificationLog.reference_date == date_str
|
||||
).first()
|
||||
|
||||
@@ -317,14 +315,14 @@ def send_daily_parking_reminder(user: "User", date: datetime, db: "Session") ->
|
||||
# Get parking assignment for this date
|
||||
assignment = db.query(DailyParkingAssignment).filter(
|
||||
DailyParkingAssignment.user_id == user.id,
|
||||
DailyParkingAssignment.date == date_str
|
||||
DailyParkingAssignment.date == assignment_date
|
||||
).first()
|
||||
|
||||
if not assignment:
|
||||
return False
|
||||
|
||||
spot_name = get_spot_display_name(assignment.spot_id, assignment.manager_id, db)
|
||||
day_name = date.strftime("%A, %B %d")
|
||||
spot_name = get_spot_display_name(assignment.spot_id, assignment.office_id, db)
|
||||
day_name = date_obj.strftime("%A, %B %d")
|
||||
|
||||
subject = f"Parking reminder for {day_name}"
|
||||
body_html = f"""
|
||||
@@ -343,9 +341,9 @@ def send_daily_parking_reminder(user: "User", date: datetime, db: "Session") ->
|
||||
log = NotificationLog(
|
||||
id=generate_uuid(),
|
||||
user_id=user.id,
|
||||
notification_type="daily_parking",
|
||||
notification_type=NotificationType.DAILY_PARKING,
|
||||
reference_date=date_str,
|
||||
sent_at=datetime.now().isoformat()
|
||||
sent_at=datetime.utcnow()
|
||||
)
|
||||
db.add(log)
|
||||
db.commit()
|
||||
@@ -369,18 +367,19 @@ def run_scheduled_notifications(db: "Session"):
|
||||
current_hour = now.hour
|
||||
current_minute = now.minute
|
||||
current_weekday = now.weekday() # 0=Monday, 6=Sunday
|
||||
today_date = now.date()
|
||||
|
||||
users = db.query(User).all()
|
||||
|
||||
for user in users:
|
||||
# Thursday at 12: Presence reminder
|
||||
if current_weekday == 3 and current_hour == 12 and current_minute < 5:
|
||||
next_week = get_next_week_dates(now)
|
||||
next_week = get_next_week_dates(today_date)
|
||||
send_presence_reminder(user, next_week, db)
|
||||
|
||||
# Friday at 12: Weekly parking summary
|
||||
if current_weekday == 4 and current_hour == 12 and current_minute < 5:
|
||||
next_week = get_next_week_dates(now)
|
||||
next_week = get_next_week_dates(today_date)
|
||||
send_weekly_parking_summary(user, next_week, db)
|
||||
|
||||
# Daily parking reminder at user's preferred time (working days only)
|
||||
|
||||
@@ -1,49 +1,48 @@
|
||||
"""
|
||||
Parking Assignment Service
|
||||
Manager-centric parking spot management with fairness algorithm
|
||||
Office-centric parking spot management with fairness algorithm
|
||||
|
||||
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
|
||||
- Offices own parking spots (defined by Office.parking_quota)
|
||||
- Each office has a spot prefix (A, B, C...) for display names
|
||||
- Spots are named like A1, A2, B1, B2 based on office prefix
|
||||
- Fairness: users with lowest parking_days/presence_days ratio get priority
|
||||
"""
|
||||
from datetime import datetime, timezone
|
||||
from datetime import datetime, date, timezone, timedelta
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import or_
|
||||
|
||||
from database.models import (
|
||||
DailyParkingAssignment, User, UserPresence,
|
||||
ParkingGuarantee, ParkingExclusion, ManagerClosingDay, ManagerWeeklyClosingDay
|
||||
DailyParkingAssignment, User, UserPresence, Office,
|
||||
ParkingGuarantee, ParkingExclusion, OfficeClosingDay, OfficeWeeklyClosingDay,
|
||||
UserRole, PresenceStatus
|
||||
)
|
||||
from utils.helpers import generate_uuid
|
||||
from app import config
|
||||
|
||||
|
||||
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:
|
||||
return manager.manager_spot_prefix
|
||||
def get_spot_prefix(office: Office, db: Session) -> str:
|
||||
"""Get the spot prefix for an office (from office.spot_prefix or auto-assign)"""
|
||||
if office.spot_prefix:
|
||||
return office.spot_prefix
|
||||
|
||||
# Auto-assign based on alphabetical order of managers without prefix
|
||||
managers = db.query(User).filter(
|
||||
User.role == "manager",
|
||||
User.manager_spot_prefix == None
|
||||
).order_by(User.name).all()
|
||||
# Auto-assign based on alphabetical order of offices without prefix
|
||||
offices = db.query(Office).filter(
|
||||
Office.spot_prefix == None
|
||||
).order_by(Office.name).all()
|
||||
|
||||
# Find existing prefixes
|
||||
existing_prefixes = set(
|
||||
m.manager_spot_prefix for m in db.query(User).filter(
|
||||
User.role == "manager",
|
||||
User.manager_spot_prefix != None
|
||||
o.spot_prefix for o in db.query(Office).filter(
|
||||
Office.spot_prefix != None
|
||||
).all()
|
||||
)
|
||||
|
||||
# Find first available letter
|
||||
manager_index = next((i for i, m in enumerate(managers) if m.id == manager.id), 0)
|
||||
office_index = next((i for i, o in enumerate(offices) if o.id == office.id), 0)
|
||||
letter = 'A'
|
||||
count = 0
|
||||
while letter in existing_prefixes or count < manager_index:
|
||||
while letter in existing_prefixes or count < office_index:
|
||||
if letter not in existing_prefixes:
|
||||
count += 1
|
||||
letter = chr(ord(letter) + 1)
|
||||
@@ -54,55 +53,58 @@ def get_spot_prefix(manager: User, db: Session) -> str:
|
||||
return letter
|
||||
|
||||
|
||||
def get_spot_display_name(spot_id: str, manager_id: str, db: Session) -> str:
|
||||
def get_spot_display_name(spot_id: str, office_id: str, db: Session) -> str:
|
||||
"""Get display name for a spot (e.g., 'A3' instead of 'spot-3')"""
|
||||
manager = db.query(User).filter(User.id == manager_id).first()
|
||||
if not manager:
|
||||
office = db.query(Office).filter(Office.id == office_id).first()
|
||||
if not office:
|
||||
return spot_id
|
||||
|
||||
prefix = get_spot_prefix(manager, db)
|
||||
prefix = get_spot_prefix(office, db)
|
||||
spot_number = spot_id.replace("spot-", "")
|
||||
return f"{prefix}{spot_number}"
|
||||
|
||||
|
||||
def is_closing_day(manager_id: str, date: str, db: Session) -> bool:
|
||||
def is_closing_day(office_id: str, check_date: date, db: Session) -> bool:
|
||||
"""
|
||||
Check if date is a closing day for this manager.
|
||||
Check if date is a closing day for this office.
|
||||
Checks both specific closing days and weekly recurring closing days.
|
||||
"""
|
||||
# Check specific closing day
|
||||
specific = db.query(ManagerClosingDay).filter(
|
||||
ManagerClosingDay.manager_id == manager_id,
|
||||
ManagerClosingDay.date == date
|
||||
# Check specific closing day (single day or range)
|
||||
specific = db.query(OfficeClosingDay).filter(
|
||||
OfficeClosingDay.office_id == office_id,
|
||||
or_(
|
||||
OfficeClosingDay.date == check_date,
|
||||
(OfficeClosingDay.end_date != None) & (OfficeClosingDay.date <= check_date) & (OfficeClosingDay.end_date >= check_date)
|
||||
)
|
||||
).first()
|
||||
if specific:
|
||||
return True
|
||||
|
||||
# Check weekly closing day
|
||||
date_obj = datetime.strptime(date, "%Y-%m-%d")
|
||||
weekday = date_obj.weekday() # 0=Monday in Python
|
||||
# Convert to our format: 0=Sunday, 1=Monday, ..., 6=Saturday
|
||||
weekday_sunday_start = (weekday + 1) % 7
|
||||
# Python: 0=Monday, 6=Sunday
|
||||
# DB/API: 0=Sunday, 1=Monday... (Legacy convention)
|
||||
python_weekday = check_date.weekday()
|
||||
db_weekday = (python_weekday + 1) % 7
|
||||
|
||||
weekly = db.query(ManagerWeeklyClosingDay).filter(
|
||||
ManagerWeeklyClosingDay.manager_id == manager_id,
|
||||
ManagerWeeklyClosingDay.weekday == weekday_sunday_start
|
||||
weekly = db.query(OfficeWeeklyClosingDay).filter(
|
||||
OfficeWeeklyClosingDay.office_id == office_id,
|
||||
OfficeWeeklyClosingDay.weekday == db_weekday
|
||||
).first()
|
||||
|
||||
return weekly is not None
|
||||
|
||||
|
||||
def initialize_parking_pool(manager_id: str, quota: int, date: str, db: Session) -> int:
|
||||
"""Initialize empty parking spots for a manager's pool on a given date.
|
||||
def initialize_parking_pool(office_id: str, quota: int, pool_date: date, db: Session) -> int:
|
||||
"""Initialize empty parking spots for an office's pool on a given date.
|
||||
Returns 0 if it's a closing day (no parking available).
|
||||
"""
|
||||
# Don't create pool on closing days
|
||||
if is_closing_day(manager_id, date, db):
|
||||
if is_closing_day(office_id, pool_date, db):
|
||||
return 0
|
||||
|
||||
existing = db.query(DailyParkingAssignment).filter(
|
||||
DailyParkingAssignment.manager_id == manager_id,
|
||||
DailyParkingAssignment.date == date
|
||||
DailyParkingAssignment.office_id == office_id,
|
||||
DailyParkingAssignment.date == pool_date
|
||||
).count()
|
||||
|
||||
if existing > 0:
|
||||
@@ -111,20 +113,20 @@ def initialize_parking_pool(manager_id: str, quota: int, date: str, db: Session)
|
||||
for i in range(1, quota + 1):
|
||||
spot = DailyParkingAssignment(
|
||||
id=generate_uuid(),
|
||||
date=date,
|
||||
date=pool_date,
|
||||
spot_id=f"spot-{i}",
|
||||
user_id=None,
|
||||
manager_id=manager_id,
|
||||
created_at=datetime.now(timezone.utc).isoformat()
|
||||
office_id=office_id,
|
||||
created_at=datetime.now(timezone.utc)
|
||||
)
|
||||
db.add(spot)
|
||||
|
||||
db.commit()
|
||||
config.logger.debug(f"Initialized {quota} parking spots for manager {manager_id} on {date}")
|
||||
config.logger.debug(f"Initialized {quota} parking spots for office {office_id} on {pool_date}")
|
||||
return quota
|
||||
|
||||
|
||||
def get_user_parking_ratio(user_id: str, manager_id: str, db: Session) -> float:
|
||||
def get_user_parking_ratio(user_id: str, office_id: str, db: Session) -> float:
|
||||
"""
|
||||
Calculate user's parking ratio: parking_days / presence_days
|
||||
Lower ratio = higher priority for next parking spot
|
||||
@@ -132,7 +134,7 @@ def get_user_parking_ratio(user_id: str, manager_id: str, db: Session) -> float:
|
||||
# Count days user was present
|
||||
presence_days = db.query(UserPresence).filter(
|
||||
UserPresence.user_id == user_id,
|
||||
UserPresence.status == "present"
|
||||
UserPresence.status == PresenceStatus.PRESENT
|
||||
).count()
|
||||
|
||||
if presence_days == 0:
|
||||
@@ -141,16 +143,16 @@ def get_user_parking_ratio(user_id: str, manager_id: str, db: Session) -> float:
|
||||
# Count days user got parking
|
||||
parking_days = db.query(DailyParkingAssignment).filter(
|
||||
DailyParkingAssignment.user_id == user_id,
|
||||
DailyParkingAssignment.manager_id == manager_id
|
||||
DailyParkingAssignment.office_id == office_id
|
||||
).count()
|
||||
|
||||
return parking_days / presence_days
|
||||
|
||||
|
||||
def is_user_excluded(user_id: str, manager_id: str, date: str, db: Session) -> bool:
|
||||
def is_user_excluded(user_id: str, office_id: str, check_date: date, db: Session) -> bool:
|
||||
"""Check if user is excluded from parking for this date"""
|
||||
exclusion = db.query(ParkingExclusion).filter(
|
||||
ParkingExclusion.manager_id == manager_id,
|
||||
ParkingExclusion.office_id == office_id,
|
||||
ParkingExclusion.user_id == user_id
|
||||
).first()
|
||||
|
||||
@@ -158,18 +160,18 @@ def is_user_excluded(user_id: str, manager_id: str, date: str, db: Session) -> b
|
||||
return False
|
||||
|
||||
# Check date range
|
||||
if exclusion.start_date and date < exclusion.start_date:
|
||||
if exclusion.start_date and check_date < exclusion.start_date:
|
||||
return False
|
||||
if exclusion.end_date and date > exclusion.end_date:
|
||||
if exclusion.end_date and check_date > exclusion.end_date:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def has_guarantee(user_id: str, manager_id: str, date: str, db: Session) -> bool:
|
||||
def has_guarantee(user_id: str, office_id: str, check_date: date, db: Session) -> bool:
|
||||
"""Check if user has a parking guarantee for this date"""
|
||||
guarantee = db.query(ParkingGuarantee).filter(
|
||||
ParkingGuarantee.manager_id == manager_id,
|
||||
ParkingGuarantee.office_id == office_id,
|
||||
ParkingGuarantee.user_id == user_id
|
||||
).first()
|
||||
|
||||
@@ -177,28 +179,25 @@ def has_guarantee(user_id: str, manager_id: str, date: str, db: Session) -> bool
|
||||
return False
|
||||
|
||||
# Check date range
|
||||
if guarantee.start_date and date < guarantee.start_date:
|
||||
if guarantee.start_date and check_date < guarantee.start_date:
|
||||
return False
|
||||
if guarantee.end_date and date > guarantee.end_date:
|
||||
if guarantee.end_date and check_date > guarantee.end_date:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def get_users_wanting_parking(manager_id: str, date: str, db: Session) -> list[dict]:
|
||||
def get_users_wanting_parking(office_id: str, pool_date: date, db: Session) -> list[dict]:
|
||||
"""
|
||||
Get all users who want parking for this date, sorted by fairness priority.
|
||||
Returns list of {user_id, has_guarantee, ratio}
|
||||
|
||||
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)
|
||||
# - Users belonging to this office
|
||||
present_users = db.query(UserPresence).join(User).filter(
|
||||
UserPresence.date == date,
|
||||
UserPresence.status == "present",
|
||||
or_(User.manager_id == manager_id, User.id == manager_id)
|
||||
UserPresence.date == pool_date,
|
||||
UserPresence.status == PresenceStatus.PRESENT,
|
||||
User.office_id == office_id
|
||||
).all()
|
||||
|
||||
candidates = []
|
||||
@@ -206,12 +205,12 @@ def get_users_wanting_parking(manager_id: str, date: str, db: Session) -> list[d
|
||||
user_id = presence.user_id
|
||||
|
||||
# Skip excluded users
|
||||
if is_user_excluded(user_id, manager_id, date, db):
|
||||
if is_user_excluded(user_id, office_id, pool_date, db):
|
||||
continue
|
||||
|
||||
# Skip users who already have a spot
|
||||
existing = db.query(DailyParkingAssignment).filter(
|
||||
DailyParkingAssignment.date == date,
|
||||
DailyParkingAssignment.date == pool_date,
|
||||
DailyParkingAssignment.user_id == user_id
|
||||
).first()
|
||||
if existing:
|
||||
@@ -219,8 +218,8 @@ def get_users_wanting_parking(manager_id: str, date: str, db: Session) -> list[d
|
||||
|
||||
candidates.append({
|
||||
"user_id": user_id,
|
||||
"has_guarantee": has_guarantee(user_id, manager_id, date, db),
|
||||
"ratio": get_user_parking_ratio(user_id, manager_id, db)
|
||||
"has_guarantee": has_guarantee(user_id, office_id, pool_date, db),
|
||||
"ratio": get_user_parking_ratio(user_id, office_id, db)
|
||||
})
|
||||
|
||||
# Sort: guaranteed users first, then by ratio (lowest first for fairness)
|
||||
@@ -229,30 +228,30 @@ def get_users_wanting_parking(manager_id: str, date: str, db: Session) -> list[d
|
||||
return candidates
|
||||
|
||||
|
||||
def assign_parking_fairly(manager_id: str, date: str, db: Session) -> dict:
|
||||
def assign_parking_fairly(office_id: str, pool_date: date, db: Session) -> dict:
|
||||
"""
|
||||
Assign parking spots fairly based on parking ratio.
|
||||
Called after presence is set for a date.
|
||||
Returns {assigned: [...], waitlist: [...]}
|
||||
"""
|
||||
manager = db.query(User).filter(User.id == manager_id).first()
|
||||
if not manager or not manager.manager_parking_quota:
|
||||
office = db.query(Office).filter(Office.id == office_id).first()
|
||||
if not office or not office.parking_quota:
|
||||
return {"assigned": [], "waitlist": []}
|
||||
|
||||
# No parking on closing days
|
||||
if is_closing_day(manager_id, date, db):
|
||||
if is_closing_day(office_id, pool_date, db):
|
||||
return {"assigned": [], "waitlist": [], "closed": True}
|
||||
|
||||
# Initialize pool
|
||||
initialize_parking_pool(manager_id, manager.manager_parking_quota, date, db)
|
||||
initialize_parking_pool(office_id, office.parking_quota, pool_date, db)
|
||||
|
||||
# Get candidates sorted by fairness
|
||||
candidates = get_users_wanting_parking(manager_id, date, db)
|
||||
candidates = get_users_wanting_parking(office_id, pool_date, db)
|
||||
|
||||
# Get available spots
|
||||
free_spots = db.query(DailyParkingAssignment).filter(
|
||||
DailyParkingAssignment.manager_id == manager_id,
|
||||
DailyParkingAssignment.date == date,
|
||||
DailyParkingAssignment.office_id == office_id,
|
||||
DailyParkingAssignment.date == pool_date,
|
||||
DailyParkingAssignment.user_id == None
|
||||
).all()
|
||||
|
||||
@@ -272,11 +271,11 @@ def assign_parking_fairly(manager_id: str, date: str, db: Session) -> dict:
|
||||
return {"assigned": assigned, "waitlist": waitlist}
|
||||
|
||||
|
||||
def release_user_spot(manager_id: str, user_id: str, date: str, db: Session) -> bool:
|
||||
def release_user_spot(office_id: str, user_id: str, pool_date: date, db: Session) -> bool:
|
||||
"""Release a user's parking spot and reassign to next in fairness queue"""
|
||||
assignment = db.query(DailyParkingAssignment).filter(
|
||||
DailyParkingAssignment.manager_id == manager_id,
|
||||
DailyParkingAssignment.date == date,
|
||||
DailyParkingAssignment.office_id == office_id,
|
||||
DailyParkingAssignment.date == pool_date,
|
||||
DailyParkingAssignment.user_id == user_id
|
||||
).first()
|
||||
|
||||
@@ -288,7 +287,7 @@ def release_user_spot(manager_id: str, user_id: str, date: str, db: Session) ->
|
||||
db.commit()
|
||||
|
||||
# Try to assign to next user in fairness queue
|
||||
candidates = get_users_wanting_parking(manager_id, date, db)
|
||||
candidates = get_users_wanting_parking(office_id, pool_date, db)
|
||||
if candidates:
|
||||
assignment.user_id = candidates[0]["user_id"]
|
||||
db.commit()
|
||||
@@ -296,29 +295,74 @@ 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, manager_id: str, db: Session):
|
||||
def handle_presence_change(user_id: str, change_date: date, old_status: PresenceStatus, new_status: PresenceStatus, office_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():
|
||||
if change_date < datetime.utcnow().date():
|
||||
return
|
||||
|
||||
# Get manager
|
||||
manager = db.query(User).filter(User.id == manager_id, User.role == "manager").first()
|
||||
if not manager or not manager.manager_parking_quota:
|
||||
# Get office (must be valid)
|
||||
office = db.query(Office).filter(Office.id == office_id).first()
|
||||
if not office or not office.parking_quota:
|
||||
return
|
||||
|
||||
# Initialize pool if needed
|
||||
initialize_parking_pool(manager.id, manager.manager_parking_quota, date, db)
|
||||
initialize_parking_pool(office.id, office.parking_quota, change_date, db)
|
||||
|
||||
if old_status == "present" and new_status in ["remote", "absent"]:
|
||||
if old_status == PresenceStatus.PRESENT and new_status in [PresenceStatus.REMOTE, PresenceStatus.ABSENT]:
|
||||
# User no longer coming - release their spot (will auto-reassign)
|
||||
release_user_spot(manager.id, user_id, date, db)
|
||||
release_user_spot(office.id, user_id, change_date, db)
|
||||
|
||||
elif new_status == "present":
|
||||
# User coming in - run fair assignment for this date
|
||||
assign_parking_fairly(manager.id, date, db)
|
||||
elif new_status == PresenceStatus.PRESENT:
|
||||
# Check booking window
|
||||
should_assign = True
|
||||
if office.booking_window_enabled:
|
||||
# Allocation time is Day-1 at cutoff hour
|
||||
cutoff_dt = datetime.combine(change_date - timedelta(days=1), datetime.min.time())
|
||||
cutoff_dt = cutoff_dt.replace(
|
||||
hour=office.booking_window_end_hour,
|
||||
minute=office.booking_window_end_minute
|
||||
)
|
||||
|
||||
# If now is before cutoff, do not assign yet (wait for batch job)
|
||||
if datetime.utcnow() < cutoff_dt:
|
||||
should_assign = False
|
||||
config.logger.debug(f"Queuing parking request for user {user_id} on {change_date} (Window open until {cutoff_dt})")
|
||||
|
||||
if should_assign:
|
||||
# User coming in - run fair assignment for this date
|
||||
assign_parking_fairly(office.id, change_date, db)
|
||||
|
||||
|
||||
def clear_assignments_for_office_date(office_id: str, pool_date: date, db: Session) -> int:
|
||||
"""
|
||||
Clear all parking assignments for an office on a specific date.
|
||||
Returns number of cleared spots.
|
||||
"""
|
||||
assignments = db.query(DailyParkingAssignment).filter(
|
||||
DailyParkingAssignment.office_id == office_id,
|
||||
DailyParkingAssignment.date == pool_date,
|
||||
DailyParkingAssignment.user_id != None
|
||||
).all()
|
||||
|
||||
count = len(assignments)
|
||||
for a in assignments:
|
||||
a.user_id = None
|
||||
|
||||
db.commit()
|
||||
return count
|
||||
|
||||
|
||||
def run_batch_allocation(office_id: str, pool_date: date, db: Session) -> dict:
|
||||
"""
|
||||
Run the batch allocation for a specific date.
|
||||
Force clears existing assignments to ensure a fair clean-slate allocation.
|
||||
"""
|
||||
# 1. Clear existing assignments
|
||||
clear_assignments_for_office_date(office_id, pool_date, db)
|
||||
|
||||
# 2. Run fair allocation
|
||||
return assign_parking_fairly(office_id, pool_date, db)
|
||||
|
||||
Reference in New Issue
Block a user