Compare commits

...

8 Commits
main ... 1.3.0

Author SHA1 Message Date
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
6 changed files with 483 additions and 274 deletions

View File

@ -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

@ -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,4 @@
import email.utils
import os import os
import re import re
import smtplib import smtplib
@ -7,28 +8,192 @@ 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
from jinja2 import Template from jinja2 import Template
tz = pytz.timezone(os.getenv("TIME_ZONE", "Europe/Berlin")) tz = pytz.timezone(os.getenv("TIME_ZONE", "Europe/Berlin"))
DavEvent = NamedTuple("DavEvent", [ class DavEvent:
("uid", str), """
("name", str), Wrapper for calendar events fetched from a CalDAV server.
("start", datetime), """
("end", datetime), uid: str
("created", datetime), name: str
("status", str), start: datetime
("organizer", str), end: datetime
("obj", caldav.Event), duration: timedelta
("is_cancelled", bool), created: datetime
("is_recurring", bool), status: str
("is_prioritized", bool), 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: 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, split_expanded=True,
) )
# 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)
@ -147,31 +293,10 @@ def clear(target_calendars: list, is_test: bool=False) -> dict:
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 +304,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,7 +314,6 @@ 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.obj.icalendar_component["status"] = "CANCELLED" event.obj.icalendar_component["status"] = "CANCELLED"
event.obj.save() event.obj.save()
is_cancelled = True is_cancelled = True
@ -197,27 +321,61 @@ def clear(target_calendars: list, is_test: bool=False) -> dict:
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)
# 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 # 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 +412,7 @@ 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)): if overlapping_events.get(event.extended_uid):
continue continue
for compare_event in events: for compare_event in events:
@ -263,6 +421,11 @@ 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
if overlapping_events.get(compare_event.extended_uid):
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 +445,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 +491,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 +507,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 +516,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 +537,77 @@ 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) -> 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

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.3.0