fix landing page
This commit is contained in:
@@ -1,105 +1,170 @@
|
||||
"""
|
||||
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)
|
||||
Handles email notifications for presence reminders and parking assignments.
|
||||
Follows org-stack pattern: direct SMTP send with file fallback when disabled.
|
||||
"""
|
||||
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 typing import TYPE_CHECKING
|
||||
|
||||
from database.models import (
|
||||
User, UserPresence, DailyParkingAssignment,
|
||||
NotificationLog, NotificationQueue
|
||||
)
|
||||
from services.parking import get_spot_display_name
|
||||
from app import config
|
||||
from utils.helpers import generate_uuid
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from sqlalchemy.orm import Session
|
||||
from database.models import User
|
||||
|
||||
|
||||
# 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 log_email_to_file(to: str, subject: str, body: str):
|
||||
"""Log email to file when SMTP is disabled (org-stack pattern)"""
|
||||
timestamp = datetime.now().isoformat()
|
||||
try:
|
||||
with open(config.EMAIL_LOG_FILE, 'a') as f:
|
||||
f.write(f'\n{"="*80}\n')
|
||||
f.write(f'Timestamp: {timestamp}\n')
|
||||
f.write(f'To: {to}\n')
|
||||
f.write(f'Subject: {subject}\n')
|
||||
f.write(f'Body:\n{body}\n')
|
||||
f.write(f'{"="*80}\n')
|
||||
config.logger.info(f"[EMAIL] Logged to {config.EMAIL_LOG_FILE}: {to} - {subject}")
|
||||
except Exception as e:
|
||||
config.logger.error(f"[EMAIL] Failed to log email: {e}")
|
||||
|
||||
|
||||
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
|
||||
def send_email(to_email: str, subject: str, body_html: str, body_text: str = None) -> bool:
|
||||
"""
|
||||
Send an email via SMTP or log to file if SMTP disabled.
|
||||
Returns True if sent/logged successfully, False otherwise.
|
||||
"""
|
||||
# Extract plain text from HTML if not provided
|
||||
if not body_text:
|
||||
import re
|
||||
body_text = re.sub('<[^<]+?>', '', body_html)
|
||||
|
||||
if not config.SMTP_ENABLED:
|
||||
log_email_to_file(to_email, subject, body_text)
|
||||
return True
|
||||
|
||||
try:
|
||||
msg = MIMEMultipart("alternative")
|
||||
msg["Subject"] = subject
|
||||
msg["From"] = SMTP_FROM
|
||||
msg["From"] = config.SMTP_FROM
|
||||
msg["To"] = to_email
|
||||
|
||||
if body_text:
|
||||
msg.attach(MIMEText(body_text, "plain"))
|
||||
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:
|
||||
with smtplib.SMTP(config.SMTP_HOST, config.SMTP_PORT) as server:
|
||||
if config.SMTP_USE_TLS:
|
||||
server.starttls()
|
||||
server.login(SMTP_USER, SMTP_PASSWORD)
|
||||
server.sendmail(SMTP_FROM, to_email, msg.as_string())
|
||||
if config.SMTP_USER and config.SMTP_PASSWORD:
|
||||
server.login(config.SMTP_USER, config.SMTP_PASSWORD)
|
||||
server.send_message(msg)
|
||||
|
||||
print(f"[NOTIFICATION] Email sent to {to_email}: {subject}")
|
||||
config.logger.info(f"[EMAIL] Sent to {to_email}: {subject}")
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"[NOTIFICATION] Failed to send email to {to_email}: {e}")
|
||||
config.logger.error(f"[EMAIL] Failed to send to {to_email}: {e}")
|
||||
# Fallback to file logging
|
||||
log_email_to_file(to_email, subject, body_text)
|
||||
return False
|
||||
|
||||
|
||||
def get_week_dates(reference_date: datetime):
|
||||
def get_week_dates(reference_date: datetime) -> list[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):
|
||||
def get_next_week_dates(reference_date: datetime) -> list[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:
|
||||
# =============================================================================
|
||||
# Notification sending functions
|
||||
# =============================================================================
|
||||
|
||||
def notify_parking_assigned(user: "User", date: str, 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")
|
||||
|
||||
subject = f"Parking spot assigned for {day_name}"
|
||||
body_html = 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>
|
||||
"""
|
||||
send_email(user.email, subject, body_html)
|
||||
|
||||
|
||||
def notify_parking_released(user: "User", date: str, 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")
|
||||
|
||||
subject = f"Parking spot released for {day_name}"
|
||||
body_html = 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>
|
||||
"""
|
||||
send_email(user.email, subject, body_html)
|
||||
|
||||
|
||||
def notify_parking_reassigned(user: "User", date: str, 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")
|
||||
|
||||
subject = f"Parking spot reassigned for {day_name}"
|
||||
body_html = 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>
|
||||
"""
|
||||
send_email(user.email, subject, body_html)
|
||||
|
||||
|
||||
def send_presence_reminder(user: "User", next_week_dates: list[datetime], 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
|
||||
@@ -112,11 +177,17 @@ def send_presence_reminder(user: User, next_week_dates: list, db: Session) -> bo
|
||||
).first()
|
||||
|
||||
if existing:
|
||||
return False # Already sent today
|
||||
return False
|
||||
|
||||
# Check if week is compiled
|
||||
if check_week_presence_compiled(user.id, next_week_dates, db):
|
||||
return False # Already compiled
|
||||
# Check if week is compiled (at least 5 days marked)
|
||||
date_strs = [d.strftime("%Y-%m-%d") for d in next_week_dates]
|
||||
presences = db.query(UserPresence).filter(
|
||||
UserPresence.user_id == user.id,
|
||||
UserPresence.date.in_(date_strs)
|
||||
).all()
|
||||
|
||||
if len(presences) >= 5:
|
||||
return False
|
||||
|
||||
# Send reminder
|
||||
start_date = next_week_dates[0].strftime("%B %d")
|
||||
@@ -137,9 +208,8 @@ def send_presence_reminder(user: User, next_week_dates: list, db: Session) -> bo
|
||||
"""
|
||||
|
||||
if send_email(user.email, subject, body_html):
|
||||
# Log the notification
|
||||
log = NotificationLog(
|
||||
id=str(uuid.uuid4()),
|
||||
id=generate_uuid(),
|
||||
user_id=user.id,
|
||||
notification_type="presence_reminder",
|
||||
reference_date=week_ref,
|
||||
@@ -152,8 +222,11 @@ def send_presence_reminder(user: User, next_week_dates: list, db: Session) -> bo
|
||||
return False
|
||||
|
||||
|
||||
def send_weekly_parking_summary(user: User, next_week_dates: list, db: Session) -> bool:
|
||||
def send_weekly_parking_summary(user: "User", next_week_dates: list[datetime], 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
|
||||
|
||||
if not user.notify_weekly_parking:
|
||||
return False
|
||||
|
||||
@@ -177,7 +250,7 @@ def send_weekly_parking_summary(user: User, next_week_dates: list, db: Session)
|
||||
).all()
|
||||
|
||||
if not assignments:
|
||||
return False # No assignments, no need to notify
|
||||
return False
|
||||
|
||||
# Build assignment list
|
||||
assignment_lines = []
|
||||
@@ -208,7 +281,7 @@ def send_weekly_parking_summary(user: User, next_week_dates: list, db: Session)
|
||||
|
||||
if send_email(user.email, subject, body_html):
|
||||
log = NotificationLog(
|
||||
id=str(uuid.uuid4()),
|
||||
id=generate_uuid(),
|
||||
user_id=user.id,
|
||||
notification_type="weekly_parking",
|
||||
reference_date=week_ref,
|
||||
@@ -221,8 +294,11 @@ def send_weekly_parking_summary(user: User, next_week_dates: list, db: Session)
|
||||
return False
|
||||
|
||||
|
||||
def send_daily_parking_reminder(user: User, date: datetime, db: Session) -> bool:
|
||||
def send_daily_parking_reminder(user: "User", date: 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
|
||||
|
||||
if not user.notify_daily_parking:
|
||||
return False
|
||||
|
||||
@@ -245,10 +321,9 @@ def send_daily_parking_reminder(user: User, date: datetime, db: Session) -> bool
|
||||
).first()
|
||||
|
||||
if not assignment:
|
||||
return False # No assignment today
|
||||
return False
|
||||
|
||||
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}"
|
||||
@@ -266,7 +341,7 @@ def send_daily_parking_reminder(user: User, date: datetime, db: Session) -> bool
|
||||
|
||||
if send_email(user.email, subject, body_html):
|
||||
log = NotificationLog(
|
||||
id=str(uuid.uuid4()),
|
||||
id=generate_uuid(),
|
||||
user_id=user.id,
|
||||
notification_type="daily_parking",
|
||||
reference_date=date_str,
|
||||
@@ -279,92 +354,18 @@ def send_daily_parking_reminder(user: User, date: datetime, db: Session) -> bool
|
||||
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
|
||||
def run_scheduled_notifications(db: "Session"):
|
||||
"""
|
||||
Run all scheduled notifications - called by a scheduler/cron job.
|
||||
|
||||
date_obj = datetime.strptime(date, "%Y-%m-%d")
|
||||
day_name = date_obj.strftime("%A, %B %d")
|
||||
Schedule:
|
||||
- Thursday at 12:00: Presence reminder for next week
|
||||
- Friday at 12:00: Weekly parking summary
|
||||
- Daily at user's preferred time: Daily parking reminder (Mon-Fri)
|
||||
"""
|
||||
from database.models import User
|
||||
|
||||
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
|
||||
@@ -372,7 +373,7 @@ def run_scheduled_notifications(db: Session):
|
||||
users = db.query(User).all()
|
||||
|
||||
for user in users:
|
||||
# Thursday at 12: Presence reminder (unmanageable)
|
||||
# Thursday at 12: Presence reminder
|
||||
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)
|
||||
@@ -388,6 +389,3 @@ def run_scheduled_notifications(db: Session):
|
||||
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)
|
||||
|
||||
@@ -8,15 +8,16 @@ Key concepts:
|
||||
- Spots are named like A1, A2, B1, B2 based on manager prefix
|
||||
- Fairness: users with lowest parking_days/presence_days ratio get priority
|
||||
"""
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import func, or_
|
||||
from sqlalchemy import or_
|
||||
|
||||
from database.models import (
|
||||
DailyParkingAssignment, User, UserPresence,
|
||||
ParkingGuarantee, ParkingExclusion, ManagerClosingDay, ManagerWeeklyClosingDay
|
||||
)
|
||||
from utils.helpers import generate_uuid
|
||||
from app import config
|
||||
|
||||
|
||||
def get_spot_prefix(manager: User, db: Session) -> str:
|
||||
@@ -109,7 +110,7 @@ def initialize_parking_pool(manager_id: str, quota: int, date: str, db: Session)
|
||||
|
||||
for i in range(1, quota + 1):
|
||||
spot = DailyParkingAssignment(
|
||||
id=str(uuid.uuid4()),
|
||||
id=generate_uuid(),
|
||||
date=date,
|
||||
spot_id=f"spot-{i}",
|
||||
user_id=None,
|
||||
@@ -119,6 +120,7 @@ def initialize_parking_pool(manager_id: str, quota: int, date: str, db: Session)
|
||||
db.add(spot)
|
||||
|
||||
db.commit()
|
||||
config.logger.debug(f"Initialized {quota} parking spots for manager {manager_id} on {date}")
|
||||
return quota
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user