"""
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"""
Presence Reminder
Hi {user.name},
This is a friendly reminder to fill your presence for the upcoming week
({start_date} - {end_date}).
Please log in to the Parking Manager to mark your presence.
Best regards,
Parking Manager
"""
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"{day_name}, {date_obj.strftime('%B %d')}: Spot {spot_name}")
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"""
Weekly Parking Summary
Hi {user.name},
Here are your parking spot assignments for the upcoming week:
{''.join(assignment_lines)}
Parking assignments are now frozen. You can still release or reassign your spots if needed.
Best regards,
Parking Manager
"""
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"""
Daily Parking Reminder
Hi {user.name},
You have a parking spot assigned for today ({day_name}):
Spot {spot_name}
Best regards,
Parking Manager
"""
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"""
Parking Spot Assigned
Hi {user.name},
You have been assigned a parking spot for {day_name}:
Spot {spot_name}
Best regards,
Parking Manager
"""
elif change_type == "released":
subject = f"Parking spot released for {day_name}"
body = f"""
Parking Spot Released
Hi {user.name},
Your parking spot (Spot {spot_name}) for {day_name} has been released.
Best regards,
Parking Manager
"""
elif change_type == "reassigned":
subject = f"Parking spot reassigned for {day_name}"
body = f"""
Parking Spot Reassigned
Hi {user.name},
Your parking spot (Spot {spot_name}) for {day_name} has been reassigned to {new_user_name}.
Best regards,
Parking Manager
"""
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)