diff --git a/README.md b/README.md
index 5d61874..c363b43 100644
--- a/README.md
+++ b/README.md
@@ -1,10 +1,10 @@
Room Booking
---
-This application allows you to
+This application allows you to
-1. create an event in a room booking calendar via a REST API.
-2. delete an event in a room booking calendar via a REST API.
+1. create an event in a room booking calendar via a REST API.
+2. delete an event in a room booking calendar via a REST API.
3. clear overlapping and canceled events in a room booking calendar via a REST
API.
@@ -35,6 +35,7 @@ with the following JSON payload:
```
Curl example:
+
```bash
curl -s \
-H "Authorization: Bearer secrettoken" \
@@ -46,14 +47,18 @@ Curl example:
The response will contain the event ID:
```json
-{"id": "4zx3QAFzoxV3vaZSKGaH2S"}
+{
+ "id": "4zx3QAFzoxV3vaZSKGaH2S"
+}
```
### Delete an event
-To delete an event, you need to send a DELETE request to `/api/{calendar}/events/{event_id}`.
+To delete an event, you need to send a DELETE request to
+`/api/{calendar}/events/{event_id}`.
Curl example:
+
```bash
curl -s -X DELETE \
-H "Authorization: Bearer secrettoken" \
@@ -76,6 +81,7 @@ The following parameters can be passed as query parameters:
return what would be deleted.
Curl example:
+
```bash
curl -s -X DELETE \
-H "Authorization: Bearer secrettoken" \
@@ -159,22 +165,30 @@ Email templates can be configured in the Django Admin interface. Jinja2 syntax
is used for the templates. The following variables are available in the
templates:
+- `booking` - The booking that was declined
+- `calendar_name` - The name of the calendar
+
+**Lists**
+- `overlap_bookings` - A list of overlap bookings, each containing:
+- `other_bookings` - A list of other bookings at the same day(s) as the declined
+- `overview` - A list of all bookings in the calendar for the day(s) of the
+ declined booking
+- `alternatives` - List of alternative calendars (rooms) for the time slot of
+ the
+
+**Attributes**
+Each event has the following attributes:
+- `uid` - The unique ID of the event
- `name` - The name of the event
- `start` - The start time (datetime) of the event
- `end` - The end time (datetime) of the event
+- `duration` - The duration of the event (timedelta)
- `created` - The datetime the event was created
-- `is_deleted` - Whether the event was deleted
+- `status` - The status of the event
+- `organizer` - The organizer of the event
- `is_cancelled` - Whether the event was cancelled
- `is_recurring` - Whether the event is recurring
-- `is_priorized` - Whether the event is prioritized
+- `is_prioritized` - Whether the event is prioritized
+- `is_deleted` - Whether the event was deleted
- `is_deprioritized` - Whether the event is deprioritized in favor of another
- event
-- `organizer` - The organizer of the event
-- `overlap_events` - A list of overlap events, each containing:
- - `name` - The name of the overlap event
- - `start` - The start time (datetime) of the overlap event
- - `end` - The end time (datetime) of the overlap event
- - `created` - The datetime the overlap event was created
- - `organizer` - The organizer of the overlap event
- - `is_priorized` - Whether the overlap event is prioritized
-- `calendar_name` - The name of the calendar
\ No newline at end of file
+ event
\ No newline at end of file
diff --git a/src/booking.py b/src/booking.py
index 6cd2b89..f411fa0 100644
--- a/src/booking.py
+++ b/src/booking.py
@@ -74,29 +74,12 @@ class EmailTemplate(models.Model):
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.
+ plaintext = models.TextField(blank=True, help_text="""\
Jinja2 template syntax can be used in the subject and body.
- Available variables:
- - name: The name of the event
- - start: The start time (datetime) of the event
- - end: The end time (datetime) of the event
- - created: The datetime the event was created
- - is_deleted: Whether the event was deleted
- - is_cancelled: Whether the event was cancelled
- - is_recurring: Whether the event is recurring
- - organizer: The organizer of the event
- - is_prioritized: Whether the event is prioritized
- - is_deprioritized: Whether the event was deprioritized in favor of another event
- - overlap_events: List of overlap events, each with:
- - name: The name of the overlap event
- - start: The start time (datetime) of the overlap event
- - end: The end time (datetime) of the overlap event
- - created: The datetime the overlap event was created
- - organizer: The organizer of the overlap event
- - is_prioritized: Whether the overlap event is prioritized
- - calendar_name: The name of the calendar""")
+ See README.md for a list of available variables.""")
+ html = models.TextField(blank=True, help_text="""\
+ Jinja2 template syntax can be used in the subject and body.
+ See README.md for a list of available variables.""")
def __str__(self):
return self.name
@@ -104,7 +87,8 @@ class EmailTemplate(models.Model):
@app.admin(
list_display=("name", "all_users", "auto_clear_bookings",
- "auto_clear_overlap_horizon_days", "email_template"),
+ "auto_clear_overlap_horizon_days", "email_template",
+ "all_alternatives"),
list_filter=("auto_clear_bookings",))
class Calendar(models.Model):
"""
@@ -124,6 +108,10 @@ class Calendar(models.Model):
help_text="""\
Email template to use for sending notifications about deleted events.
If not set, no email will be sent.""")
+ alternatives = models.ManyToManyField("self", blank=True,
+ help_text="Calendars (rooms) to be "
+ "considered as alternatives"
+ " for this one.")
def save(self, *args, **kwargs):
self.url = self.url.rstrip("/")
@@ -148,6 +136,14 @@ class Calendar(models.Model):
"""
return ", ".join([u.username for u in self.users.all()])
+ def all_alternatives(self) -> str:
+ """
+ Get the alternatives of the calendar.
+ It's kind of inefficient, but in small databases it should be fine.
+ :return: A list of alternatives.
+ """
+ return ", ".join([a.name for a in self.alternatives.all()])
+
@app.admin(ordering=("start", "end", "calendar", "name"),
list_filter=("cancelled",),
diff --git a/src/clear_bookings.py b/src/clear_bookings.py
index b12780c..9019f36 100644
--- a/src/clear_bookings.py
+++ b/src/clear_bookings.py
@@ -1,3 +1,4 @@
+import email.utils
import os
import re
import smtplib
@@ -7,28 +8,192 @@ from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
from email.utils import format_datetime
from pprint import pprint
-from typing import NamedTuple
import caldav
import pytz
-from caldav import CalendarObjectResource
+from caldav import CalendarObjectResource, Principal, Calendar
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),
- ("is_cancelled", bool),
- ("is_recurring", bool),
- ("is_prioritized", bool),
-])
+class DavEvent:
+ """
+ Wrapper for calendar events fetched from a CalDAV server.
+ """
+ uid: str
+ name: str
+ start: datetime
+ end: datetime
+ duration: timedelta
+ created: datetime
+ status: str
+ organizer: str
+ calendar: Calendar
+ obj: CalendarObjectResource
+ is_cancelled: bool
+ is_recurring: bool
+ is_prioritized: bool
+ missing_required_fields: list
+
+ def __init__(self, event: CalendarObjectResource):
+ """
+ Initialize a DavEvent object.
+ :param event: CalendarObjectResource object representing the event.
+ """
+ for component in event.icalendar_instance.walk():
+ if component.name == "VEVENT":
+
+ # Check if the event has all required fields
+ required = {
+ "uid": component.get("uid", False),
+ "dtstart": component.get("dtstart", False),
+ "dtend": component.get("dtend", False),
+ "dtstamp": component.get("dtstamp", False),
+ }
+ if not all(required.values()):
+ self.missing_required_fields = [k for k, v in required.items() if v is False]
+ else:
+ self.missing_required_fields = []
+
+ self.uid = event.icalendar_component.get("uid")
+ self.name = event.icalendar_component.get("summary", "No Name")
+ self.start = self._handle_date(event.icalendar_component.get("dtstart").dt)
+ self.end = self._handle_date(event.icalendar_component.get("dtend").dt)
+ self.duration = self.end - self.start
+ self.created = self._handle_date(event.icalendar_component.get("dtstamp").dt)
+ self.status = event.icalendar_component.get("status", "CONFIRMED")
+ self.organizer = event.icalendar_component.get("organizer", "").replace("mailto:", "").strip()
+ self.calendar = event.parent
+ self.obj = event
+ self.is_deprioritized = False
+ self.is_deleted = False
+
+ def __hash__(self):
+ return hash(self.extended_uid)
+
+ def __eq__(self, other):
+ if not isinstance(other, DavEvent):
+ return NotImplemented
+ return self.extended_uid == other.extended_uid
+
+ @property
+ def extended_uid(self):
+ """
+ Create a more unique identifier for the event.
+ This is necessary to distinguish between events of the same series, which
+ have the same UID but different start and end times.
+ :return: Unique identifier string
+ """
+ # Fallback to calendar id if UID is not set
+ uid = self.uid if self.uid else self.calendar.id
+ return f"{uid}_{int(self.start.timestamp())}_{int(self.end.timestamp())}"
+
+ @property
+ def is_recurring(self) -> bool:
+ """
+ Check if the event is a recurring event.
+ :return: True if the event is recurring, False otherwise.
+ """
+ return 'RECURRENCE-ID' in self.obj.icalendar_component
+
+ @property
+ def is_cancelled(self) -> bool:
+ """
+ Check if the event is a cancelled event.
+ :return: True if the event is cancelled, False otherwise.
+ """
+ return self.obj.icalendar_component.get("status") == "CANCELLED"
+
+ @property
+ def is_prioritized(self) -> bool:
+ """
+ Check if the event is a prioritized event by finding and validating the
+ priority token.
+ :return:
+ """
+ from booking import PriorityEventToken
+ description = self.obj.icalendar_component.get("DESCRIPTION", "")
+ uid = self.uid
+
+ # Check if there is a token in the event description
+ token_pattern = r"\#[A-Z1-9]{12}\#"
+ match = re.search(token_pattern, description)
+ if match:
+ token = match.group()
+ print(f"Priority token found in event description: {token}")
+ token_obj = PriorityEventToken.objects.get(token=token)
+
+ # Check if the token object exists in the database
+ if not token_obj:
+ print(f"Priority token '{token}' not found in database.")
+ return False
+
+ # If token is already used, verify signature
+ if token_obj.is_used:
+ # Check if the signature is valid
+
+ if token_obj.validate_signature(uid):
+ print(f"Priority token '{token}' is valid.")
+ return True
+ else:
+ print(f"Priority token '{token}' is invalid.")
+ # TODO: Notify about invalid token usage
+ return False
+
+ # If the token hasn't been used yet, redeem it
+ else:
+ print(f"Redeeming priority token '{token}' for event '{uid}'.")
+ token_obj.redeem(uid)
+ # TODO: Notify about token redemption
+ return True
+
+ # If no token is found, return False
+ return False
+
+ def delete(self):
+ """
+ Delete the event from the calendar.
+ :return:
+ """
+ self.is_deleted = True
+ self.obj.delete()
+
+ def serialize(self) -> dict[str, str | datetime | timedelta]:
+ """
+ Serialize the event to a dictionary.
+ :return:
+ """
+ return {
+ "uid": self.uid,
+ "name": self.name,
+ "start": self.start,
+ "end": self.end,
+ "duration": self.duration,
+ "created": self.created,
+ "status": self.status,
+ "organizer": self.organizer,
+ "calendar_id": self.calendar.id,
+ "calendar_name": self.calendar.name,
+ "is_cancelled": self.is_cancelled,
+ "is_recurring": self.is_recurring,
+ "is_prioritized": self.is_prioritized,
+ "missing_required_fields": self.missing_required_fields,
+ }
+
+ @staticmethod
+ def _handle_date(date_value: datetime | date) -> datetime:
+ """
+ Date objects will be converted to datetime objects in the specified timezone.
+ """
+ if isinstance(date_value, datetime):
+ date_value = date_value.astimezone(tz)
+ elif isinstance(date_value, date):
+ date_value = tz.localize(datetime.combine(date_value, datetime.min.time()))
+ else:
+ raise ValueError(f"date_value must be a datetime or date object, {type(date_value)} given.")
+
+ return date_value
+
def clear(target_calendars: list, is_test: bool=False) -> dict:
"""
@@ -79,66 +244,47 @@ def clear(target_calendars: list, is_test: bool=False) -> dict:
split_expanded=True,
)
+ # Create DavEvent objects from fetched events
events = []
for event in events_fetched:
- for component in event.icalendar_instance.walk():
+ try:
+ events.append(DavEvent(event))
+ except ValueError as e:
+ print(f"Error creating DavEvent object: {e}")
+ continue
- # Delete cancelled non-recurring events if not in test mode
- if (component.name == "VEVENT"
- and is_cancelled_event(event)
- and not is_recurring_event(event)):
+ # Filter out events that are missing required fields
+ events_missing_required_fields = [
+ event for event in events if event.missing_required_fields
+ ]
+ for event in events_missing_required_fields:
+ result = {
+ "uid": event.uid or "No UID",
+ "name": event.name or "No Name",
+ "reason": f"Missing required fields: {', '.join(event.missing_required_fields)}",
+ "calendar": calendar.id,
+ }
+ results["ignored_overlapping_events"].append(result)
+ print("Skipping event:")
+ pprint(result)
+ print("------")
+ continue
+ events = [event for event in events if not event.missing_required_fields]
- if not is_test:
- event.delete()
- result = {
- "uid": component.get("uid"),
- "name": component.get("summary", "No Name"),
- "calendar": calendar.id,
- }
- results["deleted_cancelled_events"].append(result)
- print("Deleted cancelled event:")
- pprint(result)
- print("------")
-
- # Create DavEvent objects for not cancelled events
- elif component.name == "VEVENT" and not is_cancelled_event(event):
-
- # Skip events which miss required fields
- required = {
- "uid": component.get("uid", False),
- "dtstart": component.get("dtstart", False),
- "dtend": component.get("dtend", False),
- "dtstamp": component.get("dtstamp", False),
- }
- if not all(required.values()):
- result = {
- "uid": component.get("uid"),
- "name": component.get("summary", "No Name"),
- "calendar": calendar.id,
- "reason": f"Missing required fields: {', '.join([k for k, v in required.items() if v is False])}",
- }
- results["ignored_overlapping_events"].append(result)
- print("Skipping event:")
- pprint(result)
- print("------")
- continue
-
- # Create DavEvent object
- events.append(
- DavEvent(
- uid=component.get("uid"),
- name=component.get("summary", "No Name"),
- start=handle_date(component.get("dtstart").dt),
- end=handle_date(component.get("dtend").dt),
- created=component.get("dtstamp").dt.astimezone(tz),
- status=component.get("status", "CONFIRMED"),
- organizer=component.get("organizer", "").replace("mailto:", "").strip(),
- obj=event,
- is_cancelled=is_cancelled_event(event),
- is_recurring=is_recurring_event(event),
- is_prioritized=is_prioritized_event(event),
- )
- )
+ # Delete cancelled non-recurring events if not in test mode
+ for event in events:
+ if event.is_cancelled and not event.is_recurring:
+ if not is_test:
+ event.obj.delete()
+ result = {
+ "uid": event.uid,
+ "name": event.name or "No Name",
+ "calendar": event.calendar.id,
+ }
+ results["deleted_cancelled_events"].append(result)
+ print("Deleted cancelled event:")
+ pprint(result)
+ print("------")
# Find overlapping events
overlapping_events = find_overlapping_events(events)
@@ -147,31 +293,10 @@ def clear(target_calendars: list, is_test: bool=False) -> dict:
for overlap in overlapping_events.values():
event = overlap["event"]
overlaps_with = overlap["overlaps_with"]
- is_deleted = False
- is_cancelled = False
- is_deprioritized = any(ov.is_prioritized for ov in overlaps_with)
+ event.is_deprioritized = any(ov.is_prioritized for ov in overlaps_with)
result = {
- "event": {
- "uid": event.uid,
- "name": event.name,
- "start": event.start,
- "end": event.end,
- "created": event.created,
- "organizer": event.organizer,
- "is_recurring": event.is_recurring,
- "is_prioritized": event.is_prioritized,
- "is_deprioritized": is_deprioritized,
- },
- "overlaps_with": [{
- "uid": ov.uid,
- "name": ov.name,
- "start": ov.start,
- "end": ov.end,
- "created": ov.created,
- "organizer": ov.organizer,
- "is_recurring": ov.is_recurring,
- "is_prioritized": ov.is_prioritized,
- } for ov in overlaps_with],
+ "event": event.serialize(),
+ "overlaps_with": [ov.serialize() for ov in overlaps_with],
"calendar": calendar.id,
}
try:
@@ -179,8 +304,8 @@ def clear(target_calendars: list, is_test: bool=False) -> dict:
if not event.is_recurring:
# but only if we are not in test mode
if not is_test:
- event.obj.delete()
- is_deleted = True
+ event.delete()
+
result["cancellation_type"] = "deleted"
results["deleted_overlapping_events"].append(result)
print("Deleted overlapping event:")
@@ -196,27 +321,61 @@ def clear(target_calendars: list, is_test: bool=False) -> dict:
results["cancelled_overlapping_recurring_events"].append(result)
print("Cancelled overlapping recurring event:")
+ # Get all events of the affected calendar for the days of the
+ # declined event
+ other_bookings = find_other_bookings(event, calendar)
+
+ # Find free alternative rooms for the time slot of the declined event
+ alternatives = find_alternatives(event, principal)
+
+ # Create a set for all events as an overview
+ event.is_declined = True
+ event.is_overlapping = False
+ event.is_other_booking = False
+ overview = {event}
+
+ # Add the overlapping events to the overview
+ for ov in overlaps_with:
+ ov.is_declined = False
+ ov.is_overlapping = True
+ ov.is_other_booking = False
+ overview.add(ov)
+
+ # Add the other bookings to the overview
+ for ob in other_bookings:
+ ob.is_declined = True
+ ob.is_overlapping = False
+ ob.is_other_booking = True
+ overview.add(ob)
+
+ # Make a list from the set to ensure order
+ overview = list(overview)
+
+ # Sort the overview by start time
+ overview.sort(key=lambda ev: ev.start)
# Send email to organizer of the event if not in test mode
email_template = tcal_by_name[calendar.id].email_template
- if email_template and not is_test:
+ if email_template:
try:
- send_mail_to_organizer(event, overlaps_with, calendar,
- is_deleted, is_cancelled,
- is_deprioritized, email_template)
+ send_mail_to_organizer(
+ booking=event,
+ overlap_bookings=overlaps_with,
+ event_overview=overview,
+ other_bookings=other_bookings,
+ alternatives=alternatives,
+ calendar=calendar,
+ email_template=email_template,
+ is_test=is_test,
+ )
result["email_sent"] = True
except Exception as e:
print("Failed to send email to organizer for event "
f"{event.name}: {e}")
result["email_sent"] = False
- elif email_template and is_test:
- print("Email template found, but not sending email in test mode.")
- send_mail_to_organizer(event, overlaps_with, calendar,
- is_deleted, is_cancelled,
- is_deprioritized, email_template,
- is_test=True)
- result["email_sent"] = True
else:
+ print(f"No email template found for calendar {calendar.id}. "
+ "Skipping email sending.")
result["email_sent"] = False
# Print results
@@ -253,7 +412,7 @@ def find_overlapping_events(events: list[DavEvent]) -> dict:
for event in events:
# Skip if the event is already in the overlapping events dictionary
- if overlapping_events.get(create_event_identifier(event)):
+ if overlapping_events.get(event.extended_uid):
continue
for compare_event in events:
@@ -262,6 +421,11 @@ def find_overlapping_events(events: list[DavEvent]) -> dict:
if event.uid == compare_event.uid:
continue
+ # Skip if the compare_event is already in the overlapping events
+ # dictionary
+ if overlapping_events.get(compare_event.extended_uid):
+ continue
+
# Check if the events overlap
if event_starts_later(event, compare_event) or \
event_starts_earlier(event, compare_event) or \
@@ -281,114 +445,44 @@ def find_overlapping_events(events: list[DavEvent]) -> dict:
continue
# Add to overlapping events dictionary
- event_identifier = create_event_identifier(compare_event)
- if e := overlapping_events.get(event_identifier):
+ if e := overlapping_events.get(event.extended_uid):
# If the event is already in the dictionary, extend the overlaps
e["overlaps_with"].append(event)
else:
# Create a new entry for the overlapping event
- overlapping_events[event_identifier] = {
+ overlapping_events[event.extended_uid] = {
"event": compare_event,
"overlaps_with": [event]
}
return overlapping_events
-def create_event_identifier(event: DavEvent) -> str:
- """
- Create a unique identifier for the event.
- This is necessary to distinguish between events of the same series, which
- have the same UID but different start and end times.
- :param event: DavEvent object
- :return: Unique identifier string
- """
- return f"{event.uid}_{int(event.start.timestamp())}_{int(event.end.timestamp())}"
-
-def is_recurring_event(event: CalendarObjectResource) -> bool:
- """
- Check if the event is a recurring event.
- :param event: CalendarObjectResource object representing the event.
- :return: True if the event is recurring, False otherwise.
- """
- return 'RECURRENCE-ID' in event.icalendar_component
-
-def is_cancelled_event(event: CalendarObjectResource) -> bool:
- """
- Check if the event is a cancelled event.
- :param event: CalendarObjectResource object representing the event.
- :return: True if the event is cancelled, False otherwise.
- """
- return event.icalendar_component.get("status") == "CANCELLED"
-
-def is_prioritized_event(event: CalendarObjectResource) -> bool:
- """
- Check if the event is a prioritized event by finding and validating the
- priority token.
- :param event:
- :return:
- """
- from booking import PriorityEventToken
- description = event.icalendar_component.get("DESCRIPTION", "")
- uid = event.icalendar_component.get("uid")
-
- # Check if there is a token in the event description
- token_pattern = r"\#[A-Z1-9]{12}\#"
- match = re.search(token_pattern, description)
- if match:
- token = match.group()
- print(f"Priority token found in event description: {token}")
- token_obj = PriorityEventToken.objects.get(token=token)
-
- # Check if the token object exists in the database
- if not token_obj:
- print(f"Priority token '{token}' not found in database.")
- return False
-
- # If token is already used, verify signature
- if token_obj.is_used:
- # Check if the signature is valid
-
- if token_obj.validate_signature(uid):
- print(f"Priority token '{token}' is valid.")
- return True
- else:
- print(f"Priority token '{token}' is invalid.")
- # TODO: Notify about invalid token usage
- return False
-
- # If the token hasn't been used yet, redeem it
- else:
- print(f"Redeeming priority token '{token}' for event '{uid}'.")
- token_obj.redeem(uid)
- # TODO: Notify about token redemption
- return True
-
- # If no token is found, return False
- return False
-
def send_mail_to_organizer(
- event: DavEvent,
- overlap_events: list[DavEvent],
+ booking: DavEvent,
+ overlap_bookings: list[DavEvent],
+ event_overview: list[DavEvent],
calendar: caldav.Calendar,
- is_deleted: bool,
- is_cancelled: bool,
- is_deprioritized: bool,
email_template,
+ other_bookings: list[DavEvent]=None,
+ alternatives: list[Calendar]=None,
is_test: bool=False
):
"""
Send email to organizer of the event.
- :param event: Event that was declined
- :param overlap_events: List of events which overlap with the declined event
+ :param booking: Booking that was declined
+ :param overlap_bookings: List of bookings which overlap with the declined event
+ :param event_overview: Sorted list of all events in the calendar, including the declined event and overlaps
:param calendar: Calendar to send the email from
- :param is_deleted: True if the event was deleted, False otherwise
- :param is_cancelled: True if the event was cancelled, False otherwise
- :param is_deprioritized: True if the event was deprioritized in favor of
another event, False otherwise
:param email_template: Email template to use for the email
+ :param other_bookings: List of other bookings at the same day(s) as the declined booking
+ :param alternatives: List of alternative calendars (rooms) for the time slot of the declined booking
:param is_test: If True, do not send the email, just print it
"""
+ if is_test:
+ print("Not sending email in test mode.")
+
# Check if environment variables for SMTP are set
if not all([
os.getenv('SMTP_EMAIL'),
@@ -397,28 +491,15 @@ def send_mail_to_organizer(
]):
raise Exception("SMTP environment variables are not set.")
- recipient = event.organizer
+ recipient = booking.organizer
# Prepare the email content
context = {
- "name": event.name,
- "start": event.start,
- "end": event.end,
- "created": event.created,
- "is_deleted": is_deleted,
- "is_cancelled": is_cancelled,
- "is_recurring": is_recurring_event(event.obj),
- "organizer": event.organizer,
- "is_prioritized": event.is_prioritized,
- "is_deprioritized": is_deprioritized,
- "overlap_events": [{
- "name": overlap_event.name,
- "start": overlap_event.start,
- "end": overlap_event.end,
- "created": overlap_event.created,
- "organizer": overlap_event.organizer,
- "is_prioritized": overlap_event.is_prioritized,
- } for overlap_event in overlap_events],
+ "booking": booking,
+ "overlap_bookings": overlap_bookings,
+ "other_bookings": other_bookings if other_bookings else [],
+ "overview": event_overview,
+ "alternatives": alternatives if alternatives else [],
"calendar_name": calendar.get_display_name(),
}
@@ -426,8 +507,8 @@ def send_mail_to_organizer(
# 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["From"] = email.utils.formataddr((os.getenv('SMTP_SENDER_NAME', 'Room Booking'), os.getenv('SMTP_EMAIL')))
+ message["To"] = email.utils.formataddr((None, recipient))
if bcc := os.getenv("SMTP_BCC"):
message["Bcc"] = bcc
recipients += bcc.split(",")
@@ -435,18 +516,16 @@ def send_mail_to_organizer(
# 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"))
+ plaintext = Template(email_template.plaintext).render(**context)
+ html = Template(email_template.html).render(**context)
+ message.attach(MIMEText(plaintext, "plain", "utf-8"))
+ message.attach(MIMEText(html, "html", "utf-8"))
# Create a secure SSL context
ssl_context = ssl.create_default_context()
# Send the email with starttls or SSL based on environment variable
- overlaps = ", ".join([f"'{overlap.name}'" for overlap in overlap_events])
- print(f"Sending email to {', '.join(recipients)} with subject: {message['Subject']}:")
- print('"""')
- print(body)
- print('"""')
+ print(f"Sending email to {', '.join(recipients)} with subject: {message['Subject']}")
if os.getenv('SMTP_STARTTLS', False):
print("Using STARTTLS for SMTP connection.")
if not is_test:
@@ -464,15 +543,71 @@ def send_mail_to_organizer(
server.login(os.getenv("SMTP_USER_NAME", os.getenv("SMTP_EMAIL")), os.getenv("SMTP_PASSWORD"))
server.sendmail(os.getenv("SMTP_EMAIL"), recipients, message.as_string())
-def handle_date(date_value: datetime | date) -> datetime:
+def find_other_bookings(event: DavEvent, calendar: caldav.Calendar) -> list[DavEvent]:
"""
- Date objects will be converted to datetime objects in the specified timezone.
+ Find other bookings in the calendar that are at the same day(s) as the declined event.
+ :param event: The event to check for overlaps.
+ :param calendar: The calendar to search in.
+ :return: List of DavEvent objects.
"""
- if isinstance(date_value, datetime):
- date_value = date_value.astimezone(tz)
- elif isinstance(date_value, date):
- date_value = tz.localize(datetime.combine(date_value, datetime.min.time()))
- else:
- raise ValueError(f"date_value must be a datetime or date object, {type(date_value)} given.")
+ # Get all events in the calendar for the days of the declined event
+ start_date = event.start.date()
+ end_date = event.end.date()
+ events_fetched = calendar.search(
+ start=start_date,
+ end=end_date + timedelta(days=1),
+ event=True,
+ expand=True,
+ split_expanded=True,
+ )
+
+ # Create DavEvent objects from fetched events
+ all_events = [DavEvent(event) for event in events_fetched]
+
+ # Filter out cancelled events and events that are
+ # - the exact same as the given event
+ # - that do not have all required fields
+ return [e for e in all_events if not e.is_cancelled
+ and e.extended_uid != event.extended_uid
+ and not e.missing_required_fields]
+
+def find_alternatives(event: DavEvent, principal: Principal) -> list[Calendar]:
+ """
+ Find alternative rooms for the time slot of the declined event.
+ :param event: The event to find alternatives for.
+ :param principal: The principal to search for alternative rooms.
+ :return: List of alternative calendars that are available for the time slot.
+ """
+ # Get all calendars of the principal
+ calendars = principal.calendars()
+
+ alternatives = []
+ for calendar in calendars:
+ # Skip the calendar of the declined event
+ if calendar.id == event.obj.parent.id:
+ continue
+
+ # Search for events in the alternative calendar that overlap with the
+ # declined event
+ events_fetched = calendar.search(
+ start=event.start,
+ end=event.end,
+ event=True,
+ expand=True,
+ split_expanded=True,
+ )
+
+ # Create DavEvent objects from fetched events
+ all_events = [DavEvent(event) for event in events_fetched]
+
+ # Filter out cancelled events and events that are missing required fields
+ blocked = [e for e in all_events if not e.is_cancelled
+ and not e.missing_required_fields]
+
+ # If there are no blocked events, the calendar is available
+ if not blocked:
+ alternatives.append(calendar)
+
+ return alternatives
+
- return date_value
diff --git a/src/migrations/0012_calendar_alternatives_alter_emailtemplate_body.py b/src/migrations/0012_calendar_alternatives_alter_emailtemplate_body.py
new file mode 100644
index 0000000..ac85179
--- /dev/null
+++ b/src/migrations/0012_calendar_alternatives_alter_emailtemplate_body.py
@@ -0,0 +1,29 @@
+# Generated by Django 5.1.4 on 2025-07-21 14:10
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("booking", "0011_alter_emailtemplate_body_and_more"),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name="calendar",
+ name="alternatives",
+ field=models.ManyToManyField(
+ blank=True,
+ help_text="Calendars (rooms) to be considered as alternatives for this one.",
+ to="booking.calendar",
+ ),
+ ),
+ migrations.AlterField(
+ model_name="emailtemplate",
+ name="body",
+ field=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 See README.md for a list of available variables."
+ ),
+ ),
+ ]
diff --git a/src/migrations/0013_remove_emailtemplate_body_emailtemplate_html_and_more.py b/src/migrations/0013_remove_emailtemplate_body_emailtemplate_html_and_more.py
new file mode 100644
index 0000000..37674eb
--- /dev/null
+++ b/src/migrations/0013_remove_emailtemplate_body_emailtemplate_html_and_more.py
@@ -0,0 +1,33 @@
+# Generated by Django 5.1.4 on 2025-07-22 13:49
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("booking", "0012_calendar_alternatives_alter_emailtemplate_body"),
+ ]
+
+ operations = [
+ migrations.RemoveField(
+ model_name="emailtemplate",
+ name="body",
+ ),
+ migrations.AddField(
+ model_name="emailtemplate",
+ name="html",
+ field=models.TextField(
+ blank=True,
+ help_text=" Jinja2 template syntax can be used in the subject and body.
\n See README.md for a list of available variables.",
+ ),
+ ),
+ migrations.AddField(
+ model_name="emailtemplate",
+ name="plaintext",
+ field=models.TextField(
+ blank=True,
+ help_text=" Jinja2 template syntax can be used in the subject and body.
\n See README.md for a list of available variables.",
+ ),
+ ),
+ ]