Compare commits
8 Commits
| Author | SHA1 | Date |
|---|---|---|
|
|
1d62cab68d | |
|
|
037cfa0d5e | |
|
|
85d0f940e5 | |
|
|
66cb2f01f8 | |
|
|
9bfb8c28f6 | |
|
|
f7755cd61d | |
|
|
ef60524866 | |
|
|
ee4ff38f13 |
48
README.md
48
README.md
|
|
@ -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
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
|
||||||
|
|
@ -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."
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
@ -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.",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
@ -1 +1 @@
|
||||||
1.2.0
|
1.3.0
|
||||||
Loading…
Reference in New Issue