Compare commits

..

18 Commits
main ... 1.4.0

Author SHA1 Message Date
Marc Koch 3b3588000f
🔖 Version 1.4.0 2025-08-20 13:54:48 +02:00
Marc Koch ef1cdb0441
👔 filter calendars when searching for alternatives
Filter out calendars which are:
- not administrated by the current user
- not set as an alternative to the calendar of the declined event
2025-08-20 13:47:50 +02:00
Marc Koch 34f48c7482
🐛 fix: other bookings displayed as declined 2025-08-20 10:31:51 +02:00
Marc Koch d31366bb40
🔧 increase timeout for uvicorn server 2025-08-20 10:31:20 +02:00
Marc Koch 46e62a94ed
introduce max search horizon config
Adds `MAX_SEARCH_HORIZON` environment variable to control the
maximum range of days for search operations.

Splits longer search periods into multiple requests, preventing
potential performance issues or timeouts with large datasets.
2025-08-19 17:04:24 +02:00
Marc Koch 13dc40302c
🥅 catch errors when searching for calendars 2025-08-19 15:30:07 +02:00
Marc Koch 2aed79e796
🐛 do not identify cacelled events as overlapping 2025-08-19 15:18:11 +02:00
Marc Koch 16984bdbb4
🔊 improve logging 2025-08-19 14:43:01 +02:00
Marc Koch bab3ef6ec0
🐛 fix bug in regex expression for priority tokens 2025-08-19 14:42:53 +02:00
Marc Koch 4752e9d555
♻️ move recurring event canellation into method 2025-08-19 13:00:01 +02:00
Marc Koch 1d62cab68d
🔖 Version 1.3.0 2025-08-18 12:36:42 +02:00
Marc Koch 037cfa0d5e
Adds HTML email template support
Enables the use of HTML email templates, providing richer formatting and
improved user experience.

This includes:
- Modifying the EmailTemplate model to include both plaintext and HTML fields.
- Updating the clear_bookings script to use the HTML template when available,
  for sending notifications.
- Adds the possibility to add alternative calendars to the calendar model.
- Adds the possibility to consider calendar alternatives when clearing bookings.
- Provides a list of alternative rooms to the email template.

The available template variables were also updated in the README.
2025-08-18 12:35:13 +02:00
Marc Koch 85d0f940e5
🔖 Version 1.2.3 2025-07-16 15:01:06 +02:00
Marc Koch 66cb2f01f8
🩹 round event end time to nearest minute
Ensures that the event end time is rounded down to the nearest minute.

This prevents potential collision issues where events start and end at
very close times (seconds/milliseconds), especially when creating new events.
2025-07-16 15:00:30 +02:00
Marc Koch 9bfb8c28f6
🔖 Version 1.2.2 2025-07-15 13:04:01 +02:00
Marc Koch f7755cd61d
🐛 fix: cannot decline invitation for shared calendar
If the principal of the calendar does not own it, because it is a shared calendar, invitations cannot be declined because the principal is not invited. This fix causes the appointment to be canceled in the calendar instead.
2025-07-15 13:03:42 +02:00
Marc Koch ef60524866
🔖 Version 1.2.1 2025-07-15 12:50:11 +02:00
Marc Koch ee4ff38f13
🐛 fix: remove hardcoded mailserver
Oops 🤦‍♂️
2025-07-15 11:03:11 +02:00
11 changed files with 589 additions and 287 deletions

View File

@ -16,5 +16,20 @@
</library> </library>
</libraries> </libraries>
</data-source> </data-source>
<data-source source="LOCAL" name="db [2]" uuid="653863ac-b4c8-4525-be5f-881f75f27fff">
<driver-ref>sqlite.xerial</driver-ref>
<synchronize>true</synchronize>
<jdbc-driver>org.sqlite.JDBC</jdbc-driver>
<jdbc-url>jdbc:sqlite:$PROJECT_DIR$/db.sqlite3</jdbc-url>
<working-dir>$ProjectFileDir$</working-dir>
<libraries>
<library>
<url>file://$APPLICATION_CONFIG_DIR$/jdbc-drivers/Xerial SQLiteJDBC/3.45.1/org/xerial/sqlite-jdbc/3.45.1.0/sqlite-jdbc-3.45.1.0.jar</url>
</library>
<library>
<url>file://$APPLICATION_CONFIG_DIR$/jdbc-drivers/Xerial SQLiteJDBC/3.45.1/org/slf4j/slf4j-api/1.7.36/slf4j-api-1.7.36.jar</url>
</library>
</libraries>
</data-source>
</component> </component>
</project> </project>

View File

@ -6,5 +6,5 @@
<component name="MarkdownSettingsMigration"> <component name="MarkdownSettingsMigration">
<option name="stateVersion" value="1" /> <option name="stateVersion" value="1" />
</component> </component>
<component name="ProjectRootManager" version="2" project-jdk-name="Python 3.8 (tazPlease)" project-jdk-type="Python SDK" /> <component name="ProjectRootManager" version="2" project-jdk-name="Python 3.12 (RoomBooking)" project-jdk-type="Python SDK" />
</project> </project>

6
.idea/vcs.xml Normal file
View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$" vcs="Git" />
</component>
</project>

View File

@ -23,5 +23,5 @@ USER app-user
EXPOSE 8000 EXPOSE 8000
CMD ["nanodjango", "serve", "booking.py"] #CMD ["nanodjango", "serve", "booking.py"]
#CMD ["nanodjango", "run", "booking.py"] CMD ["gunicorn", "--timeout", "180", "--bind", "0.0.0.0:8000", "booking:app"]

View File

@ -1,10 +1,10 @@
Room Booking 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. 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. 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 3. clear overlapping and canceled events in a room booking calendar via a REST
API. API.
@ -35,6 +35,7 @@ with the following JSON payload:
``` ```
Curl example: Curl example:
```bash ```bash
curl -s \ curl -s \
-H "Authorization: Bearer secrettoken" \ -H "Authorization: Bearer secrettoken" \
@ -46,14 +47,18 @@ Curl example:
The response will contain the event ID: The response will contain the event ID:
```json ```json
{"id": "4zx3QAFzoxV3vaZSKGaH2S"} {
"id": "4zx3QAFzoxV3vaZSKGaH2S"
}
``` ```
### Delete an event ### 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: Curl example:
```bash ```bash
curl -s -X DELETE \ curl -s -X DELETE \
-H "Authorization: Bearer secrettoken" \ -H "Authorization: Bearer secrettoken" \
@ -76,6 +81,7 @@ The following parameters can be passed as query parameters:
return what would be deleted. return what would be deleted.
Curl example: Curl example:
```bash ```bash
curl -s -X DELETE \ curl -s -X DELETE \
-H "Authorization: Bearer secrettoken" \ -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 is used for the templates. The following variables are available in the
templates: 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 - `name` - The name of the event
- `start` - The start time (datetime) of the event - `start` - The start time (datetime) of the event
- `end` - The end 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 - `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_cancelled` - Whether the event was cancelled
- `is_recurring` - Whether the event is recurring - `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 - `is_deprioritized` - Whether the event is deprioritized in favor of another
event 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

View File

@ -16,6 +16,7 @@ services:
- SMTP_SERVER=your.smtp.server # adjust this - SMTP_SERVER=your.smtp.server # adjust this
- SMTP_PORT=587 # adjust this if necessary - SMTP_PORT=587 # adjust this if necessary
- SMTP_SENDER_NAME=Room Booking System # adjust this if you want - SMTP_SENDER_NAME=Room Booking System # adjust this if you want
- MAX_SEARCH_HORIZON=14 # adjust maximal range of days (longer search periods will get split into multiple requests)
volumes: volumes:
app-data: app-data:

View File

@ -74,29 +74,12 @@ class EmailTemplate(models.Model):
id = models.AutoField(primary_key=True) id = models.AutoField(primary_key=True)
name = models.CharField(max_length=100, unique=True) name = models.CharField(max_length=100, unique=True)
subject = models.CharField(max_length=200) subject = models.CharField(max_length=200)
body = models.TextField(help_text="""\ plaintext = models.TextField(blank=True, 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> Jinja2 template syntax can be used in the subject and body.<br>
Available variables:<br> See README.md for a list of available variables.""")
- name: The name of the event<br> html = models.TextField(blank=True, help_text="""\
- start: The start time (datetime) of the event<br> Jinja2 template syntax can be used in the subject and body.<br>
- end: The end time (datetime) of the event<br> See README.md for a list of available variables.""")
- created: The datetime the event was created<br>
- is_deleted: Whether the event was deleted<br>
- is_cancelled: Whether the event was cancelled<br>
- is_recurring: Whether the event is recurring<br>
- organizer: The organizer of the event<br>
- is_prioritized: Whether the event is prioritized<br>
- is_deprioritized: Whether the event was deprioritized in favor of another event<br>
- overlap_events: List of overlap events, each with:<br>
- name: The name of the overlap event<br>
- start: The start time (datetime) of the overlap event<br>
- end: The end time (datetime) of the overlap event<br>
- created: The datetime the overlap event was created<br>
- organizer: The organizer of the overlap event<br>
- is_prioritized: Whether the overlap event is prioritized<br>
- calendar_name: The name of the calendar""")
def __str__(self): def __str__(self):
return self.name return self.name
@ -104,7 +87,8 @@ class EmailTemplate(models.Model):
@app.admin( @app.admin(
list_display=("name", "all_users", "auto_clear_bookings", 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",)) list_filter=("auto_clear_bookings",))
class Calendar(models.Model): class Calendar(models.Model):
""" """
@ -124,6 +108,10 @@ class Calendar(models.Model):
help_text="""\ help_text="""\
Email template to use for sending notifications about deleted events.<br> Email template to use for sending notifications about deleted events.<br>
If not set, no email will be sent.""") 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): def save(self, *args, **kwargs):
self.url = self.url.rstrip("/") self.url = self.url.rstrip("/")
@ -148,6 +136,14 @@ class Calendar(models.Model):
""" """
return ", ".join([u.username for u in self.users.all()]) 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"), @app.admin(ordering=("start", "end", "calendar", "name"),
list_filter=("cancelled",), list_filter=("cancelled",),
@ -176,6 +172,9 @@ class Event(models.Model):
self.created = datetime.now(tz=timezone.get_current_timezone()) self.created = datetime.now(tz=timezone.get_current_timezone())
self.ics = self.create_ics() self.ics = self.create_ics()
# Round down to the nearest minute to avoid collisions between events
self.end = self.end.replace(second=0, microsecond=0)
# Send the event to the CalDAV server if it has not been cancelled yet # Send the event to the CalDAV server if it has not been cancelled yet
if not self.cancelled: if not self.cancelled:
try: try:

View File

@ -1,3 +1,5 @@
import email.utils
import json
import os import os
import re import re
import smtplib import smtplib
@ -7,28 +9,218 @@ from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText from email.mime.text import MIMEText
from email.utils import format_datetime from email.utils import format_datetime
from pprint import pprint from pprint import pprint
from typing import NamedTuple
import caldav import caldav
import pytz import pytz
from caldav import CalendarObjectResource from caldav import CalendarObjectResource, Principal, Calendar, DAVObject
from jinja2 import Template from jinja2 import Template
from django.core.exceptions import ObjectDoesNotExist
tz = pytz.timezone(os.getenv("TIME_ZONE", "Europe/Berlin")) tz = pytz.timezone(os.getenv("TIME_ZONE", "Europe/Berlin"))
MAX_SEARCH_HORIZON = int(os.getenv("MAX_SEARCH_HORIZON", 14))
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: DAVObject
obj: CalendarObjectResource
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-Z0-9]{12}\#"
match = re.search(token_pattern, str(description))
if match:
token = match.group()
print(f"Priority token found in event description: {token}")
try:
token_obj = PriorityEventToken.objects.get(token=token)
except ObjectDoesNotExist:
print(f"Priority token could not be found in database: {token}")
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 cancel(self):
"""
Cancel the event.
:return:
"""
self.obj.icalendar_component["status"] = "CANCELLED"
self.obj.save(no_create=True, increase_seqno=False)
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,
}
def dumps(self, indent=4, datetime_format="%Y-%m-%d %X") -> str:
"""
Dump a json string with the event data.
:return: string with json data
"""
return json.dumps({
"uid": self.uid,
"name": self.name,
"start": self.start.strftime(datetime_format),
"end": self.end.strftime(datetime_format),
"duration": self.duration.total_seconds(),
"created": self.created.strftime(datetime_format),
"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,
}, indent=indent)
@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)
else:
date_value = tz.localize(datetime.combine(date_value, datetime.min.time()))
return date_value
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),
])
def clear(target_calendars: list, is_test: bool=False) -> dict: def clear(target_calendars: list, is_test: bool=False) -> dict:
""" """
@ -71,107 +263,91 @@ def clear(target_calendars: list, is_test: bool=False) -> dict:
# Get events from calendar # Get events from calendar
print(f"--- Clearing cancelled bookings and overlaps in calendar: {calendar.id}") print(f"--- Clearing cancelled bookings and overlaps in calendar: {calendar.id}")
horizon = tcal_by_name[calendar.id].auto_clear_overlap_horizon_days 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=True,
split_expanded=True,
)
# Split horizon search into multiple requests if horizon is bigger
# than MAX_SEARCH_HORIZON
horizons = []
while horizon > 0:
if horizon >= MAX_SEARCH_HORIZON:
horizons.append(MAX_SEARCH_HORIZON)
else:
horizons.append(horizon)
horizon -= MAX_SEARCH_HORIZON
events_fetched = []
start_delta = 0
end_delta = 0
today = datetime.now(tz=tz).date()
for h in horizons:
end_delta += h
try:
events_fetched.extend(calendar.search(
start=today + timedelta(days=start_delta),
end=today + timedelta(days=end_delta),
event=True,
expand=True,
split_expanded=True,
))
except Exception as e:
print(f"--- Failed to fetch events for calendar: {calendar.id}: {e}")
continue
start_delta += h
# Create DavEvent objects from fetched events
events = [] events = []
for event in events_fetched: 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 # Filter out events that are missing required fields
if (component.name == "VEVENT" events_missing_required_fields = [
and is_cancelled_event(event) event for event in events if event.missing_required_fields
and not is_recurring_event(event)): ]
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: # Delete cancelled non-recurring events if not in test mode
event.delete() for event in events:
result = { if event.is_cancelled and not event.is_recurring:
"uid": component.get("uid"), if not is_test:
"name": component.get("summary", "No Name"), event.obj.delete()
"calendar": calendar.id, result = {
} "uid": event.uid,
results["deleted_cancelled_events"].append(result) "name": event.name or "No Name",
print("Deleted cancelled event:") "calendar": event.calendar.id,
pprint(result) }
print("------") results["deleted_cancelled_events"].append(result)
print("Deleted cancelled event:")
# Create DavEvent objects for not cancelled events pprint(result)
elif component.name == "VEVENT" and not is_cancelled_event(event): print("------")
# 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),
)
)
# Find overlapping events # Find overlapping events
overlapping_events = find_overlapping_events(events) overlapping_events = find_overlapping_events(events)
overlapping_events_json = json.dumps([json.loads(o.get("event").dumps()) for _, o in overlapping_events.items()], indent=2)
print(f"Found overlapping events:\n{overlapping_events_json}")
# Delete overlapping events and send emails to organizers # Delete overlapping events and send emails to organizers
for overlap in overlapping_events.values(): for overlap in overlapping_events.values():
event = overlap["event"] event = overlap["event"]
overlaps_with = overlap["overlaps_with"] overlaps_with = overlap["overlaps_with"]
is_deleted = False event.is_deprioritized = any(ov.is_prioritized for ov in overlaps_with)
is_cancelled = False
is_deprioritized = any(ov.is_prioritized for ov in overlaps_with)
result = { result = {
"event": { "event": event.serialize(),
"uid": event.uid, "overlaps_with": [ov.serialize() for ov in overlaps_with],
"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],
"calendar": calendar.id, "calendar": calendar.id,
} }
try: try:
@ -179,8 +355,8 @@ def clear(target_calendars: list, is_test: bool=False) -> dict:
if not event.is_recurring: if not event.is_recurring:
# but only if we are not in test mode # but only if we are not in test mode
if not is_test: if not is_test:
event.obj.delete() event.delete()
is_deleted = True
result["cancellation_type"] = "deleted" result["cancellation_type"] = "deleted"
results["deleted_overlapping_events"].append(result) results["deleted_overlapping_events"].append(result)
print("Deleted overlapping event:") print("Deleted overlapping event:")
@ -189,35 +365,66 @@ def clear(target_calendars: list, is_test: bool=False) -> dict:
# we need to cancel it now # we need to cancel it now
elif not event.is_cancelled: elif not event.is_cancelled:
if not is_test: if not is_test:
event.obj.decline_invite() event.cancel()
event.obj.icalendar_component["status"] = "CANCELLED"
event.obj.save()
is_cancelled = True
result["cancellation_type"] = "cancelled" result["cancellation_type"] = "cancelled"
results["cancelled_overlapping_recurring_events"].append(result) results["cancelled_overlapping_recurring_events"].append(result)
print("Cancelled overlapping recurring event:") 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, target_calendars)
# 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 = False
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 # Send email to organizer of the event if not in test mode
email_template = tcal_by_name[calendar.id].email_template email_template = tcal_by_name[calendar.id].email_template
if email_template and not is_test: if email_template:
try: try:
send_mail_to_organizer(event, overlaps_with, calendar, send_mail_to_organizer(
is_deleted, is_cancelled, booking=event,
is_deprioritized, email_template) 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 result["email_sent"] = True
except Exception as e: except Exception as e:
print("Failed to send email to organizer for event " print("Failed to send email to organizer for event "
f"{event.name}: {e}") f"{event.name}: {e}")
result["email_sent"] = False 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: else:
print(f"No email template found for calendar {calendar.id}. "
"Skipping email sending.")
result["email_sent"] = False result["email_sent"] = False
# Print results # Print results
@ -254,7 +461,8 @@ def find_overlapping_events(events: list[DavEvent]) -> dict:
for event in events: for event in events:
# Skip if the event is already in the overlapping events dictionary # Skip if the event is already in the overlapping events dictionary
if overlapping_events.get(create_event_identifier(event)): # or if it is already canceled
if overlapping_events.get(event.extended_uid) or event.is_cancelled:
continue continue
for compare_event in events: for compare_event in events:
@ -263,6 +471,12 @@ def find_overlapping_events(events: list[DavEvent]) -> dict:
if event.uid == compare_event.uid: if event.uid == compare_event.uid:
continue continue
# Skip if the compare_event is already in the overlapping events
# dictionary or if it is already canceled
if (overlapping_events.get(compare_event.extended_uid)
or compare_event.is_cancelled):
continue
# Check if the events overlap # Check if the events overlap
if event_starts_later(event, compare_event) or \ if event_starts_later(event, compare_event) or \
event_starts_earlier(event, compare_event) or \ event_starts_earlier(event, compare_event) or \
@ -282,114 +496,44 @@ def find_overlapping_events(events: list[DavEvent]) -> dict:
continue continue
# Add to overlapping events dictionary # Add to overlapping events dictionary
event_identifier = create_event_identifier(compare_event) if e := overlapping_events.get(event.extended_uid):
if e := overlapping_events.get(event_identifier):
# If the event is already in the dictionary, extend the overlaps # If the event is already in the dictionary, extend the overlaps
e["overlaps_with"].append(event) e["overlaps_with"].append(event)
else: else:
# Create a new entry for the overlapping event # Create a new entry for the overlapping event
overlapping_events[event_identifier] = { overlapping_events[event.extended_uid] = {
"event": compare_event, "event": compare_event,
"overlaps_with": [event] "overlaps_with": [event]
} }
return overlapping_events 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( def send_mail_to_organizer(
event: DavEvent, booking: DavEvent,
overlap_events: list[DavEvent], overlap_bookings: list[DavEvent],
event_overview: list[DavEvent],
calendar: caldav.Calendar, calendar: caldav.Calendar,
is_deleted: bool,
is_cancelled: bool,
is_deprioritized: bool,
email_template, email_template,
other_bookings: list[DavEvent]=None,
alternatives: list[Calendar]=None,
is_test: bool=False is_test: bool=False
): ):
""" """
Send email to organizer of the event. Send email to organizer of the event.
:param event: Event that was declined :param booking: Booking that was declined
:param overlap_events: List of events which overlap with the declined event :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 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 another event, False otherwise
:param email_template: Email template to use for the email :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 :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 # Check if environment variables for SMTP are set
if not all([ if not all([
os.getenv('SMTP_EMAIL'), os.getenv('SMTP_EMAIL'),
@ -398,28 +542,15 @@ def send_mail_to_organizer(
]): ]):
raise Exception("SMTP environment variables are not set.") raise Exception("SMTP environment variables are not set.")
recipient = event.organizer recipient = booking.organizer
# Prepare the email content # Prepare the email content
context = { context = {
"name": event.name, "booking": booking,
"start": event.start, "overlap_bookings": overlap_bookings,
"end": event.end, "other_bookings": other_bookings if other_bookings else [],
"created": event.created, "overview": event_overview,
"is_deleted": is_deleted, "alternatives": alternatives if alternatives else [],
"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],
"calendar_name": calendar.get_display_name(), "calendar_name": calendar.get_display_name(),
} }
@ -427,8 +558,8 @@ def send_mail_to_organizer(
# Create the email message # Create the email message
message = MIMEMultipart("alternative") message = MIMEMultipart("alternative")
message["From"] = f"{os.getenv('SMTP_SENDER_NAME', 'Room Booking')} <{os.getenv('SMTP_EMAIL')}>" message["From"] = email.utils.formataddr((os.getenv('SMTP_SENDER_NAME', 'Room Booking'), os.getenv('SMTP_EMAIL')))
message["To"] = recipient message["To"] = email.utils.formataddr((None, recipient))
if bcc := os.getenv("SMTP_BCC"): if bcc := os.getenv("SMTP_BCC"):
message["Bcc"] = bcc message["Bcc"] = bcc
recipients += bcc.split(",") recipients += bcc.split(",")
@ -436,18 +567,16 @@ def send_mail_to_organizer(
# Try to render subject and body from the email template # Try to render subject and body from the email template
message["Subject"] = Template(email_template.subject).render(**context) message["Subject"] = Template(email_template.subject).render(**context)
body = Template(email_template.body).render(**context) plaintext = Template(email_template.plaintext).render(**context)
message.attach(MIMEText(body, "plain")) 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 # Create a secure SSL context
ssl_context = ssl.create_default_context() ssl_context = ssl.create_default_context()
# Send the email with starttls or SSL based on environment variable # 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(f"Sending email to {', '.join(recipients)} with subject: {message['Subject']}:")
print('"""')
print(body)
print('"""')
if os.getenv('SMTP_STARTTLS', False): if os.getenv('SMTP_STARTTLS', False):
print("Using STARTTLS for SMTP connection.") print("Using STARTTLS for SMTP connection.")
if not is_test: if not is_test:
@ -459,21 +588,97 @@ def send_mail_to_organizer(
else: else:
print("Using SSL for SMTP connection.") print("Using SSL for SMTP connection.")
if not is_test: if not is_test:
with smtplib.SMTP_SSL("mail.extrasolar.space", with smtplib.SMTP_SSL(os.getenv('SMTP_SERVER'),
os.getenv('SMTP_PORT', 465), os.getenv('SMTP_PORT', 465),
context=ssl_context) as server: context=ssl_context) as server:
server.login(os.getenv("SMTP_USER_NAME", os.getenv("SMTP_EMAIL")), os.getenv("SMTP_PASSWORD")) 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()) 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): # Get all events in the calendar for the days of the declined event
date_value = date_value.astimezone(tz) start_date = event.start.date()
elif isinstance(date_value, date): end_date = event.end.date()
date_value = tz.localize(datetime.combine(date_value, datetime.min.time())) events_fetched = calendar.search(
else: start=start_date,
raise ValueError(f"date_value must be a datetime or date object, {type(date_value)} given.") 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,
target_calendars: list) -> 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.
:param target_calendars: Calendars administrated by the current user
:return: List of alternative calendars that are available for the time slot.
"""
# Get all calendars of the principal
calendars = principal.calendars()
alternative_calendars = {
calendar.name: [c.name for c in calendar.alternatives.all()]
for calendar in target_calendars
}
print("Alternative calendars for event calendar: "
f"{', '.join([a for a in alternative_calendars[event.obj.parent.id]])}")
alternatives = []
for calendar in calendars:
# Skip the calendar of the declined event
if calendar.id == event.obj.parent.id:
continue
# Skip if the calendar is not administrated by the current user
if calendar.id not in alternative_calendars.keys():
continue
# Skip if the calendar is not an alternative to the calendar of the
# declined event
if calendar.id not in alternative_calendars[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)
print("Available alternative calendars for event: "
f"{', '.join([a.id for a in alternatives])}")
return alternatives
return date_value

View File

@ -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.<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 See README.md for a list of available variables."
),
),
]

View File

@ -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.<br>\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.<br>\n See README.md for a list of available variables.",
),
),
]

View File

@ -1 +1 @@
1.2.0 1.4.0