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
394 lines
13 KiB
Python
394 lines
13 KiB
Python
"""
|
|
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
|
|
)
|
|
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)
|