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:
393
services/notifications.py
Normal file
393
services/notifications.py
Normal file
@@ -0,0 +1,393 @@
|
||||
"""
|
||||
Notification Service
|
||||
Handles email notifications for presence reminders and parking assignments
|
||||
|
||||
TODO: This service is NOT YET ACTIVE. To enable notifications:
|
||||
1. Add APScheduler or similar to run run_scheduled_notifications() periodically
|
||||
2. Configure SMTP environment variables (SMTP_HOST, SMTP_USER, SMTP_PASSWORD, SMTP_FROM)
|
||||
3. Notifications will be sent for:
|
||||
- Presence reminders (Thursday at 12:00)
|
||||
- Weekly parking summary (Friday at 12:00)
|
||||
- Daily parking reminders (at user's preferred time)
|
||||
- Immediate parking change notifications (via queue)
|
||||
"""
|
||||
import smtplib
|
||||
import os
|
||||
from email.mime.text import MIMEText
|
||||
from email.mime.multipart import MIMEMultipart
|
||||
from datetime import datetime, timedelta
|
||||
from sqlalchemy.orm import Session
|
||||
import uuid
|
||||
|
||||
from database.models import (
|
||||
User, UserPresence, DailyParkingAssignment,
|
||||
NotificationLog, NotificationQueue, OfficeMembership
|
||||
)
|
||||
from services.parking import get_spot_display_name
|
||||
|
||||
|
||||
# Email configuration (from environment variables)
|
||||
SMTP_HOST = os.getenv("SMTP_HOST", "localhost")
|
||||
SMTP_PORT = int(os.getenv("SMTP_PORT", "587"))
|
||||
SMTP_USER = os.getenv("SMTP_USER", "")
|
||||
SMTP_PASSWORD = os.getenv("SMTP_PASSWORD", "")
|
||||
SMTP_FROM = os.getenv("SMTP_FROM", "noreply@parkingmanager.local")
|
||||
SMTP_USE_TLS = os.getenv("SMTP_USE_TLS", "true").lower() == "true"
|
||||
|
||||
|
||||
def send_email(to_email: str, subject: str, body_html: str, body_text: str = None):
|
||||
"""Send an email"""
|
||||
if not SMTP_USER or not SMTP_PASSWORD:
|
||||
print(f"[NOTIFICATION] Email not configured. Would send to {to_email}: {subject}")
|
||||
return False
|
||||
|
||||
try:
|
||||
msg = MIMEMultipart("alternative")
|
||||
msg["Subject"] = subject
|
||||
msg["From"] = SMTP_FROM
|
||||
msg["To"] = to_email
|
||||
|
||||
if body_text:
|
||||
msg.attach(MIMEText(body_text, "plain"))
|
||||
msg.attach(MIMEText(body_html, "html"))
|
||||
|
||||
with smtplib.SMTP(SMTP_HOST, SMTP_PORT) as server:
|
||||
if SMTP_USE_TLS:
|
||||
server.starttls()
|
||||
server.login(SMTP_USER, SMTP_PASSWORD)
|
||||
server.sendmail(SMTP_FROM, to_email, msg.as_string())
|
||||
|
||||
print(f"[NOTIFICATION] Email sent to {to_email}: {subject}")
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"[NOTIFICATION] Failed to send email to {to_email}: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def get_week_dates(reference_date: datetime):
|
||||
"""Get Monday-Sunday dates for the week containing reference_date"""
|
||||
# Find Monday of this week
|
||||
monday = reference_date - timedelta(days=reference_date.weekday())
|
||||
return [monday + timedelta(days=i) for i in range(7)]
|
||||
|
||||
|
||||
def get_next_week_dates(reference_date: datetime):
|
||||
"""Get Monday-Sunday dates for the week after reference_date"""
|
||||
# Find Monday of next week
|
||||
days_until_next_monday = 7 - reference_date.weekday()
|
||||
next_monday = reference_date + timedelta(days=days_until_next_monday)
|
||||
return [next_monday + timedelta(days=i) for i in range(7)]
|
||||
|
||||
|
||||
def check_week_presence_compiled(user_id: str, week_dates: list, db: Session) -> bool:
|
||||
"""Check if user has filled presence for all working days in a week"""
|
||||
date_strs = [d.strftime("%Y-%m-%d") for d in week_dates]
|
||||
|
||||
presences = db.query(UserPresence).filter(
|
||||
UserPresence.user_id == user_id,
|
||||
UserPresence.date.in_(date_strs)
|
||||
).all()
|
||||
|
||||
# Consider week compiled if at least 5 days have presence marked
|
||||
# (allowing for weekends or holidays)
|
||||
return len(presences) >= 5
|
||||
|
||||
|
||||
def get_week_reference(date: datetime) -> str:
|
||||
"""Get ISO week reference string (e.g., 2024-W48)"""
|
||||
return date.strftime("%Y-W%W")
|
||||
|
||||
|
||||
def send_presence_reminder(user: User, next_week_dates: list, db: Session) -> bool:
|
||||
"""Send presence compilation reminder for next week"""
|
||||
week_ref = get_week_reference(next_week_dates[0])
|
||||
|
||||
# Check if already sent today for this week
|
||||
today = datetime.now().strftime("%Y-%m-%d")
|
||||
existing = db.query(NotificationLog).filter(
|
||||
NotificationLog.user_id == user.id,
|
||||
NotificationLog.notification_type == "presence_reminder",
|
||||
NotificationLog.reference_date == week_ref,
|
||||
NotificationLog.sent_at >= today
|
||||
).first()
|
||||
|
||||
if existing:
|
||||
return False # Already sent today
|
||||
|
||||
# Check if week is compiled
|
||||
if check_week_presence_compiled(user.id, next_week_dates, db):
|
||||
return False # Already compiled
|
||||
|
||||
# Send reminder
|
||||
start_date = next_week_dates[0].strftime("%B %d")
|
||||
end_date = next_week_dates[-1].strftime("%B %d, %Y")
|
||||
|
||||
subject = f"Reminder: Please fill your presence for {start_date} - {end_date}"
|
||||
body_html = f"""
|
||||
<html>
|
||||
<body>
|
||||
<h2>Presence Reminder</h2>
|
||||
<p>Hi {user.name},</p>
|
||||
<p>This is a friendly reminder to fill your presence for the upcoming week
|
||||
({start_date} - {end_date}).</p>
|
||||
<p>Please log in to the Parking Manager to mark your presence.</p>
|
||||
<p>Best regards,<br>Parking Manager</p>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
if send_email(user.email, subject, body_html):
|
||||
# Log the notification
|
||||
log = NotificationLog(
|
||||
id=str(uuid.uuid4()),
|
||||
user_id=user.id,
|
||||
notification_type="presence_reminder",
|
||||
reference_date=week_ref,
|
||||
sent_at=datetime.now().isoformat()
|
||||
)
|
||||
db.add(log)
|
||||
db.commit()
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def send_weekly_parking_summary(user: User, next_week_dates: list, db: Session) -> bool:
|
||||
"""Send weekly parking assignment summary for next week (Friday at 12)"""
|
||||
if not user.notify_weekly_parking:
|
||||
return False
|
||||
|
||||
week_ref = get_week_reference(next_week_dates[0])
|
||||
|
||||
# Check if already sent for this week
|
||||
existing = db.query(NotificationLog).filter(
|
||||
NotificationLog.user_id == user.id,
|
||||
NotificationLog.notification_type == "weekly_parking",
|
||||
NotificationLog.reference_date == week_ref
|
||||
).first()
|
||||
|
||||
if existing:
|
||||
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)
|
||||
).all()
|
||||
|
||||
if not assignments:
|
||||
return False # No assignments, no need to notify
|
||||
|
||||
# Build assignment list
|
||||
assignment_lines = []
|
||||
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>")
|
||||
|
||||
start_date = next_week_dates[0].strftime("%B %d")
|
||||
end_date = next_week_dates[-1].strftime("%B %d, %Y")
|
||||
|
||||
subject = f"Your parking spots for {start_date} - {end_date}"
|
||||
body_html = f"""
|
||||
<html>
|
||||
<body>
|
||||
<h2>Weekly Parking Summary</h2>
|
||||
<p>Hi {user.name},</p>
|
||||
<p>Here are your parking spot assignments for the upcoming week:</p>
|
||||
<ul>
|
||||
{''.join(assignment_lines)}
|
||||
</ul>
|
||||
<p>Parking assignments are now frozen. You can still release or reassign your spots if needed.</p>
|
||||
<p>Best regards,<br>Parking Manager</p>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
if send_email(user.email, subject, body_html):
|
||||
log = NotificationLog(
|
||||
id=str(uuid.uuid4()),
|
||||
user_id=user.id,
|
||||
notification_type="weekly_parking",
|
||||
reference_date=week_ref,
|
||||
sent_at=datetime.now().isoformat()
|
||||
)
|
||||
db.add(log)
|
||||
db.commit()
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def send_daily_parking_reminder(user: User, date: datetime, db: Session) -> bool:
|
||||
"""Send daily parking reminder for a specific date"""
|
||||
if not user.notify_daily_parking:
|
||||
return False
|
||||
|
||||
date_str = date.strftime("%Y-%m-%d")
|
||||
|
||||
# Check if already sent for this date
|
||||
existing = db.query(NotificationLog).filter(
|
||||
NotificationLog.user_id == user.id,
|
||||
NotificationLog.notification_type == "daily_parking",
|
||||
NotificationLog.reference_date == date_str
|
||||
).first()
|
||||
|
||||
if existing:
|
||||
return False
|
||||
|
||||
# Get parking assignment for this date
|
||||
assignment = db.query(DailyParkingAssignment).filter(
|
||||
DailyParkingAssignment.user_id == user.id,
|
||||
DailyParkingAssignment.date == date_str
|
||||
).first()
|
||||
|
||||
if not assignment:
|
||||
return False # No assignment today
|
||||
|
||||
spot_name = get_spot_display_name(assignment.spot_id, assignment.manager_id, db)
|
||||
|
||||
day_name = date.strftime("%A, %B %d")
|
||||
|
||||
subject = f"Parking reminder for {day_name}"
|
||||
body_html = f"""
|
||||
<html>
|
||||
<body>
|
||||
<h2>Daily Parking Reminder</h2>
|
||||
<p>Hi {user.name},</p>
|
||||
<p>You have a parking spot assigned for today ({day_name}):</p>
|
||||
<p style="font-size: 18px; font-weight: bold;">Spot {spot_name}</p>
|
||||
<p>Best regards,<br>Parking Manager</p>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
if send_email(user.email, subject, body_html):
|
||||
log = NotificationLog(
|
||||
id=str(uuid.uuid4()),
|
||||
user_id=user.id,
|
||||
notification_type="daily_parking",
|
||||
reference_date=date_str,
|
||||
sent_at=datetime.now().isoformat()
|
||||
)
|
||||
db.add(log)
|
||||
db.commit()
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def queue_parking_change_notification(
|
||||
user: User,
|
||||
date: str,
|
||||
change_type: str, # "assigned", "released", "reassigned"
|
||||
spot_name: str,
|
||||
new_user_name: str = None,
|
||||
db: Session = None
|
||||
):
|
||||
"""Queue an immediate notification for a parking assignment change"""
|
||||
if not user.notify_parking_changes:
|
||||
return
|
||||
|
||||
date_obj = datetime.strptime(date, "%Y-%m-%d")
|
||||
day_name = date_obj.strftime("%A, %B %d")
|
||||
|
||||
if change_type == "assigned":
|
||||
subject = f"Parking spot assigned for {day_name}"
|
||||
body = f"""
|
||||
<html>
|
||||
<body>
|
||||
<h2>Parking Spot Assigned</h2>
|
||||
<p>Hi {user.name},</p>
|
||||
<p>You have been assigned a parking spot for {day_name}:</p>
|
||||
<p style="font-size: 18px; font-weight: bold;">Spot {spot_name}</p>
|
||||
<p>Best regards,<br>Parking Manager</p>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
elif change_type == "released":
|
||||
subject = f"Parking spot released for {day_name}"
|
||||
body = f"""
|
||||
<html>
|
||||
<body>
|
||||
<h2>Parking Spot Released</h2>
|
||||
<p>Hi {user.name},</p>
|
||||
<p>Your parking spot (Spot {spot_name}) for {day_name} has been released.</p>
|
||||
<p>Best regards,<br>Parking Manager</p>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
elif change_type == "reassigned":
|
||||
subject = f"Parking spot reassigned for {day_name}"
|
||||
body = f"""
|
||||
<html>
|
||||
<body>
|
||||
<h2>Parking Spot Reassigned</h2>
|
||||
<p>Hi {user.name},</p>
|
||||
<p>Your parking spot (Spot {spot_name}) for {day_name} has been reassigned to {new_user_name}.</p>
|
||||
<p>Best regards,<br>Parking Manager</p>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
else:
|
||||
return
|
||||
|
||||
# Add to queue
|
||||
notification = NotificationQueue(
|
||||
id=str(uuid.uuid4()),
|
||||
user_id=user.id,
|
||||
notification_type="parking_change",
|
||||
subject=subject,
|
||||
body=body,
|
||||
created_at=datetime.now().isoformat()
|
||||
)
|
||||
db.add(notification)
|
||||
db.commit()
|
||||
|
||||
|
||||
def process_notification_queue(db: Session):
|
||||
"""Process and send all pending notifications in the queue"""
|
||||
pending = db.query(NotificationQueue).filter(
|
||||
NotificationQueue.sent_at.is_(None)
|
||||
).all()
|
||||
|
||||
for notification in pending:
|
||||
user = db.query(User).filter(User.id == notification.user_id).first()
|
||||
if user and send_email(user.email, notification.subject, notification.body):
|
||||
notification.sent_at = datetime.now().isoformat()
|
||||
|
||||
db.commit()
|
||||
|
||||
|
||||
def run_scheduled_notifications(db: Session):
|
||||
"""Run all scheduled notifications - called by a scheduler/cron job"""
|
||||
now = datetime.now()
|
||||
today = now.date()
|
||||
current_hour = now.hour
|
||||
current_minute = now.minute
|
||||
current_weekday = now.weekday() # 0=Monday, 6=Sunday
|
||||
|
||||
users = db.query(User).all()
|
||||
|
||||
for user in users:
|
||||
# Thursday at 12: Presence reminder (unmanageable)
|
||||
if current_weekday == 3 and current_hour == 12 and current_minute < 5:
|
||||
next_week = get_next_week_dates(now)
|
||||
send_presence_reminder(user, next_week, db)
|
||||
|
||||
# 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)
|
||||
send_weekly_parking_summary(user, next_week, db)
|
||||
|
||||
# Daily parking reminder at user's preferred time (working days only)
|
||||
if current_weekday < 5: # Monday to Friday
|
||||
user_hour = user.notify_daily_parking_hour or 8
|
||||
user_minute = user.notify_daily_parking_minute or 0
|
||||
if current_hour == user_hour and abs(current_minute - user_minute) < 5:
|
||||
send_daily_parking_reminder(user, now, db)
|
||||
|
||||
# Process queued notifications
|
||||
process_notification_queue(db)
|
||||
Reference in New Issue
Block a user