Compare commits

..

8 Commits
1.1.0 ... main

Author SHA1 Message Date
Marc Koch 0b3edff6f0
🔖 Version 1.2.0 2025-07-14 20:55:02 +02:00
Marc Koch 76c2bf1631
♻️ reorders imports 2025-07-14 20:15:56 +02:00
Marc Koch e3e9ac3c58
💄show duration in event list display 2025-07-14 20:13:31 +02:00
Marc Koch 14dd63ad72
🐛 fix: declined events not cancelled
Events were declined but not cancelled what lead to a loop of allready declined events getting declined again in every run.
2025-07-14 19:54:20 +02:00
Marc Koch f053f4456f
💄 improve list display of calendars amd events
Enhances the calendar admin interface by adding more relevant
fields to the list display, such as associated users,
auto-clearing settings, and the email template used.

Also improves the event admin by including calendar, creation
date, start, and end times to the list display,
and orders events by calendar in addition to start and end times.
2025-07-14 19:52:51 +02:00
Marc Koch 3fa2763ebf
🐛 fix: corrects placement of cancellation type assignment 2025-07-14 16:40:32 +02:00
Marc Koch c3b1d42cef
Merge branch 'priority_events' 2025-07-14 15:41:53 +02:00
Marc Koch 4bd038bf10
adds prioritized event support
This commit introduces support for prioritized events, allowing certain events to take precedence over others during overlap resolution.

This is achieved by:
- Introducing a `PriorityEventToken` model to manage tokens for priority events.
- Modifying the overlap resolution logic to skip overlaps if a prioritized event is involved.
- Updating email templates to include information about prioritization and overlap events.
- Adding a check for priority tokens in event descriptions.
2025-07-14 15:41:04 +02:00
6 changed files with 395 additions and 106 deletions

View File

@ -159,17 +159,22 @@ 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:
- `event_name` - The name of the event - `name` - The name of the event
- `event_start` - The start time (datetime) of the event - `start` - The start time (datetime) of the event
- `event_end` - The end time (datetime) of the event - `end` - The end time (datetime) of the event
- `event_created` - The datetime the event was created - `created` - The datetime the event was created
- `event_is_deleted` - Whether the event was deleted - `is_deleted` - Whether the event was deleted
- `event_is_cancelled` - Whether the event was cancelled - `is_cancelled` - Whether the event was cancelled
- `event_is_recurring` - Whether the event is recurring - `is_recurring` - Whether the event is recurring
- `event_organizer` - The organizer of the event - `is_priorized` - Whether the event is prioritized
- `overlap_event_name` - The name of the overlap event - `is_deprioritized` - Whether the event is deprioritized in favor of another
- `overlap_event_start` - The start time (datetime) of the overlap event event
- `overlap_event_end` - The end time (datetime) of the overlap event - `organizer` - The organizer of the event
- `overlap_event_created` - The datetime the overlap event was created - `overlap_events` - A list of overlap events, each containing:
- `overlap_event_organizer` - The organizer of the overlap event - `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 - `calendar_name` - The name of the calendar

View File

@ -3,20 +3,21 @@ import secrets
from datetime import datetime from datetime import datetime
from pathlib import Path from pathlib import Path
from uuid import UUID from uuid import UUID
import markdown
import markdown
import requests import requests
import shortuuid import shortuuid
from django.views.decorators.csrf import csrf_exempt from django.core.signing import Signer, BadSignature
from django.core.validators import URLValidator from django.core.validators import URLValidator
from django.db import models from django.db import models
from django.shortcuts import render, get_object_or_404 from django.shortcuts import render, get_object_or_404
from django.utils import timezone from django.utils import timezone
from django.views.decorators.csrf import csrf_exempt
from ics import Calendar as ICS_Calendar, Event as ICS_Event from ics import Calendar as ICS_Calendar, Event as ICS_Event
from nanodjango import Django from nanodjango import Django
from shortuuid.django_fields import ShortUUIDField from shortuuid.django_fields import ShortUUIDField
from clear_overlaps import clear_overlaps as clear from clear_bookings import clear
DEBUG = os.getenv("DJANGO_DEBUG", False) DEBUG = os.getenv("DJANGO_DEBUG", False)
SECRET_KEY = os.getenv("DJANGO_SECRET_KEY") \ SECRET_KEY = os.getenv("DJANGO_SECRET_KEY") \
@ -64,6 +65,7 @@ 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):
""" """
@ -77,25 +79,33 @@ class EmailTemplate(models.Model):
If not set, no email will be sent.<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> Available variables:<br>
- event_name: The name of the event<br> - name: The name of the event<br>
- event_start: The start time (datetime) of the event<br> - start: The start time (datetime) of the event<br>
- event_end: The end time (datetime) of the event<br> - end: The end time (datetime) of the event<br>
- event_created: The datetime the event was created<br> - created: The datetime the event was created<br>
- event_is_deleted: Whether the event was deleted<br> - is_deleted: Whether the event was deleted<br>
- event_is_cancelled: Whether the event was cancelled<br> - is_cancelled: Whether the event was cancelled<br>
- event_is_recurring: Whether the event is recurring<br> - is_recurring: Whether the event is recurring<br>
- event_organizer: The organizer of the event<br> - organizer: The organizer of the event<br>
- overlap_event_name: The name of the overlap event<br> - is_prioritized: Whether the event is prioritized<br>
- overlap_event_start: The start time (datetime) of the overlap event<br> - is_deprioritized: Whether the event was deprioritized in favor of another event<br>
- overlap_event_end: The end time (datetime) of the overlap event<br> - overlap_events: List of overlap events, each with:<br>
- overlap_event_created: The datetime the overlap event was created<br> - name: The name of the overlap event<br>
- overlap_event_organizer: The organizer 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""") - calendar_name: The name of the calendar""")
def __str__(self): def __str__(self):
return self.name return self.name
@app.admin
@app.admin(
list_display=("name", "all_users", "auto_clear_bookings",
"auto_clear_overlap_horizon_days", "email_template"),
list_filter=("auto_clear_bookings",))
class Calendar(models.Model): class Calendar(models.Model):
""" """
A calendar model to store events. A calendar model to store events.
@ -130,8 +140,19 @@ class Calendar(models.Model):
def __str__(self): def __str__(self):
return self.name return self.name
def all_users(self) -> str:
"""
Get the users of the calendar.
It's kind of inefficient, but in small databases it should be fine.
:return: A list of users.
"""
return ", ".join([u.username for u in self.users.all()])
@app.admin(ordering=("start", "end", "name"), list_filter=("cancelled",))
@app.admin(ordering=("start", "end", "calendar", "name"),
list_filter=("cancelled",),
list_display=("name", "calendar", "created", "duration", "start",
"end"))
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.
@ -222,9 +243,105 @@ class Event(models.Model):
def uuid(self) -> UUID: def uuid(self) -> UUID:
return shortuuid.decode(self.id.__str__()) return shortuuid.decode(self.id.__str__())
def duration(self) -> str:
"""
Calculate the duration of the event.
:return: The duration in minutes.
"""
duration = (self.end - self.start).total_seconds() // 60
return f"{duration:.0f}m"
def __str__(self): def __str__(self):
string = f"{self.id} - {self.name} - ({self.start:%Y-%m-%d %H:%M} - {self.end:%Y-%m-%d %H:%M})" return self.name
return string if not self.cancelled else f"{string} - CANCELLED"
@app.admin(
list_display=("token", "is_used", "created_at", "updated_at", "notes"),
list_filter=("is_used",),
read_only_fields=("created_at", "token", "signature"))
class PriorityEventToken(models.Model):
"""
Priority event token model to store tokens for priority events.
"""
id = models.AutoField(primary_key=True)
token = models.CharField(max_length=14, unique=True, editable=False)
signature = models.CharField(max_length=80, blank=True, editable=False)
notes = models.TextField(blank=True)
is_used = models.BooleanField(default=False)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
@classmethod
def generate_token(cls):
return f"#{secrets.token_hex(6).upper()}#"
def save(self, *args, **kwargs):
if not self.token:
self.token = self.generate_token()
return super().save(*args, **kwargs)
def redeem(self, uuid: str) -> str | bool:
"""
Use the token to prioritize the event.
:return: Returns the signature or False if the token or the uuid is not
valid.
"""
# Check for a valid uuid
try:
uuid = UUID(uuid, version=4)
except ValueError:
print(f"Failed to decode UUID {uuid}")
return False
if not self.is_used:
self.signature = self._sign(uuid)
self.is_used = True
self.save()
return True
return False
def validate_signature(self, uuid: str) -> bool:
"""
Validate the signature of the uuid.
:param uuid: The uuid4 of the priority event.
:return:
"""
try:
uuid = UUID(uuid, version=4)
except ValueError:
print(f"Failed to decode UUID {uuid}")
return False
if not self.signature:
print(f"No signature found for token '{self.token}'")
return False
return self._validate(uuid, self.signature)
@staticmethod
def _sign(uuid: UUID) -> str:
"""
Generate a signed uuid.
:return: The signature.
"""
signer = Signer()
return signer.sign(uuid.__str__())
@staticmethod
def _validate(uuid: UUID, signature: str) -> bool:
"""
Validate the signature of the uuid.
:return: True if the signature matches the uuid, False otherwise.
"""
signer = Signer()
try:
return signer.unsign(signature) == uuid.__str__()
except BadSignature as e:
print(e)
return False
def __str__(self):
return self.token
@app.admin(readonly_fields=('key',)) @app.admin(readonly_fields=('key',))
@ -341,7 +458,9 @@ def delete_event(request, calendar: str, event_id: str):
event.cancel() event.cancel()
return 204, None return 204, None
@api.delete("/clear-bookings", response={200: dict, 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_bookings(request, calendar: str = None, test: bool = False): def clear_bookings(request, calendar: str = None, test: bool = False):
user = get_user(request) user = get_user(request)
@ -349,7 +468,7 @@ def clear_bookings(request, calendar: str = None, test: bool = False):
# 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_bookings=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 bookings in calendar '{cal.name}'") f"User not authorised to clear bookings in calendar '{cal.name}'")
@ -362,16 +481,18 @@ def clear_bookings(request, calendar: str = None, test: bool = False):
calendars = user.calendars.filter(auto_clear_bookings=True) calendars = user.calendars.filter(auto_clear_bookings=True)
if not calendars: if not calendars:
return 204, None # No bookings to clear return 204, None # No bookings to clear
result = clear(list(calendars), is_test=test) result = clear(list(calendars), is_test=test)
if all(len(r) == 0 for r in result.values() if type(r) is list): if all(len(r) == 0 for r in result.values() if type(r) is list):
return 204, None # No bookings to clear return 204, None # No bookings to clear
else: else:
return 200, result 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

@ -1,14 +1,16 @@
import os import os
import re
import smtplib import smtplib
import ssl import ssl
from datetime import datetime, date, timedelta from datetime import datetime, date, timedelta
from email.mime.multipart import MIMEMultipart from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText from email.mime.text import MIMEText
from typing import NamedTuple from email.utils import format_datetime
import pytz
from pprint import pprint from pprint import pprint
from typing import NamedTuple
import caldav import caldav
import pytz
from caldav import CalendarObjectResource from caldav import CalendarObjectResource
from jinja2 import Template from jinja2 import Template
@ -23,11 +25,14 @@ DavEvent = NamedTuple("DavEvent", [
("status", str), ("status", str),
("organizer", str), ("organizer", str),
("obj", caldav.Event), ("obj", caldav.Event),
("is_cancelled", bool),
("is_recurring", bool),
("is_prioritized", bool),
]) ])
def clear_overlaps(target_calendars: list, is_test: bool=False) -> dict: def clear(target_calendars: list, is_test: bool=False) -> dict:
""" """
Clear overlapping events in calendars. Clear overlapping and cancelled events in calendars.
:param target_calendars: List of calendars to check for overlaps. :param target_calendars: List of calendars to check for overlaps.
:param is_test: Do not delete events, just print what would be deleted. :param is_test: Do not delete events, just print what would be deleted.
:return: Dictionary with results of the operation. :return: Dictionary with results of the operation.
@ -71,7 +76,7 @@ def clear_overlaps(target_calendars: list, is_test: bool=False) -> dict:
end=date.today() + timedelta(days=horizon), end=date.today() + timedelta(days=horizon),
event=True, event=True,
expand=True, expand=True,
#split_expanded=False, split_expanded=True,
) )
events = [] events = []
@ -129,6 +134,9 @@ def clear_overlaps(target_calendars: list, is_test: bool=False) -> dict:
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,
is_cancelled=is_cancelled_event(event),
is_recurring=is_recurring_event(event),
is_prioritized=is_prioritized_event(event),
) )
) )
@ -140,7 +148,8 @@ def clear_overlaps(target_calendars: list, is_test: bool=False) -> dict:
event = overlap["event"] event = overlap["event"]
overlaps_with = overlap["overlaps_with"] overlaps_with = overlap["overlaps_with"]
is_deleted = False is_deleted = False
is_canceled = False is_cancelled = False
is_deprioritized = any(ov.is_prioritized for ov in overlaps_with)
result = { result = {
"event": { "event": {
"uid": event.uid, "uid": event.uid,
@ -149,51 +158,72 @@ def clear_overlaps(target_calendars: list, is_test: bool=False) -> dict:
"end": event.end, "end": event.end,
"created": event.created, "created": event.created,
"organizer": event.organizer, "organizer": event.organizer,
"is_recurring": event.is_recurring,
"is_prioritized": event.is_prioritized,
"is_deprioritized": is_deprioritized,
}, },
"overlaps_with": { "overlaps_with": [{
"uid": overlaps_with.uid, "uid": ov.uid,
"name": overlaps_with.name, "name": ov.name,
"start": overlaps_with.start, "start": ov.start,
"end": overlaps_with.end, "end": ov.end,
"created": overlaps_with.created, "created": ov.created,
"organizer": overlaps_with.organizer, "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:
# If this is not a recurring event, we can delete it directly # If this is not a recurring event, we can delete it directly
if not is_recurring_event(event.obj): 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.obj.delete()
is_deleted = True is_deleted = True
result["cancellation_type"] = "deleted"
results["deleted_overlapping_events"].append(result) results["deleted_overlapping_events"].append(result)
print("Deleted overlapping event:") print("Deleted overlapping event:")
pprint(result)
print("------")
# If this is a recurring event, and not already canceled, # If this is a recurring event, and not already cancelled,
# we need to cancel it now # we need to cancel it now
elif not is_cancelled_event(event.obj): elif not event.is_cancelled:
if not is_test: if not is_test:
event.obj.decline_invite() event.obj.decline_invite()
is_canceled = True event.obj.icalendar_component["status"] = "CANCELLED"
event.obj.save()
is_cancelled = True
result["cancellation_type"] = "cancelled"
results["cancelled_overlapping_recurring_events"].append(result) results["cancelled_overlapping_recurring_events"].append(result)
print("Cancelled overlapping recurring event:") print("Cancelled overlapping recurring event:")
pprint(result)
print("------")
# 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 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, is_deleted, is_cancelled,
email_template) is_deprioritized, email_template)
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}")
continue 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:
result["email_sent"] = False
# Print results
pprint(result)
print("------")
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("------") print("------")
@ -211,7 +241,6 @@ def find_overlapping_events(events: list[DavEvent]) -> dict:
as values. as values.
""" """
overlapping_events = {} overlapping_events = {}
checked_events = {}
# Order events by created time # Order events by created time
events = sorted(events, key=lambda item: item.created) events = sorted(events, key=lambda item: item.created)
@ -229,25 +258,40 @@ def find_overlapping_events(events: list[DavEvent]) -> dict:
continue continue
for compare_event in events: for compare_event in events:
# Skip if the compare_events is: # Skip if the compare_event is same event or belongs to the same
# 1. The same event or belongs to the same series as the compare event # series as the compare event
# 2. Already checked if event.uid == compare_event.uid:
# 3. Already in the overlapping events dictionary
if event.uid == compare_event.uid \
or checked_events.get(create_event_identifier(compare_event)) \
or overlapping_events.get(create_event_identifier(compare_event)):
continue 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 \
event_inbetween(event, compare_event): event_inbetween(event, compare_event):
# If both events are prioritized, skip the overlap
if compare_event.is_prioritized and event.is_prioritized:
print(f"Skipping overlap between prioritized events: "
f"'{event.name}' and '{compare_event.name}'")
continue
# If the compare_event is prioritized and the event is not,
# skip the overlap
elif compare_event.is_prioritized and not event.is_prioritized:
print(f"Skipping overlap between event '{event.name}' "
f"and prioritized event '{compare_event.name}'")
continue
# Add to overlapping events dictionary # Add to overlapping events dictionary
overlapping_events.update({ event_identifier = create_event_identifier(compare_event)
create_event_identifier(compare_event): {"event": compare_event, if e := overlapping_events.get(event_identifier):
"overlaps_with": event}}) # If the event is already in the dictionary, extend the overlaps
# Add to checked events dictionary e["overlaps_with"].append(event)
checked_events.update({create_event_identifier(event): event}) else:
# Create a new entry for the overlapping event
overlapping_events[event_identifier] = {
"event": compare_event,
"overlaps_with": [event]
}
return overlapping_events return overlapping_events
@ -277,22 +321,74 @@ def is_cancelled_event(event: CalendarObjectResource) -> bool:
""" """
return event.icalendar_component.get("status") == "CANCELLED" 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, event: DavEvent,
overlap_event: DavEvent, overlap_events: list[DavEvent],
calendar: caldav.Calendar, calendar: caldav.Calendar,
is_deleted: bool, is_deleted: bool,
is_canceled: bool, is_cancelled: bool,
is_deprioritized: bool,
email_template, email_template,
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 event: Event that was declined
:param overlap_event: Event that overlaps with the declined event :param overlap_events: List of events which overlap 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_deleted: True if the event was deleted, False otherwise
:param is_canceled: True if the event was canceled, False otherwise :param is_cancelled: True if the event was cancelled, False otherwise
:param is_deprioritized: True if the event was deprioritized in favor of
another event, False otherwise
:param email_template: Email template to use for the email :param email_template: Email template to use for the email
:param is_test: If True, do not send the email, just print it
""" """
# Check if environment variables for SMTP are set # Check if environment variables for SMTP are set
if not all([ if not all([
@ -306,19 +402,24 @@ def send_mail_to_organizer(
# Prepare the email content # Prepare the email content
context = { context = {
"event_name": event.name, "name": event.name,
"event_start": event.start, "start": event.start,
"event_end": event.end, "end": event.end,
"event_created": event.created, "created": event.created,
"event_is_deleted": is_deleted, "is_deleted": is_deleted,
"event_is_canceled": is_canceled, "is_cancelled": is_cancelled,
"event_is_recurring": is_recurring_event(event.obj), "is_recurring": is_recurring_event(event.obj),
"event_organizer": event.organizer, "organizer": event.organizer,
"overlap_event_name": overlap_event.name, "is_prioritized": event.is_prioritized,
"overlap_event_start": overlap_event.start, "is_deprioritized": is_deprioritized,
"overlap_event_end": overlap_event.end, "overlap_events": [{
"overlap_event_created": overlap_event.created, "name": overlap_event.name,
"overlap_event_organizer": overlap_event.organizer, "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(),
} }
@ -331,7 +432,7 @@ def send_mail_to_organizer(
if bcc := os.getenv("SMTP_BCC"): if bcc := os.getenv("SMTP_BCC"):
message["Bcc"] = bcc message["Bcc"] = bcc
recipients += bcc.split(",") recipients += bcc.split(",")
message["Date"] = datetime.now().astimezone(tz).strftime( "%d/%m/%Y %H:%M") message["Date"] = format_datetime(datetime.now().astimezone(tz))
# 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)
@ -342,22 +443,27 @@ def send_mail_to_organizer(
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
print(f"Sending email to {', '.join(recipients)} about event '{event.name}' " overlaps = ", ".join([f"'{overlap.name}'" for overlap in overlap_events])
f"overlap with '{overlap_event.name}'.") 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.")
with smtplib.SMTP(os.getenv('SMTP_SERVER'), if not is_test:
os.getenv('SMTP_PORT', 587)) as server: with smtplib.SMTP(os.getenv('SMTP_SERVER'),
server.starttls(context=ssl_context) os.getenv('SMTP_PORT', 587)) as server:
server.login(os.getenv("SMTP_USER_NAME", os.getenv("SMTP_EMAIL")), os.getenv("SMTP_PASSWORD")) server.starttls(context=ssl_context)
server.sendmail(os.getenv("SMTP_EMAIL"), recipients, message.as_string()) server.login(os.getenv(" ", os.getenv("SMTP_EMAIL")), os.getenv("SMTP_PASSWORD"))
server.sendmail(os.getenv("SMTP_EMAIL"), recipients, message.as_string())
else: else:
print("Using SSL for SMTP connection.") print("Using SSL for SMTP connection.")
with smtplib.SMTP_SSL("mail.extrasolar.space", if not is_test:
os.getenv('SMTP_PORT', 465), with smtplib.SMTP_SSL("mail.extrasolar.space",
context=ssl_context) as server: os.getenv('SMTP_PORT', 465),
server.login(os.getenv("SMTP_USER_NAME", os.getenv("SMTP_EMAIL")), os.getenv("SMTP_PASSWORD")) context=ssl_context) as server:
server.sendmail(os.getenv("SMTP_EMAIL"), recipients, message.as_string()) server.login(os.getenv("SMTP_USER_NAME", os.getenv("SMTP_EMAIL")), os.getenv("SMTP_PASSWORD"))
server.sendmail(os.getenv("SMTP_EMAIL"), recipients, message.as_string())
def handle_date(date_value: datetime | date) -> datetime: def handle_date(date_value: datetime | date) -> datetime:
""" """

View File

@ -0,0 +1,32 @@
# Generated by Django 5.1.4 on 2025-07-11 16:46
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("booking", "0009_alter_emailtemplate_body"),
]
operations = [
migrations.CreateModel(
name="PriorityEventToken",
fields=[
("id", models.AutoField(primary_key=True, serialize=False)),
("token", models.CharField(editable=False, max_length=14, unique=True)),
("signature", models.CharField(blank=True, max_length=80)),
("notes", models.TextField(blank=True)),
("is_used", models.BooleanField(default=False)),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
],
),
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 - name: The name of the event<br>\n - start: The start time (datetime) of the event<br>\n - end: The end time (datetime) of the event<br>\n - created: The datetime the event was created<br>\n - is_deleted: Whether the event was deleted<br>\n - is_cancelled: Whether the event was cancelled<br>\n - is_recurring: Whether the event is recurring<br>\n - organizer: The organizer of the event<br>\n - is_prioritized: Whether the event is prioritized<br>\n - overlap_events: List of overlap events, each with:<br>\n - name: The name of the overlap event<br>\n - start: The start time (datetime) of the overlap event<br>\n - end: The end time (datetime) of the overlap event<br>\n - created: The datetime the overlap event was created<br>\n - organizer: The organizer of the overlap event<br>\n - is_prioritized: Whether the overlap event is prioritized<br>\n - calendar_name: The name of the calendar"
),
),
]

View File

@ -0,0 +1,25 @@
# Generated by Django 5.1.4 on 2025-07-14 09:09
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("booking", "0010_priorityeventtoken_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 - name: The name of the event<br>\n - start: The start time (datetime) of the event<br>\n - end: The end time (datetime) of the event<br>\n - created: The datetime the event was created<br>\n - is_deleted: Whether the event was deleted<br>\n - is_cancelled: Whether the event was cancelled<br>\n - is_recurring: Whether the event is recurring<br>\n - organizer: The organizer of the event<br>\n - is_prioritized: Whether the event is prioritized<br>\n - is_deprioritized: Whether the event was deprioritized in favor of another event<br>\n - overlap_events: List of overlap events, each with:<br>\n - name: The name of the overlap event<br>\n - start: The start time (datetime) of the overlap event<br>\n - end: The end time (datetime) of the overlap event<br>\n - created: The datetime the overlap event was created<br>\n - organizer: The organizer of the overlap event<br>\n - is_prioritized: Whether the overlap event is prioritized<br>\n - calendar_name: The name of the calendar"
),
),
migrations.AlterField(
model_name="priorityeventtoken",
name="signature",
field=models.CharField(blank=True, editable=False, max_length=80),
),
]

View File

@ -1 +1 @@
1.1.0 1.2.0