✨ 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
|
asgiref==3.8.1
|
||||||
attrs==24.3.0
|
attrs==24.3.0
|
||||||
black==24.10.0
|
black==24.10.0
|
||||||
|
caldav==1.6.0
|
||||||
certifi==2024.12.14
|
certifi==2024.12.14
|
||||||
charset-normalizer==3.4.1
|
charset-normalizer==3.4.1
|
||||||
click==8.1.8
|
click==8.1.8
|
||||||
|
|
@ -10,10 +11,14 @@ Django==5.1.4
|
||||||
django-ninja==1.3.0
|
django-ninja==1.3.0
|
||||||
gunicorn==23.0.0
|
gunicorn==23.0.0
|
||||||
h11==0.14.0
|
h11==0.14.0
|
||||||
|
icalendar==6.3.1
|
||||||
ics==0.7.2
|
ics==0.7.2
|
||||||
idna==3.10
|
idna==3.10
|
||||||
isort==5.13.2
|
isort==5.13.2
|
||||||
|
Jinja2==3.1.6
|
||||||
|
lxml==5.4.0
|
||||||
Markdown==3.7
|
Markdown==3.7
|
||||||
|
MarkupSafe==3.0.2
|
||||||
mypy-extensions==1.0.0
|
mypy-extensions==1.0.0
|
||||||
nanodjango==0.9.2
|
nanodjango==0.9.2
|
||||||
packaging==24.2
|
packaging==24.2
|
||||||
|
|
@ -22,6 +27,8 @@ platformdirs==4.3.6
|
||||||
pydantic==2.10.4
|
pydantic==2.10.4
|
||||||
pydantic_core==2.27.2
|
pydantic_core==2.27.2
|
||||||
python-dateutil==2.9.0.post0
|
python-dateutil==2.9.0.post0
|
||||||
|
pytz==2025.2
|
||||||
|
recurring-ical-events==3.8.0
|
||||||
requests==2.32.3
|
requests==2.32.3
|
||||||
shortuuid==1.0.13
|
shortuuid==1.0.13
|
||||||
six==1.17.0
|
six==1.17.0
|
||||||
|
|
@ -29,7 +36,10 @@ sqlparse==0.5.3
|
||||||
TatSu==5.12.2
|
TatSu==5.12.2
|
||||||
types-python-dateutil==2.9.0.20241206
|
types-python-dateutil==2.9.0.20241206
|
||||||
typing_extensions==4.12.2
|
typing_extensions==4.12.2
|
||||||
|
tzdata==2025.2
|
||||||
urllib3==2.3.0
|
urllib3==2.3.0
|
||||||
uvicorn==0.34.0
|
uvicorn==0.34.0
|
||||||
validators==0.34.0
|
validators==0.34.0
|
||||||
|
vobject==0.9.9
|
||||||
whitenoise==6.8.2
|
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 nanodjango import Django
|
||||||
from shortuuid.django_fields import ShortUUIDField
|
from shortuuid.django_fields import ShortUUIDField
|
||||||
|
|
||||||
|
from clear_overlaps import clear_overlaps as clear
|
||||||
|
|
||||||
DEBUG = os.getenv("DJANGO_DEBUG", False)
|
DEBUG = os.getenv("DJANGO_DEBUG", False)
|
||||||
SECRET_KEY = os.getenv("DJANGO_SECRET_KEY") \
|
SECRET_KEY = os.getenv("DJANGO_SECRET_KEY") \
|
||||||
if 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
|
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
|
@app.admin
|
||||||
class Calendar(models.Model):
|
class Calendar(models.Model):
|
||||||
"""
|
"""
|
||||||
|
|
@ -60,6 +90,14 @@ class Calendar(models.Model):
|
||||||
calendar_password = models.CharField(max_length=29)
|
calendar_password = models.CharField(max_length=29)
|
||||||
users = models.ManyToManyField(app.settings.AUTH_USER_MODEL,
|
users = models.ManyToManyField(app.settings.AUTH_USER_MODEL,
|
||||||
related_name='calendars')
|
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):
|
def save(self, *args, **kwargs):
|
||||||
self.url = self.url.rstrip("/")
|
self.url = self.url.rstrip("/")
|
||||||
|
|
@ -169,7 +207,7 @@ class Event(models.Model):
|
||||||
return string if not self.cancelled else f"{string} - CANCELLED"
|
return string if not self.cancelled else f"{string} - CANCELLED"
|
||||||
|
|
||||||
|
|
||||||
@app.admin(readonly_fields = ('key',))
|
@app.admin(readonly_fields=('key',))
|
||||||
class APIKey(models.Model):
|
class APIKey(models.Model):
|
||||||
id = models.AutoField(primary_key=True)
|
id = models.AutoField(primary_key=True)
|
||||||
user = models.ForeignKey(app.settings.AUTH_USER_MODEL,
|
user = models.ForeignKey(app.settings.AUTH_USER_MODEL,
|
||||||
|
|
@ -238,6 +276,7 @@ def get_markdown():
|
||||||
md = markdown.Markdown(extensions=["fenced_code"])
|
md = markdown.Markdown(extensions=["fenced_code"])
|
||||||
return md.convert(f.read())
|
return md.convert(f.read())
|
||||||
|
|
||||||
|
|
||||||
def get_version():
|
def get_version():
|
||||||
"""
|
"""
|
||||||
Get the version of the app from the version.txt file.
|
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()
|
event.cancel()
|
||||||
return 204, None
|
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)
|
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