Compare commits

..

21 Commits
1.0.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
Marc Koch 595d02403d
🔀 merge branch 'clear_event_overlaps' 2025-07-10 19:59:18 +02:00
Marc Koch 67ec6ccf5e
implement clearing of bookings 2025-07-10 19:58:12 +02:00
Marc Koch 9a44afa6b9
🔖bump version to 1.1.0-alpha1 2025-06-25 17:05:18 +02:00
Marc Koch 07ddbb793e
🔧 configure application environment variables
Sets up environment variables within the docker-compose file,
replacing the use of a .env file. This includes defining
variables for Django secret key, allowed hosts, SMTP settings,
and sender name. This change improves configuration management
and clarifies the necessary environment settings.
2025-06-25 17:02:50 +02:00
Marc Koch e6ab41594d
add automatic event overlap clearing
Implements a mechanism to automatically clear overlapping events in user calendars.

Introduces a new model for email templates, enabling customizable notifications to event organizers when their events are declined due to overlaps.

Adds a background task to find overlapping events based on calendar settings and automatically cancels the later event, sending a notification email to the organizer, if configured.
2025-06-25 17:01:52 +02:00
Marc Koch cf95da9d81
🐛 fix info endpoint 2025-06-18 17:02:15 +02:00
Marc Koch 6f03a47129
🩹 use csrf_exempt decorator instead of CSRF_TRUSTED_ORIGINS 2025-06-18 17:02:14 +02:00
Marc Koch 828b4096a3
♻️ renamed variables and environment variables
Also introduces an env variable to set the DJANGO_DATA_DIR
2025-06-18 17:02:13 +02:00
Marc Koch 3e5e53a929
🐛 show short event_id in description 2025-04-25 17:57:08 +02:00
Marc Koch 4a37e61265
add csrf env settion 2025-04-25 17:33:35 +02:00
Marc Koch 1a2e88948a
separate data from code 2025-04-25 17:06:49 +02:00
Marc Koch 244c9f376e
display app version 2025-04-25 16:39:10 +02:00
Marc Koch 0e2e80529c
provide event_id in event description 2025-04-25 16:38:19 +02:00
19 changed files with 1112 additions and 20 deletions

View File

@ -6,14 +6,18 @@ ENV PYTHONUNBUFFERED 1
WORKDIR /app WORKDIR /app
RUN mkdir /data
COPY src/ ./ COPY src/ ./
COPY requirements.txt . COPY requirements.txt .
COPY README.md index.md COPY README.md index.md
COPY version.txt .
RUN pip install --no-cache-dir -r requirements.txt RUN pip install --no-cache-dir -r requirements.txt
RUN groupadd -r app-user && useradd -r -g app-user app-user && \ RUN groupadd -r app-user && useradd -r -g app-user app-user && \
chown -R app-user:app-user /app chown -R app-user:app-user /app && \
chown -R app-user:app-user /data
USER app-user USER app-user

124
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
@ -58,3 +62,119 @@ curl -s -X DELETE \
``` ```
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:
- `name` - The name of the event
- `start` - The start time (datetime) of the event
- `end` - The end time (datetime) of the event
- `created` - The datetime the event was created
- `is_deleted` - Whether the event was deleted
- `is_cancelled` - Whether the event was cancelled
- `is_recurring` - Whether the event is recurring
- `is_priorized` - Whether the event is prioritized
- `is_deprioritized` - Whether the event is deprioritized in favor of another
event
- `organizer` - The organizer of the event
- `overlap_events` - A list of overlap events, each containing:
- `name` - The name of the overlap event
- `start` - The start time (datetime) of the overlap event
- `end` - The end time (datetime) of the overlap event
- `created` - The datetime the overlap event was created
- `organizer` - The organizer of the overlap event
- `is_priorized` - Whether the overlap event is prioritized
- `calendar_name` - The name of the calendar

View File

@ -1,15 +1,21 @@
--- ---
services: services:
app: app:
image: git.extrasolar.space/marc/room-booking.git:latest
build: build:
context: . context: .
dockerfile: Dockerfile dockerfile: Dockerfile
ports: ports:
- "8000:8000" - "8000:8000"
volumes: volumes:
- app-data:/app - app-data:/data
env_file: .env environment:
- DJANGO_SECRET_KEY=A_VERY_SECRETKEY_KEY # adjust this
- DJANGO_ALLOWED_HOSTS=www.example.org # adjust this (comma-separated list of allowed hosts)
- SMTP_EMAIL=room-booking@example.org # adjust this
- SMTP_PASSWORD=YOUR_SMTP_PASSWORD # adjust this
- SMTP_SERVER=your.smtp.server # adjust this
- SMTP_PORT=587 # adjust this if necessary
- SMTP_SENDER_NAME=Room Booking System # adjust this if you want
volumes: volumes:
app-data: app-data:

View File

@ -3,6 +3,7 @@ arrow==1.3.0
asgiref==3.8.1 asgiref==3.8.1
attrs==24.3.0 attrs==24.3.0
black==24.10.0 black==24.10.0
caldav==1.6.0
certifi==2024.12.14 certifi==2024.12.14
charset-normalizer==3.4.1 charset-normalizer==3.4.1
click==8.1.8 click==8.1.8
@ -10,10 +11,14 @@ Django==5.1.4
django-ninja==1.3.0 django-ninja==1.3.0
gunicorn==23.0.0 gunicorn==23.0.0
h11==0.14.0 h11==0.14.0
icalendar==6.3.1
ics==0.7.2 ics==0.7.2
idna==3.10 idna==3.10
isort==5.13.2 isort==5.13.2
Jinja2==3.1.6
lxml==5.4.0
Markdown==3.7 Markdown==3.7
MarkupSafe==3.0.2
mypy-extensions==1.0.0 mypy-extensions==1.0.0
nanodjango==0.9.2 nanodjango==0.9.2
packaging==24.2 packaging==24.2
@ -22,6 +27,8 @@ platformdirs==4.3.6
pydantic==2.10.4 pydantic==2.10.4
pydantic_core==2.27.2 pydantic_core==2.27.2
python-dateutil==2.9.0.post0 python-dateutil==2.9.0.post0
pytz==2025.2
recurring-ical-events==3.8.0
requests==2.32.3 requests==2.32.3
shortuuid==1.0.13 shortuuid==1.0.13
six==1.17.0 six==1.17.0
@ -29,7 +36,10 @@ sqlparse==0.5.3
TatSu==5.12.2 TatSu==5.12.2
types-python-dateutil==2.9.0.20241206 types-python-dateutil==2.9.0.20241206
typing_extensions==4.12.2 typing_extensions==4.12.2
tzdata==2025.2
urllib3==2.3.0 urllib3==2.3.0
uvicorn==0.34.0 uvicorn==0.34.0
validators==0.34.0 validators==0.34.0
vobject==0.9.9
whitenoise==6.8.2 whitenoise==6.8.2
x-wr-timezone==2.0.1

View File

@ -3,35 +3,55 @@ 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.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
DJANGO_SECRET_KEY = os.getenv("DJANGO_SECRET_KEY") \ from clear_bookings import clear
DEBUG = os.getenv("DJANGO_DEBUG", False)
SECRET_KEY = os.getenv("DJANGO_SECRET_KEY") \
if os.getenv("DJANGO_SECRET_KEY") \ if os.getenv("DJANGO_SECRET_KEY") \
else secrets.token_hex(40) if os.getenv("DEBUG") else None else secrets.token_hex(40) if DEBUG else None
DEBUG = os.getenv("DEBUG")
BASE_DIR = Path(__file__).resolve().parent BASE_DIR = Path(__file__).resolve().parent
DATA_DIR = Path(os.getenv("DJANGO_DATA_DIR", BASE_DIR.parent / "data"))
# Check if all required values are set # Check if all required values are set
if not DJANGO_SECRET_KEY and not DEBUG: 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=DJANGO_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("ALLOWED_HOSTS", "").split(",")], CSRF_TRUSTED_ORIGINS=CSRF_TRUSTED_ORIGINS,
SQLITE_DATABASE=DATA_DIR / "db.sqlite3",
DEBUG=DEBUG, DEBUG=DEBUG,
TEMPLATES_DIR=BASE_DIR / "templates", TEMPLATES_DIR=BASE_DIR / "templates",
STATICFILES_DIRS=[ STATICFILES_DIRS=[
@ -47,6 +67,45 @@ from ninja.errors import HttpError
@app.admin @app.admin
class EmailTemplate(models.Model):
"""
Email template model to store email templates for sending notifications.
"""
id = models.AutoField(primary_key=True)
name = models.CharField(max_length=100, unique=True)
subject = models.CharField(max_length=200)
body = models.TextField(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>
Available variables:<br>
- name: The name of the event<br>
- start: The start time (datetime) of the event<br>
- end: The end time (datetime) of the event<br>
- 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):
return self.name
@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.
@ -57,6 +116,14 @@ 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_bookings = models.BooleanField(default=False)
auto_clear_overlap_horizon_days = models.IntegerField(default=30)
email_template = models.ForeignKey(EmailTemplate, on_delete=models.SET_NULL,
null=True, blank=True,
related_name='calendars',
help_text="""\
Email template to use for sending notifications about deleted events.<br>
If not set, no email will be sent.""")
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
self.url = self.url.rstrip("/") self.url = self.url.rstrip("/")
@ -73,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"))
@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.
@ -85,6 +163,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)
@ -93,6 +172,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
@ -151,8 +232,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"Booking-ID: {self.id}"
c.events.add(e) c.events.add(e)
return c.serialize() return c.serialize()
@ -160,12 +243,108 @@ 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(readonly_fields = ('key',)) @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',))
class APIKey(models.Model): class APIKey(models.Model):
id = models.AutoField(primary_key=True) id = models.AutoField(primary_key=True)
user = models.ForeignKey(app.settings.AUTH_USER_MODEL, user = models.ForeignKey(app.settings.AUTH_USER_MODEL,
@ -235,17 +414,28 @@ def get_markdown():
return md.convert(f.read()) return md.convert(f.read())
def get_version():
"""
Get the version of the app from the version.txt file.
:return:
"""
with open(BASE_DIR / "version.txt") as f:
return f.read().strip()
@api.get("/info") @api.get("/info")
@csrf_exempt
def info(request): def info(request):
if request.user.is_anonymous: if request.user.is_anonymous:
user = APIKey.objects.get(key=request.auth.key).user user = APIKey.objects.get(key=request.auth.key).user
else: else:
user = request.user user = request.user
return {"user": str(user), return {"user": str(user),
"rooms": [room.name for room in user.rooms.all()]} "calendars": [room.name for room in user.calendars.all()]}
@api.post("/{calendar}/event", response={201: EventSchemaOut}) @api.post("/{calendar}/event", response={201: EventSchemaOut})
@csrf_exempt
def create_event(request, calendar: str, event: EventSchemaIn): def create_event(request, calendar: str, event: EventSchemaIn):
user = get_user(request) user = get_user(request)
cal = get_object_or_404(Calendar, name=calendar) cal = get_object_or_404(Calendar, name=calendar)
@ -257,6 +447,7 @@ def create_event(request, calendar: str, event: EventSchemaIn):
@api.delete("/{calendar}/event/{event_id}", response={204: None}) @api.delete("/{calendar}/event/{event_id}", response={204: None})
@csrf_exempt
def delete_event(request, calendar: str, event_id: str): def delete_event(request, calendar: str, event_id: str):
user = get_user(request) user = get_user(request)
cal = get_object_or_404(Calendar, name=calendar) cal = get_object_or_404(Calendar, name=calendar)
@ -268,11 +459,44 @@ def delete_event(request, calendar: str, event_id: str):
return 204, None return 204, None
@api.delete("/clear-bookings",
response={200: dict, 204: None, 401: None, 404: None})
@csrf_exempt
def clear_bookings(request, calendar: str = None, test: bool = False):
user = get_user(request)
# Get optional calendar name from the request
if calendar:
cal = get_object_or_404(Calendar, name=calendar,
auto_clear_bookings=True)
if user not in cal.users.all():
raise HttpError(401,
f"User not authorised to clear bookings in calendar '{cal.name}'")
calendars = [cal]
# If no calendar is specified, get all calendars for the user that have
# auto_clear_bookings enabled
else:
calendars = user.calendars.filter(auto_clear_bookings=True)
if not calendars:
return 204, None # No bookings to clear
result = clear(list(calendars), is_test=test)
if all(len(r) == 0 for r in result.values() if type(r) is list):
return 204, None # No bookings to clear
else:
return 200, result
app.route("api/", include=api.urls) app.route("api/", include=api.urls)
@app.route("/") @app.route("/")
@csrf_exempt
def home(request): def home(request):
return render(request, "index.html", { return render(request, "index.html", {
"content": get_markdown(), "content": get_markdown(),
"version": get_version(),
}) })

479
src/clear_bookings.py Normal file
View File

@ -0,0 +1,479 @@
import os
import re
import smtplib
import ssl
from datetime import datetime, date, timedelta
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
from email.utils import format_datetime
from pprint import pprint
from typing import NamedTuple
import caldav
import pytz
from caldav import CalendarObjectResource
from jinja2 import Template
tz = pytz.timezone(os.getenv("TIME_ZONE", "Europe/Berlin"))
DavEvent = NamedTuple("DavEvent", [
("uid", str),
("name", str),
("start", datetime),
("end", datetime),
("created", datetime),
("status", str),
("organizer", str),
("obj", caldav.Event),
("is_cancelled", bool),
("is_recurring", bool),
("is_prioritized", bool),
])
def clear(target_calendars: list, is_test: bool=False) -> dict:
"""
Clear overlapping and cancelled events in calendars.
:param target_calendars: List of calendars to check for overlaps.
:param is_test: Do not delete events, just print what would be deleted.
:return: Dictionary with results of the operation.
"""
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(
url=target_calendars[0].url,
username=target_calendars[0].username,
password=target_calendars[0].calendar_password,
)
principal = dav_client.principal()
# Filter calendars to only those that are in the target_calendars list
# Note: The id of the objects returned by principal.calendars() corresponds
# to the name of the Django Calendar model instances.
tcal_by_name = {c.name: c for c in target_calendars}
calendars = [cal for cal in principal.calendars()
if cal.id in tcal_by_name.keys()]
if not calendars:
print("--- No calendars to clear overlaps in. Exiting.")
return results
for calendar in calendars:
# Get events from calendar
print(f"--- Clearing cancelled bookings and overlaps in calendar: {calendar.id}")
horizon = tcal_by_name[calendar.id].auto_clear_overlap_horizon_days
events_fetched = calendar.search(
start=datetime.now(),
end=date.today() + timedelta(days=horizon),
event=True,
expand=True,
split_expanded=True,
)
events = []
for event in events_fetched:
for component in event.icalendar_instance.walk():
# 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(
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
overlapping_events = find_overlapping_events(events)
# Delete overlapping events and send emails to organizers
for overlap in overlapping_events.values():
event = overlap["event"]
overlaps_with = overlap["overlaps_with"]
is_deleted = False
is_cancelled = False
is_deprioritized = any(ov.is_prioritized for ov in overlaps_with)
result = {
"event": {
"uid": event.uid,
"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,
}
try:
# If this is not a recurring event, we can delete it directly
if not event.is_recurring:
# but only if we are not in test mode
if not is_test:
event.obj.delete()
is_deleted = True
result["cancellation_type"] = "deleted"
results["deleted_overlapping_events"].append(result)
print("Deleted overlapping event:")
# If this is a recurring event, and not already cancelled,
# we need to cancel it now
elif not event.is_cancelled:
if not is_test:
event.obj.decline_invite()
event.obj.icalendar_component["status"] = "CANCELLED"
event.obj.save()
is_cancelled = True
result["cancellation_type"] = "cancelled"
results["cancelled_overlapping_recurring_events"].append(result)
print("Cancelled overlapping recurring event:")
# Send email to organizer of the event if not in test mode
email_template = tcal_by_name[calendar.id].email_template
if email_template and not is_test:
try:
send_mail_to_organizer(event, overlaps_with, calendar,
is_deleted, is_cancelled,
is_deprioritized, email_template)
result["email_sent"] = True
except Exception as e:
print("Failed to send email to organizer for event "
f"{event.name}: {e}")
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:
print(f"Failed to delete event {event.uid}: {e}")
print("------")
continue
print(f"--- Clearing completed.{' Test mode enabled.' if is_test else ''}")
return results
def find_overlapping_events(events: list[DavEvent]) -> dict:
"""
Find overlapping events.
:param events: List of events to check for overlaps.
:return: Dictionary of overlapping events with their UIDs as keys and
a dictionary containing the event and the event it overlaps with
as values.
"""
overlapping_events = {}
# Order events by created time
events = sorted(events, key=lambda item: item.created)
# Define lambda functions to check for overlaps
event_starts_later = lambda e1, e2: e1.end >= e2.end > e1.start
event_starts_earlier = lambda e1, e2: e1.end > e2.start >= e1.start
event_inbetween = lambda e1, e2: e2.start < e1.start < e1.end < e2.end
# Find overlapping events
for event in events:
# Skip if the event is already in the overlapping events dictionary
if overlapping_events.get(create_event_identifier(event)):
continue
for compare_event in events:
# Skip if the compare_event is same event or belongs to the same
# series as the compare event
if event.uid == compare_event.uid:
continue
# Check if the events overlap
if event_starts_later(event, compare_event) or \
event_starts_earlier(event, compare_event) or \
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
event_identifier = create_event_identifier(compare_event)
if e := overlapping_events.get(event_identifier):
# If the event is already in the dictionary, extend the overlaps
e["overlaps_with"].append(event)
else:
# Create a new entry for the overlapping event
overlapping_events[event_identifier] = {
"event": compare_event,
"overlaps_with": [event]
}
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(
event: DavEvent,
overlap_events: list[DavEvent],
calendar: caldav.Calendar,
is_deleted: bool,
is_cancelled: bool,
is_deprioritized: bool,
email_template,
is_test: bool=False
):
"""
Send email to organizer of the event.
:param event: Event that was declined
:param overlap_events: List of events which overlap with the declined event
: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
: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
if not all([
os.getenv('SMTP_EMAIL'),
os.getenv('SMTP_PASSWORD'),
os.getenv('SMTP_SERVER'),
]):
raise Exception("SMTP environment variables are not set.")
recipient = event.organizer
# Prepare the email content
context = {
"name": event.name,
"start": event.start,
"end": event.end,
"created": event.created,
"is_deleted": is_deleted,
"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(),
}
recipients = [recipient]
# Create the email message
message = MIMEMultipart("alternative")
message["From"] = f"{os.getenv('SMTP_SENDER_NAME', 'Room Booking')} <{os.getenv('SMTP_EMAIL')}>"
message["To"] = recipient
if bcc := os.getenv("SMTP_BCC"):
message["Bcc"] = bcc
recipients += bcc.split(",")
message["Date"] = format_datetime(datetime.now().astimezone(tz))
# Try to render subject and body from the email template
message["Subject"] = Template(email_template.subject).render(**context)
body = Template(email_template.body).render(**context)
message.attach(MIMEText(body, "plain"))
# Create a secure SSL context
ssl_context = ssl.create_default_context()
# 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('"""')
print(body)
print('"""')
if os.getenv('SMTP_STARTTLS', False):
print("Using STARTTLS for SMTP connection.")
if not is_test:
with smtplib.SMTP(os.getenv('SMTP_SERVER'),
os.getenv('SMTP_PORT', 587)) as server:
server.starttls(context=ssl_context)
server.login(os.getenv(" ", 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.")
if not is_test:
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,50 @@
# Generated by Django 5.1.4 on 2025-06-25 14:08
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("booking", "0001_initial"),
]
operations = [
migrations.CreateModel(
name="EmailTemplate",
fields=[
("id", models.AutoField(primary_key=True, serialize=False)),
("name", models.CharField(max_length=100, unique=True)),
("subject", models.CharField(max_length=200)),
(
"body",
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 - 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"
),
),
],
),
migrations.AddField(
model_name="calendar",
name="auto_clear_overlap_horizon_days",
field=models.IntegerField(default=30),
),
migrations.AddField(
model_name="calendar",
name="auto_clear_overlaps",
field=models.BooleanField(default=False),
),
migrations.AddField(
model_name="calendar",
name="email_template",
field=models.ForeignKey(
blank=True,
help_text=" Email template to use for sending notifications about deleted events.<br>\n If not set, no email will be sent.",
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="calendars",
to="booking.emailtemplate",
),
),
]

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

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

@ -2,3 +2,8 @@
width: 90%; width: 90%;
margin: 3em auto; margin: 3em auto;
} }
.footer {
text-align: center;
margin: 2em auto;
}

View File

@ -10,5 +10,8 @@
<div class="container"> <div class="container">
{{ content | safe }} {{ content | safe }}
</div> </div>
<div class="container footer">
Version: {{ version }}
</div>
</body> </body>
</html> </html>

1
version.txt Normal file
View File

@ -0,0 +1 @@
1.2.0