diff --git a/requirements.txt b/requirements.txt
index d1d5d8b..d98e4c5 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -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
diff --git a/src/booking.py b/src/booking.py
index 4ef463c..9f5b388 100644
--- a/src/booking.py
+++ b/src/booking.py
@@ -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.
+ If not set, no email will be sent.
+ Jinja2 template syntax can be used in the subject and body.
+ Available variables:
+ - event_name: The name of the event
+ - event_start: The start time of the event
+ - event_end: The end time of the event
+ - event_created: The time the event was created
+ - overlap_event_name: The name of the overlap event
+ - overlap_event_start: The start time of the overlap event
+ - overlap_event_end: The end time of the overlap event
+ - overlap_event_created: The time the overlap event was created
+ - overlap_event_organizer: The organizer of the overlap event
+ - 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.
+ If not set, no email will be sent.""")
def save(self, *args, **kwargs):
self.url = self.url.rstrip("/")
@@ -169,7 +207,7 @@ class Event(models.Model):
return string if not self.cancelled else f"{string} - CANCELLED"
-@app.admin(readonly_fields = ('key',))
+@app.admin(readonly_fields=('key',))
class APIKey(models.Model):
id = models.AutoField(primary_key=True)
user = models.ForeignKey(app.settings.AUTH_USER_MODEL,
@@ -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)
diff --git a/src/clear_overlaps.py b/src/clear_overlaps.py
new file mode 100644
index 0000000..27ce14e
--- /dev/null
+++ b/src/clear_overlaps.py
@@ -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())
diff --git a/src/migrations/0002_emailtemplate_and_more.py b/src/migrations/0002_emailtemplate_and_more.py
new file mode 100644
index 0000000..47087ca
--- /dev/null
+++ b/src/migrations/0002_emailtemplate_and_more.py
@@ -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.
\n If not set, no email will be sent.
\n Jinja2 template syntax can be used in the subject and body.
\n Available variables:
\n - event_name: The name of the event
\n - event_start: The start time of the event
\n - event_end: The end time of the event
\n - event_created: The time the event was created
\n - overlap_event_name: The name of the overlap event
\n - overlap_event_start: The start time of the overlap event
\n - overlap_event_end: The end time of the overlap event
\n - overlap_event_created: The time the overlap event was created
\n - overlap_event_organizer: The organizer of the overlap event
\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.
\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",
+ ),
+ ),
+ ]