✨ add automatic event overlap clearing
Implements a mechanism to automatically clear overlapping events in user calendars. Introduces a new model for email templates, enabling customizable notifications to event organizers when their events are declined due to overlaps. Adds a background task to find overlapping events based on calendar settings and automatically cancels the later event, sending a notification email to the organizer, if configured.
This commit is contained in:
parent
cf95da9d81
commit
e6ab41594d
|
|
@ -3,6 +3,7 @@ arrow==1.3.0
|
|||
asgiref==3.8.1
|
||||
attrs==24.3.0
|
||||
black==24.10.0
|
||||
caldav==1.6.0
|
||||
certifi==2024.12.14
|
||||
charset-normalizer==3.4.1
|
||||
click==8.1.8
|
||||
|
|
@ -10,10 +11,14 @@ Django==5.1.4
|
|||
django-ninja==1.3.0
|
||||
gunicorn==23.0.0
|
||||
h11==0.14.0
|
||||
icalendar==6.3.1
|
||||
ics==0.7.2
|
||||
idna==3.10
|
||||
isort==5.13.2
|
||||
Jinja2==3.1.6
|
||||
lxml==5.4.0
|
||||
Markdown==3.7
|
||||
MarkupSafe==3.0.2
|
||||
mypy-extensions==1.0.0
|
||||
nanodjango==0.9.2
|
||||
packaging==24.2
|
||||
|
|
@ -22,6 +27,8 @@ platformdirs==4.3.6
|
|||
pydantic==2.10.4
|
||||
pydantic_core==2.27.2
|
||||
python-dateutil==2.9.0.post0
|
||||
pytz==2025.2
|
||||
recurring-ical-events==3.8.0
|
||||
requests==2.32.3
|
||||
shortuuid==1.0.13
|
||||
six==1.17.0
|
||||
|
|
@ -29,7 +36,10 @@ sqlparse==0.5.3
|
|||
TatSu==5.12.2
|
||||
types-python-dateutil==2.9.0.20241206
|
||||
typing_extensions==4.12.2
|
||||
tzdata==2025.2
|
||||
urllib3==2.3.0
|
||||
uvicorn==0.34.0
|
||||
validators==0.34.0
|
||||
vobject==0.9.9
|
||||
whitenoise==6.8.2
|
||||
x-wr-timezone==2.0.1
|
||||
|
|
|
|||
|
|
@ -16,6 +16,8 @@ from ics import Calendar as ICS_Calendar, Event as ICS_Event
|
|||
from nanodjango import Django
|
||||
from shortuuid.django_fields import ShortUUIDField
|
||||
|
||||
from clear_overlaps import clear_overlaps as clear
|
||||
|
||||
DEBUG = os.getenv("DJANGO_DEBUG", False)
|
||||
SECRET_KEY = os.getenv("DJANGO_SECRET_KEY") \
|
||||
if os.getenv("DJANGO_SECRET_KEY") \
|
||||
|
|
@ -49,6 +51,34 @@ from ninja.security import HttpBearer, django_auth
|
|||
from ninja.errors import HttpError
|
||||
|
||||
|
||||
@app.admin
|
||||
class EmailTemplate(models.Model):
|
||||
"""
|
||||
Email template model to store email templates for sending notifications.
|
||||
"""
|
||||
id = models.AutoField(primary_key=True)
|
||||
name = models.CharField(max_length=100, unique=True)
|
||||
subject = models.CharField(max_length=200)
|
||||
body = models.TextField(help_text="""\
|
||||
Email template to use for sending notifications about deleted events.<br>
|
||||
If not set, no email will be sent.<br>
|
||||
Jinja2 template syntax can be used in the subject and body.<br>
|
||||
Available variables:<br>
|
||||
- event_name: The name of the event<br>
|
||||
- event_start: The start time of the event<br>
|
||||
- event_end: The end time of the event<br>
|
||||
- event_created: The time the event was created<br>
|
||||
- overlap_event_name: The name of the overlap event<br>
|
||||
- overlap_event_start: The start time of the overlap event<br>
|
||||
- overlap_event_end: The end time of the overlap event<br>
|
||||
- overlap_event_created: The time the overlap event was created<br>
|
||||
- overlap_event_organizer: The organizer of the overlap event<br>
|
||||
- calendar_name: The name of the calendar""")
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
|
||||
@app.admin
|
||||
class Calendar(models.Model):
|
||||
"""
|
||||
|
|
@ -60,6 +90,14 @@ class Calendar(models.Model):
|
|||
calendar_password = models.CharField(max_length=29)
|
||||
users = models.ManyToManyField(app.settings.AUTH_USER_MODEL,
|
||||
related_name='calendars')
|
||||
auto_clear_overlaps = models.BooleanField(default=False)
|
||||
auto_clear_overlap_horizon_days = models.IntegerField(default=30)
|
||||
email_template = models.ForeignKey(EmailTemplate, on_delete=models.SET_NULL,
|
||||
null=True, blank=True,
|
||||
related_name='calendars',
|
||||
help_text="""\
|
||||
Email template to use for sending notifications about deleted events.<br>
|
||||
If not set, no email will be sent.""")
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
self.url = self.url.rstrip("/")
|
||||
|
|
@ -238,6 +276,7 @@ def get_markdown():
|
|||
md = markdown.Markdown(extensions=["fenced_code"])
|
||||
return md.convert(f.read())
|
||||
|
||||
|
||||
def get_version():
|
||||
"""
|
||||
Get the version of the app from the version.txt file.
|
||||
|
|
@ -282,6 +321,34 @@ def delete_event(request, calendar: str, event_id: str):
|
|||
event.cancel()
|
||||
return 204, None
|
||||
|
||||
@api.delete("/clear-overlaps", response={200: None, 204: None, 401: None, 404: None})
|
||||
@csrf_exempt
|
||||
def clear_overlaps(request, calendar: str = None):
|
||||
user = get_user(request)
|
||||
|
||||
# Get optional calendar name from the request
|
||||
if calendar:
|
||||
cal = get_object_or_404(Calendar, name=calendar,
|
||||
auto_clear_overlaps=True)
|
||||
if user not in cal.users.all():
|
||||
raise HttpError(401,
|
||||
f"User not authorised to clear overlaps in calendar '{cal.name}'")
|
||||
|
||||
calendars = [cal]
|
||||
|
||||
# If no calendar is specified, get all calendars for the user that have
|
||||
# auto_clear_overlaps enabled
|
||||
else:
|
||||
calendars = user.calendars.filter(auto_clear_overlaps=True)
|
||||
|
||||
if not calendars:
|
||||
return 200, "No calendars with auto_clear_overlaps enabled found for user."
|
||||
|
||||
if clear(list(calendars)):
|
||||
return 204, "Overlaps cleared successfully."
|
||||
else:
|
||||
raise HttpError(200, "No overlaps to clear.")
|
||||
|
||||
|
||||
app.route("api/", include=api.urls)
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,219 @@
|
|||
import os
|
||||
import smtplib
|
||||
import ssl
|
||||
from datetime import datetime, date, timedelta
|
||||
from email.mime.multipart import MIMEMultipart
|
||||
from email.mime.text import MIMEText
|
||||
from typing import NamedTuple
|
||||
import pytz
|
||||
|
||||
import caldav
|
||||
from jinja2 import Template
|
||||
|
||||
tz = pytz.timezone(os.getenv("TIME_ZONE", "Europe/Berlin"))
|
||||
|
||||
DavEvent = NamedTuple("DavEvent", [
|
||||
("uid", str),
|
||||
("name", str),
|
||||
("start", datetime),
|
||||
("end", datetime),
|
||||
("created", datetime),
|
||||
("status", str),
|
||||
("organizer", str),
|
||||
("obj", caldav.Event),
|
||||
])
|
||||
|
||||
def clear_overlaps(target_calendars: list) -> bool:
|
||||
"""
|
||||
Clear overlapping events in calendars.
|
||||
:param target_calendars: List of calendars to check for overlaps.
|
||||
:return: True if overlaps were cleared, False otherwise.
|
||||
"""
|
||||
cleared = False
|
||||
|
||||
dav_client = caldav.DAVClient(
|
||||
url=target_calendars[0].url,
|
||||
username=target_calendars[0].username,
|
||||
password=target_calendars[0].calendar_password,
|
||||
)
|
||||
|
||||
principal = dav_client.principal()
|
||||
|
||||
# Filter calendars to only those that are in the target_calendars list
|
||||
# Note: The id of the objects returned by principal.calendars() corresponds
|
||||
# to the name of the Django Calendar model instances.
|
||||
tcal_by_name = {c.name: c for c in target_calendars}
|
||||
calendars = [cal for cal in principal.calendars()
|
||||
if cal.id in tcal_by_name.keys()]
|
||||
|
||||
if not calendars:
|
||||
print("No calendars to clear overlaps in. Exiting.")
|
||||
return False
|
||||
|
||||
for calendar in calendars:
|
||||
|
||||
# Get events from calendar
|
||||
print(f"Clearing overlaps in calendar: {calendar.id}")
|
||||
horizon = tcal_by_name[calendar.id].auto_clear_overlap_horizon_days
|
||||
events_fetched = calendar.search(
|
||||
start=datetime.now(),
|
||||
end=date.today() + timedelta(days=horizon),
|
||||
event=True,
|
||||
expand=False,
|
||||
)
|
||||
|
||||
events = []
|
||||
for event in events_fetched:
|
||||
for component in event.icalendar_instance.walk():
|
||||
if component.name == "VEVENT" and component.get("status") != "CANCELLED":
|
||||
events.append(
|
||||
DavEvent(
|
||||
uid=component.get("uid"),
|
||||
name=component.get("summary", "No Name"),
|
||||
start=component.get("dtstart").dt.astimezone(tz),
|
||||
end=component.get("dtend").dt.astimezone(tz),
|
||||
created=component.get("created").dt.astimezone(tz),
|
||||
status=component.get("status", "CONFIRMED"),
|
||||
organizer=component.get("organizer", "").replace("mailto:", "").strip(),
|
||||
obj=event,
|
||||
)
|
||||
)
|
||||
|
||||
# Find overlapping events
|
||||
overlapping_events = find_overlapping_events(events)
|
||||
|
||||
# Delete overlapping events and send emails to organizers
|
||||
for overlap in overlapping_events.values():
|
||||
event = overlap["event"]
|
||||
overlaps_with = overlap["overlaps_with"]
|
||||
try:
|
||||
# Delete the event
|
||||
event.obj.delete()
|
||||
cleared = True
|
||||
|
||||
# Send email to organizer of the event
|
||||
email_template = tcal_by_name[calendar.id].email_template
|
||||
if email_template:
|
||||
try:
|
||||
send_mail_to_organizer(event, overlaps_with, calendar,
|
||||
email_template)
|
||||
except Exception as e:
|
||||
print("Failed to send email to organizer for event "
|
||||
f"{event.name}: {e}")
|
||||
continue
|
||||
except Exception as e:
|
||||
print(f"Failed to delete event {event.uid}: {e}")
|
||||
continue
|
||||
|
||||
return cleared
|
||||
|
||||
|
||||
def find_overlapping_events(events: list[DavEvent]) -> dict:
|
||||
"""
|
||||
Find overlapping events.
|
||||
:param events: List of events to check for overlaps.
|
||||
:return: Dictionary of overlapping events with their UIDs as keys and
|
||||
a dictionary containing the event and the event it overlaps with
|
||||
as values.
|
||||
"""
|
||||
overlapping_events = {}
|
||||
checked_events = {}
|
||||
|
||||
# Create a dictionary to make it easier to find an event by its UID
|
||||
events = {e.uid: e for e in events}
|
||||
|
||||
# Order events by created time
|
||||
events = dict(sorted(events.items(), key=lambda item: item[1].created))
|
||||
|
||||
# Define lambda functions to check for overlaps
|
||||
event_starts_earlier = lambda e1, e2: e1.start <= e2.start <= e1.end
|
||||
event_inbetween = lambda e1, e2: e2.start <= e1.start <= e1.end <= e2.end
|
||||
event_ends_later = lambda e1, e2: e1.end >= e2.end >= e1.start
|
||||
|
||||
# Find overlapping events
|
||||
for event in events.values():
|
||||
|
||||
# Skip if the event is already in the overlapping events dictionary
|
||||
if event.uid in overlapping_events.keys():
|
||||
continue
|
||||
|
||||
for compare_event in events.values():
|
||||
# Skip if the events is:
|
||||
# 1. The same event as the compare_event
|
||||
# 2. Already checked
|
||||
# 3. Already in the overlapping events dictionary
|
||||
if event.uid == compare_event.uid \
|
||||
or compare_event.uid in checked_events.keys() \
|
||||
or compare_event.uid in overlapping_events.keys():
|
||||
continue
|
||||
|
||||
# Check if the events overlap
|
||||
if event_starts_earlier(event, compare_event) or \
|
||||
event_inbetween(event, compare_event) or \
|
||||
event_ends_later(event, compare_event):
|
||||
# Add to overlapping events dictionary
|
||||
overlapping_events.update({
|
||||
compare_event.uid: {"event": compare_event,
|
||||
"overlaps_with": event}})
|
||||
# Add to checked events dictionary
|
||||
checked_events.update({event.uid: event})
|
||||
|
||||
return overlapping_events
|
||||
|
||||
def send_mail_to_organizer(
|
||||
event: DavEvent,
|
||||
overlap_event: DavEvent,
|
||||
calendar: caldav.Calendar,
|
||||
email_template,
|
||||
):
|
||||
"""
|
||||
Send email to organizer of the event.
|
||||
:param event: Event that was declined
|
||||
:param overlap_event: Event that overlaps with the declined event
|
||||
:param calendar: Calendar to send the email from
|
||||
:param email_template: Email template to use for the email
|
||||
"""
|
||||
# Check if environment variables for SMTP are set
|
||||
if not all([
|
||||
os.getenv('SMTP_EMAIL'),
|
||||
os.getenv('SMTP_PASSWORD'),
|
||||
os.getenv('SMTP_SERVER'),
|
||||
]):
|
||||
raise Exception("SMTP environment variables are not set.")
|
||||
|
||||
recipient = event.organizer
|
||||
|
||||
# Prepare the email content
|
||||
context = {
|
||||
"event_name": event.name,
|
||||
"event_start": event.start,
|
||||
"event_end": event.end,
|
||||
"event_created": event.created,
|
||||
"overlap_event_name": overlap_event.name,
|
||||
"overlap_event_start": overlap_event.start,
|
||||
"overlap_event_end": overlap_event.end,
|
||||
"overlap_event_created": overlap_event.created,
|
||||
"overlap_event_organizer": overlap_event.organizer,
|
||||
"calendar_name": calendar.get_display_name(),
|
||||
}
|
||||
|
||||
# Create the email message
|
||||
message = MIMEMultipart("alternative")
|
||||
message["From"] = f"{os.getenv('SMTP_SENDER_NAME', 'Room Booking')} <{os.getenv('SMTP_EMAIL')}>"
|
||||
message["To"] = recipient
|
||||
message["Date"] = datetime.now().astimezone(tz).strftime( "%d/%m/%Y %H:%M")
|
||||
|
||||
# Try to render subject and body from the email template
|
||||
message["Subject"] = Template(email_template.subject).render(**context)
|
||||
body = Template(email_template.body).render(**context)
|
||||
message.attach(MIMEText(body, "plain"))
|
||||
|
||||
# Create a secure SSL context
|
||||
ssl_context = ssl.create_default_context()
|
||||
|
||||
# Send the email
|
||||
with smtplib.SMTP_SSL("mail.extrasolar.space",
|
||||
os.getenv('SMTP_PORT', 465),
|
||||
context=ssl_context) as server:
|
||||
server.login(os.getenv("SMTP_EMAIL"), os.getenv("SMTP_PASSWORD"))
|
||||
server.sendmail(os.getenv("SMTP_EMAIL"), recipient, message.as_string())
|
||||
|
|
@ -0,0 +1,50 @@
|
|||
# Generated by Django 5.1.4 on 2025-06-25 14:08
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("booking", "0001_initial"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="EmailTemplate",
|
||||
fields=[
|
||||
("id", models.AutoField(primary_key=True, serialize=False)),
|
||||
("name", models.CharField(max_length=100, unique=True)),
|
||||
("subject", models.CharField(max_length=200)),
|
||||
(
|
||||
"body",
|
||||
models.TextField(
|
||||
help_text=" Email template to use for sending notifications about deleted events.<br>\n If not set, no email will be sent.<br>\n Jinja2 template syntax can be used in the subject and body.<br>\n Available variables:<br>\n - event_name: The name of the event<br>\n - event_start: The start time of the event<br>\n - event_end: The end time of the event<br>\n - event_created: The time the event was created<br>\n - overlap_event_name: The name of the overlap event<br>\n - overlap_event_start: The start time of the overlap event<br>\n - overlap_event_end: The end time of the overlap event<br>\n - overlap_event_created: The time the overlap event was created<br>\n - overlap_event_organizer: The organizer of the overlap event<br>\n - calendar_name: The name of the calendar"
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="calendar",
|
||||
name="auto_clear_overlap_horizon_days",
|
||||
field=models.IntegerField(default=30),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="calendar",
|
||||
name="auto_clear_overlaps",
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="calendar",
|
||||
name="email_template",
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
help_text=" Email template to use for sending notifications about deleted events.<br>\n If not set, no email will be sent.",
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="calendars",
|
||||
to="booking.emailtemplate",
|
||||
),
|
||||
),
|
||||
]
|
||||
Loading…
Reference in New Issue