implement clearing of bookings

This commit is contained in:
Marc Koch 2025-07-10 19:58:12 +02:00
parent 9a44afa6b9
commit 67ec6ccf5e
Signed by: marc
GPG Key ID: 12406554CFB028B9
11 changed files with 493 additions and 72 deletions

121
README.md
View File

@ -1,8 +1,12 @@
Room Booking Room Booking
--- ---
This application allows you to create an event in a room booking calendar via a This application allows you to
REST API.
1. create an event in a room booking calendar via a REST API.
2. delete an event in a room booking calendar via a REST API.
3. clear overlapping and canceled events in a room booking calendar via a REST
API.
## Setup ## Setup
@ -57,4 +61,115 @@ curl -s -X DELETE \
localhost:8000/api/test-kalender/event/4zx3QAFzoxV3vaZSKGaH2S localhost:8000/api/test-kalender/event/4zx3QAFzoxV3vaZSKGaH2S
``` ```
The response will be empty but the status code will be `204`. The response will be empty but the status code will be `204`.
### Clear overlapping and canceled events
To clear overlapping and canceled events, you need to send a DELETE request to
`/api/clear-bookings`.
The following parameters can be passed as query parameters:
- `calendar`: The calendar to clear overlapping events from. If not specified,
all calendars of the user will be cleared.
- `test`: If set to `1` or `ok`, the API will not delete any events but only
return what would be deleted.
Curl example:
```bash
curl -s -X DELETE \
-H "Authorization: Bearer secrettoken" \
localhost:8000/api/clear-bookings?calendar=meeting-room-1&test=on" | jq "."
```
The response will contain a list of events that would be deleted:
```json
{
"deleted_cancelled_events": [],
"deleted_overlapping_events": [
{
"event": {
"uid": "abeda082-2a4b-48a7-a92a-ba24575622ae",
"name": "Fundraising Weekly",
"start": "2025-07-14T00:00:00+02:00",
"end": "2025-07-19T00:00:00+02:00",
"created": "2025-07-09T15:11:19+02:00",
"organizer": "head-of-fundraising@my-awesome.org"
},
"overlaps_with": {
"uid": "2051008d-6ce2-4489-b7d9-38d164c5e66e",
"name": "Finance Monthly",
"start": "2025-07-15T10:00:00+02:00",
"end": "2025-07-15T12:00:00+02:00",
"created": "2025-01-14T08:33:20+01:00",
"organizer": "head-of-finance@my-awesome.org"
},
"calendar": "meeting-room-1"
}
],
"cancelled_overlapping_recurring_events": [
{
"event": {
"uid": "be6d2d42-513b-45ca-bb43-663e2a10a1d7",
"name": "Job Interviews",
"start": "2025-07-14T14:00:00+02:00",
"end": "2025-07-14T15:00:00+02:00",
"created": "2025-06-30T12:04:14+02:00",
"organizer": "head-of-hr@my-awesome.org"
},
"overlaps_with": {
"uid": "f2b7b703-dfba-4ae7-a0e9-bf0e7637c7e4",
"name": "Workers Council Meeting",
"start": "2025-07-14T14:00:00+02:00",
"end": "2025-07-14T15:00:00+02:00",
"created": "2025-02-10T12:33:39+01:00",
"organizer": "workers-council@my-awesome.org"
},
"calendar": "meeting-room-2"
}
],
"ignored_overlapping_events": [],
"test_mode": true
}
```
#### Email Notifications
The organizer of the events will be notified via email about the deletion of
their event if an email template is configured for the calendar.
The following environment variables can be set to configure the application:
- `SMTP_EMAIL` - The email address to use as the sender for notification emails.
- `SMTP_PASSWORD` - The password for the SMTP email account.
- `SMTP_SERVER` - The SMTP server to use for sending emails.
- `SMTP_USER_NAME` - The username for the SMTP email account. If not set, the
`SMTP_EMAIL` will be used as the username.
- `SMTP_SENDER_NAME` - The name to use as the sender for notification emails.
- `SMTP_BCC` - A comma-separated list of email addresses to BCC on notification
emails.
- `SMTP_STARTTLS` - Whether to use STARTTLS for the SMTP connection. Defaults to
`False`, so SSL is used instead.
- `SMTP_PORT` - The port to use for the SMTP connection. Defaults to `465` for
SSL and
`587` for STARTTLS.
Email templates can be configured in the Django Admin interface. Jinja2 syntax
is used for the templates. The following variables are available in the
templates:
- `event_name` - The name of the event
- `event_start` - The start time (datetime) of the event
- `event_end` - The end time (datetime) of the event
- `event_created` - The datetime the event was created
- `event_is_deleted` - Whether the event was deleted
- `event_is_cancelled` - Whether the event was cancelled
- `event_is_recurring` - Whether the event is recurring
- `event_organizer` - The organizer of the event
- `overlap_event_name` - The name of the overlap event
- `overlap_event_start` - The start time (datetime) of the overlap event
- `overlap_event_end` - The end time (datetime) of the overlap event
- `overlap_event_created` - The datetime the overlap event was created
- `overlap_event_organizer` - The organizer of the overlap event
- `calendar_name` - The name of the calendar

View File

@ -30,12 +30,26 @@ if not SECRET_KEY and not DEBUG:
print("DJANGO_SECRET_KEY is not set") print("DJANGO_SECRET_KEY is not set")
exit(1) exit(1)
ALLOWED_HOSTS = [host.strip() for host in
os.getenv("DJANGO_ALLOWED_HOSTS", "").split(",")]
# Set CSRF_TRUSTED_ORIGINS to allow requests from the allowed hosts
CSRF_TRUSTED_ORIGINS = [
host if host.startswith(("http://", "https://"))
else f"https://{host}"
for host in ALLOWED_HOSTS
]
if DEBUG:
print(f"ALLOWED_HOSTS: {ALLOWED_HOSTS}")
print(f"CSRF_TRUSTED_ORIGINS: {CSRF_TRUSTED_ORIGINS}")
# Initialise nanodjango # Initialise nanodjango
app = Django( app = Django(
SECRET_KEY=SECRET_KEY, SECRET_KEY=SECRET_KEY,
TIME_ZONE=os.getenv("TIME_ZONE", "Europe/Berlin"), TIME_ZONE=os.getenv("TIME_ZONE", "Europe/Berlin"),
ALLOWED_HOSTS=["localhost"] + [host for host in ALLOWED_HOSTS=["localhost"] + ALLOWED_HOSTS,
os.getenv("DJANGO_ALLOWED_HOSTS", "").split(",")], CSRF_TRUSTED_ORIGINS=CSRF_TRUSTED_ORIGINS,
SQLITE_DATABASE=DATA_DIR / "db.sqlite3", SQLITE_DATABASE=DATA_DIR / "db.sqlite3",
DEBUG=DEBUG, DEBUG=DEBUG,
TEMPLATES_DIR=BASE_DIR / "templates", TEMPLATES_DIR=BASE_DIR / "templates",
@ -50,7 +64,6 @@ from ninja import NinjaAPI, ModelSchema
from ninja.security import HttpBearer, django_auth from ninja.security import HttpBearer, django_auth
from ninja.errors import HttpError from ninja.errors import HttpError
@app.admin @app.admin
class EmailTemplate(models.Model): class EmailTemplate(models.Model):
""" """
@ -65,20 +78,23 @@ class EmailTemplate(models.Model):
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> Available variables:<br>
- event_name: The name of the event<br> - event_name: The name of the event<br>
- event_start: The start time of the event<br> - event_start: The start time (datetime) of the event<br>
- event_end: The end time of the event<br> - event_end: The end time (datetime) of the event<br>
- event_created: The time the event was created<br> - event_created: The datetime the event was created<br>
- event_is_deleted: Whether the event was deleted<br>
- event_is_cancelled: Whether the event was cancelled<br>
- event_is_recurring: Whether the event is recurring<br>
- event_organizer: The organizer of the event<br>
- overlap_event_name: The name of the overlap event<br> - overlap_event_name: The name of the overlap event<br>
- overlap_event_start: The start time of the overlap event<br> - overlap_event_start: The start time (datetime) of the overlap event<br>
- overlap_event_end: The end time of the overlap event<br> - overlap_event_end: The end time (datetime) of the overlap event<br>
- overlap_event_created: The time the overlap event was created<br> - overlap_event_created: The datetime the overlap event was created<br>
- overlap_event_organizer: The organizer of the overlap event<br> - overlap_event_organizer: The organizer of the overlap event<br>
- calendar_name: The name of the calendar""") - calendar_name: The name of the calendar""")
def __str__(self): def __str__(self):
return self.name return self.name
@app.admin @app.admin
class Calendar(models.Model): class Calendar(models.Model):
""" """
@ -90,7 +106,7 @@ class Calendar(models.Model):
calendar_password = models.CharField(max_length=29) calendar_password = models.CharField(max_length=29)
users = models.ManyToManyField(app.settings.AUTH_USER_MODEL, users = models.ManyToManyField(app.settings.AUTH_USER_MODEL,
related_name='calendars') related_name='calendars')
auto_clear_overlaps = models.BooleanField(default=False) auto_clear_bookings = models.BooleanField(default=False)
auto_clear_overlap_horizon_days = models.IntegerField(default=30) auto_clear_overlap_horizon_days = models.IntegerField(default=30)
email_template = models.ForeignKey(EmailTemplate, on_delete=models.SET_NULL, email_template = models.ForeignKey(EmailTemplate, on_delete=models.SET_NULL,
null=True, blank=True, null=True, blank=True,
@ -115,7 +131,7 @@ class Calendar(models.Model):
return self.name return self.name
@app.admin(ordering=("start", "end", "name")) @app.admin(ordering=("start", "end", "name"), list_filter=("cancelled",))
class Event(models.Model): class Event(models.Model):
""" """
Event model to store events in a calendar and send them to a CalDAV server. Event model to store events in a calendar and send them to a CalDAV server.
@ -126,6 +142,7 @@ class Event(models.Model):
related_name='events') related_name='events')
name = models.CharField(max_length=100) name = models.CharField(max_length=100)
path = models.CharField(max_length=200, editable=False) path = models.CharField(max_length=200, editable=False)
created = models.DateTimeField()
start = models.DateTimeField() start = models.DateTimeField()
end = models.DateTimeField() end = models.DateTimeField()
ics = models.TextField(blank=True) ics = models.TextField(blank=True)
@ -134,6 +151,8 @@ class Event(models.Model):
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
self.path = f"{self.calendar.url}/{self.uuid}" self.path = f"{self.calendar.url}/{self.uuid}"
if self.created is None:
self.created = datetime.now(tz=timezone.get_current_timezone())
self.ics = self.create_ics() self.ics = self.create_ics()
# 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
@ -192,9 +211,10 @@ class Event(models.Model):
e = ICS_Event() e = ICS_Event()
e.uid = self.uuid.__str__() e.uid = self.uuid.__str__()
e.name = self.name e.name = self.name
e.created = self.created
e.begin = self.start e.begin = self.start
e.end = self.end e.end = self.end
e.description = f"Meeting-ID: {self.id}" e.description = f"Booking-ID: {self.id}"
c.events.add(e) c.events.add(e)
return c.serialize() return c.serialize()
@ -321,38 +341,37 @@ def delete_event(request, calendar: str, event_id: str):
event.cancel() event.cancel()
return 204, None return 204, None
@api.delete("/clear-overlaps", response={200: None, 204: None, 401: None, 404: None}) @api.delete("/clear-bookings", response={200: dict, 204: None, 401: None, 404: None})
@csrf_exempt @csrf_exempt
def clear_overlaps(request, calendar: str = None): def clear_bookings(request, calendar: str = None, test: bool = False):
user = get_user(request) user = get_user(request)
# Get optional calendar name from the request # Get optional calendar name from the request
if calendar: if calendar:
cal = get_object_or_404(Calendar, name=calendar, cal = get_object_or_404(Calendar, name=calendar,
auto_clear_overlaps=True) auto_clear_bookings=True)
if user not in cal.users.all(): if user not in cal.users.all():
raise HttpError(401, raise HttpError(401,
f"User not authorised to clear overlaps in calendar '{cal.name}'") f"User not authorised to clear bookings in calendar '{cal.name}'")
calendars = [cal] calendars = [cal]
# If no calendar is specified, get all calendars for the user that have # If no calendar is specified, get all calendars for the user that have
# auto_clear_overlaps enabled # auto_clear_bookings enabled
else: else:
calendars = user.calendars.filter(auto_clear_overlaps=True) calendars = user.calendars.filter(auto_clear_bookings=True)
if not calendars: if not calendars:
return 200, "No calendars with auto_clear_overlaps enabled found for user." return 204, None # No bookings to clear
if clear(list(calendars)): result = clear(list(calendars), is_test=test)
return 204, "Overlaps cleared successfully." if all(len(r) == 0 for r in result.values() if type(r) is list):
return 204, None # No bookings to clear
else: else:
raise HttpError(200, "No overlaps to clear.") return 200, result
app.route("api/", include=api.urls) app.route("api/", include=api.urls)
@app.route("/") @app.route("/")
@csrf_exempt @csrf_exempt
def home(request): def home(request):

View File

@ -6,8 +6,10 @@ from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText from email.mime.text import MIMEText
from typing import NamedTuple from typing import NamedTuple
import pytz import pytz
from pprint import pprint
import caldav import caldav
from caldav import CalendarObjectResource
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"))
@ -23,13 +25,22 @@ DavEvent = NamedTuple("DavEvent", [
("obj", caldav.Event), ("obj", caldav.Event),
]) ])
def clear_overlaps(target_calendars: list) -> bool: def clear_overlaps(target_calendars: list, is_test: bool=False) -> dict:
""" """
Clear overlapping events in calendars. Clear overlapping events in calendars.
:param target_calendars: List of calendars to check for overlaps. :param target_calendars: List of calendars to check for overlaps.
:return: True if overlaps were cleared, False otherwise. :param is_test: Do not delete events, just print what would be deleted.
:return: Dictionary with results of the operation.
""" """
cleared = False print(f"--- Clear bookings.{' Test mode enabled.' if is_test else ''}")
results = {
"deleted_cancelled_events": [],
"deleted_overlapping_events": [],
"cancelled_overlapping_recurring_events": [],
"ignored_overlapping_events": [],
"test_mode": is_test,
}
dav_client = caldav.DAVClient( dav_client = caldav.DAVClient(
url=target_calendars[0].url, url=target_calendars[0].url,
@ -47,32 +58,74 @@ def clear_overlaps(target_calendars: list) -> bool:
if cal.id in tcal_by_name.keys()] if cal.id in tcal_by_name.keys()]
if not calendars: if not calendars:
print("No calendars to clear overlaps in. Exiting.") print("--- No calendars to clear overlaps in. Exiting.")
return False return results
for calendar in calendars: for calendar in calendars:
# Get events from calendar # Get events from calendar
print(f"Clearing 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( events_fetched = calendar.search(
start=datetime.now(), start=datetime.now(),
end=date.today() + timedelta(days=horizon), end=date.today() + timedelta(days=horizon),
event=True, event=True,
expand=False, expand=True,
#split_expanded=False,
) )
events = [] events = []
for event in events_fetched: for event in events_fetched:
for component in event.icalendar_instance.walk(): for component in event.icalendar_instance.walk():
if component.name == "VEVENT" and component.get("status") != "CANCELLED":
# Delete cancelled non-recurring events if not in test mode
if (component.name == "VEVENT"
and is_cancelled_event(event)
and not is_recurring_event(event)):
if not is_test:
event.delete()
result = {
"uid": component.get("uid"),
"name": component.get("summary", "No Name"),
"calendar": calendar.id,
}
results["deleted_cancelled_events"].append(result)
print("Deleted cancelled event:")
pprint(result)
print("------")
# Create DavEvent objects for not cancelled events
elif component.name == "VEVENT" and not is_cancelled_event(event):
# Skip events which miss required fields
required = {
"uid": component.get("uid", False),
"dtstart": component.get("dtstart", False),
"dtend": component.get("dtend", False),
"dtstamp": component.get("dtstamp", False),
}
if not all(required.values()):
result = {
"uid": component.get("uid"),
"name": component.get("summary", "No Name"),
"calendar": calendar.id,
"reason": f"Missing required fields: {', '.join([k for k, v in required.items() if v is False])}",
}
results["ignored_overlapping_events"].append(result)
print("Skipping event:")
pprint(result)
print("------")
continue
# Create DavEvent object
events.append( events.append(
DavEvent( DavEvent(
uid=component.get("uid"), uid=component.get("uid"),
name=component.get("summary", "No Name"), name=component.get("summary", "No Name"),
start=component.get("dtstart").dt.astimezone(tz), start=handle_date(component.get("dtstart").dt),
end=component.get("dtend").dt.astimezone(tz), end=handle_date(component.get("dtend").dt),
created=component.get("created").dt.astimezone(tz), created=component.get("dtstamp").dt.astimezone(tz),
status=component.get("status", "CONFIRMED"), status=component.get("status", "CONFIRMED"),
organizer=component.get("organizer", "").replace("mailto:", "").strip(), organizer=component.get("organizer", "").replace("mailto:", "").strip(),
obj=event, obj=event,
@ -86,16 +139,56 @@ def clear_overlaps(target_calendars: list) -> bool:
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
is_canceled = False
result = {
"event": {
"uid": event.uid,
"name": event.name,
"start": event.start,
"end": event.end,
"created": event.created,
"organizer": event.organizer,
},
"overlaps_with": {
"uid": overlaps_with.uid,
"name": overlaps_with.name,
"start": overlaps_with.start,
"end": overlaps_with.end,
"created": overlaps_with.created,
"organizer": overlaps_with.organizer,
},
"calendar": calendar.id,
}
try: try:
# Delete the event # If this is not a recurring event, we can delete it directly
event.obj.delete() if not is_recurring_event(event.obj):
cleared = True # but only if we are not in test mode
if not is_test:
event.obj.delete()
is_deleted = True
results["deleted_overlapping_events"].append(result)
print("Deleted overlapping event:")
pprint(result)
print("------")
# Send email to organizer of the event # If this is a recurring event, and not already canceled,
# we need to cancel it now
elif not is_cancelled_event(event.obj):
if not is_test:
event.obj.decline_invite()
is_canceled = True
results["cancelled_overlapping_recurring_events"].append(result)
print("Cancelled overlapping recurring event:")
pprint(result)
print("------")
# 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: if email_template and not is_test:
try: try:
send_mail_to_organizer(event, overlaps_with, calendar, send_mail_to_organizer(event, overlaps_with, calendar,
is_deleted, is_canceled,
email_template) email_template)
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 "
@ -103,10 +196,11 @@ def clear_overlaps(target_calendars: list) -> bool:
continue continue
except Exception as e: except Exception as e:
print(f"Failed to delete event {event.uid}: {e}") print(f"Failed to delete event {event.uid}: {e}")
print("------")
continue continue
return cleared print(f"--- Clearing completed.{' Test mode enabled.' if is_test else ''}")
return results
def find_overlapping_events(events: list[DavEvent]) -> dict: def find_overlapping_events(events: list[DavEvent]) -> dict:
""" """
@ -119,51 +213,76 @@ def find_overlapping_events(events: list[DavEvent]) -> dict:
overlapping_events = {} overlapping_events = {}
checked_events = {} checked_events = {}
# Create a dictionary to make it easier to find an event by its UID
events = {e.uid: e for e in events}
# Order events by created time # Order events by created time
events = dict(sorted(events.items(), key=lambda item: item[1].created)) events = sorted(events, key=lambda item: item.created)
# Define lambda functions to check for overlaps # Define lambda functions to check for overlaps
event_starts_earlier = lambda e1, e2: e1.start <= e2.start <= e1.end event_starts_later = lambda e1, e2: e1.end >= e2.end > e1.start
event_inbetween = lambda e1, e2: e2.start <= e1.start <= e1.end <= e2.end event_starts_earlier = lambda e1, e2: e1.end > e2.start >= e1.start
event_ends_later = lambda e1, e2: e1.end >= e2.end >= e1.start event_inbetween = lambda e1, e2: e2.start < e1.start < e1.end < e2.end
# Find overlapping events # Find overlapping events
for event in events.values(): 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 event.uid in overlapping_events.keys(): if overlapping_events.get(create_event_identifier(event)):
continue continue
for compare_event in events.values(): for compare_event in events:
# Skip if the events is: # Skip if the compare_events is:
# 1. The same event as the compare_event # 1. The same event or belongs to the same series as the compare event
# 2. Already checked # 2. Already checked
# 3. Already in the overlapping events dictionary # 3. Already in the overlapping events dictionary
if event.uid == compare_event.uid \ if event.uid == compare_event.uid \
or compare_event.uid in checked_events.keys() \ or checked_events.get(create_event_identifier(compare_event)) \
or compare_event.uid in overlapping_events.keys(): or overlapping_events.get(create_event_identifier(compare_event)):
continue continue
# Check if the events overlap # Check if the events overlap
if event_starts_earlier(event, compare_event) or \ if event_starts_later(event, compare_event) or \
event_inbetween(event, compare_event) or \ event_starts_earlier(event, compare_event) or \
event_ends_later(event, compare_event): event_inbetween(event, compare_event):
# Add to overlapping events dictionary # Add to overlapping events dictionary
overlapping_events.update({ overlapping_events.update({
compare_event.uid: {"event": compare_event, create_event_identifier(compare_event): {"event": compare_event,
"overlaps_with": event}}) "overlaps_with": event}})
# Add to checked events dictionary # Add to checked events dictionary
checked_events.update({event.uid: event}) checked_events.update({create_event_identifier(event): 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 send_mail_to_organizer( def send_mail_to_organizer(
event: DavEvent, event: DavEvent,
overlap_event: DavEvent, overlap_event: DavEvent,
calendar: caldav.Calendar, calendar: caldav.Calendar,
is_deleted: bool,
is_canceled: bool,
email_template, email_template,
): ):
""" """
@ -171,6 +290,8 @@ def send_mail_to_organizer(
:param event: Event that was declined :param event: Event that was declined
:param overlap_event: Event that overlaps with the declined event :param overlap_event: Event that overlaps with the declined event
: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_canceled: True if the event was canceled, False otherwise
:param email_template: Email template to use for the email :param email_template: Email template to use for the email
""" """
# Check if environment variables for SMTP are set # Check if environment variables for SMTP are set
@ -189,6 +310,10 @@ def send_mail_to_organizer(
"event_start": event.start, "event_start": event.start,
"event_end": event.end, "event_end": event.end,
"event_created": event.created, "event_created": event.created,
"event_is_deleted": is_deleted,
"event_is_canceled": is_canceled,
"event_is_recurring": is_recurring_event(event.obj),
"event_organizer": event.organizer,
"overlap_event_name": overlap_event.name, "overlap_event_name": overlap_event.name,
"overlap_event_start": overlap_event.start, "overlap_event_start": overlap_event.start,
"overlap_event_end": overlap_event.end, "overlap_event_end": overlap_event.end,
@ -197,10 +322,15 @@ def send_mail_to_organizer(
"calendar_name": calendar.get_display_name(), "calendar_name": calendar.get_display_name(),
} }
recipients = [recipient]
# 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"] = f"{os.getenv('SMTP_SENDER_NAME', 'Room Booking')} <{os.getenv('SMTP_EMAIL')}>"
message["To"] = recipient message["To"] = recipient
if bcc := os.getenv("SMTP_BCC"):
message["Bcc"] = bcc
recipients += bcc.split(",")
message["Date"] = datetime.now().astimezone(tz).strftime( "%d/%m/%Y %H:%M") message["Date"] = datetime.now().astimezone(tz).strftime( "%d/%m/%Y %H:%M")
# Try to render subject and body from the email template # Try to render subject and body from the email template
@ -211,9 +341,33 @@ def send_mail_to_organizer(
# 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 # Send the email with starttls or SSL based on environment variable
with smtplib.SMTP_SSL("mail.extrasolar.space", print(f"Sending email to {', '.join(recipients)} about event '{event.name}' "
os.getenv('SMTP_PORT', 465), f"overlap with '{overlap_event.name}'.")
context=ssl_context) as server: if os.getenv('SMTP_STARTTLS', False):
server.login(os.getenv("SMTP_EMAIL"), os.getenv("SMTP_PASSWORD")) print("Using STARTTLS for SMTP connection.")
server.sendmail(os.getenv("SMTP_EMAIL"), recipient, message.as_string()) with smtplib.SMTP(os.getenv('SMTP_SERVER'),
os.getenv('SMTP_PORT', 587)) as server:
server.starttls(context=ssl_context)
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())
else:
print("Using SSL for SMTP connection.")
with smtplib.SMTP_SSL("mail.extrasolar.space",
os.getenv('SMTP_PORT', 465),
context=ssl_context) as server:
server.login(os.getenv("SMTP_USER_NAME", os.getenv("SMTP_EMAIL")), os.getenv("SMTP_PASSWORD"))
server.sendmail(os.getenv("SMTP_EMAIL"), recipients, message.as_string())
def handle_date(date_value: datetime | date) -> datetime:
"""
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

View File

@ -0,0 +1,18 @@
# Generated by Django 5.1.4 on 2025-06-27 13:19
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("booking", "0002_emailtemplate_and_more"),
]
operations = [
migrations.AddField(
model_name="event",
name="created",
field=models.DateTimeField(auto_now_add=True, null=True),
),
]

View File

@ -0,0 +1,19 @@
# Generated by Django 5.1.4 on 2025-06-27 16:11
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("booking", "0003_event_created"),
]
operations = [
migrations.AlterField(
model_name="event",
name="created",
field=models.DateTimeField(auto_now_add=True, default=None),
preserve_default=False,
),
]

View File

@ -0,0 +1,18 @@
# Generated by Django 5.1.4 on 2025-06-27 16:14
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("booking", "0004_alter_event_created"),
]
operations = [
migrations.AlterField(
model_name="event",
name="created",
field=models.DateTimeField(),
),
]

View File

@ -0,0 +1,18 @@
# Generated by Django 5.1.4 on 2025-06-30 16:08
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("booking", "0005_alter_event_created"),
]
operations = [
migrations.RenameField(
model_name="calendar",
old_name="auto_clear_overlaps",
new_name="auto_clear_bookings",
),
]

View File

@ -0,0 +1,20 @@
# Generated by Django 5.1.4 on 2025-07-10 16:19
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("booking", "0006_rename_auto_clear_overlaps_calendar_auto_clear_bookings"),
]
operations = [
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 Available variables:<br>\n - event_name: The name of the event<br>\n - event_start: The start time of the event<br>\n - event_end: The end time of the event<br>\n - event_created: The time the event was created<br>\n - event_is_deleted: Whether the event was deleted<br>\n - event_is_cancelled: Whether the event was cancelled<br>\n - overlap_event_name: The name of the overlap event<br>\n - overlap_event_start: The start time of the overlap event<br>\n - overlap_event_end: The end time of the overlap event<br>\n - overlap_event_created: The time the overlap event was created<br>\n - overlap_event_organizer: The organizer of the overlap event<br>\n - calendar_name: The name of the calendar"
),
),
]

View File

@ -0,0 +1,20 @@
# Generated by Django 5.1.4 on 2025-07-10 16:24
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("booking", "0007_alter_emailtemplate_body"),
]
operations = [
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 Available variables:<br>\n - event_name: The name of the event<br>\n - event_start: The start time of the event<br>\n - event_end: The end time of the event<br>\n - event_created: The time the event was created<br>\n - event_is_deleted: Whether the event was deleted<br>\n - event_is_cancelled: Whether the event was cancelled<br>\n - event_is_recurring: Whether the event is recurring<br>\n - event_organizer: The organizer of the event<br>\n - overlap_event_name: The name of the overlap event<br>\n - overlap_event_start: The start time of the overlap event<br>\n - overlap_event_end: The end time of the overlap event<br>\n - overlap_event_created: The time the overlap event was created<br>\n - overlap_event_organizer: The organizer of the overlap event<br>\n - calendar_name: The name of the calendar"
),
),
]

View File

@ -0,0 +1,20 @@
# Generated by Django 5.1.4 on 2025-07-10 17:21
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("booking", "0008_alter_emailtemplate_body"),
]
operations = [
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 Available variables:<br>\n - event_name: The name of the event<br>\n - event_start: The start time (datetime) of the event<br>\n - event_end: The end time (datetime) of the event<br>\n - event_created: The datetime the event was created<br>\n - event_is_deleted: Whether the event was deleted<br>\n - event_is_cancelled: Whether the event was cancelled<br>\n - event_is_recurring: Whether the event is recurring<br>\n - event_organizer: The organizer of the event<br>\n - overlap_event_name: The name of the overlap event<br>\n - overlap_event_start: The start time (datetime) of the overlap event<br>\n - overlap_event_end: The end time (datetime) of the overlap event<br>\n - overlap_event_created: The datetime the overlap event was created<br>\n - overlap_event_organizer: The organizer of the overlap event<br>\n - calendar_name: The name of the calendar"
),
),
]

View File

@ -1 +1 @@
1.1.0-alpha1 1.1.0