Compare commits
No commits in common. "main" and "1.0.2" have entirely different histories.
124
README.md
124
README.md
|
|
@ -1,12 +1,8 @@
|
||||||
Room Booking
|
Room Booking
|
||||||
---
|
---
|
||||||
|
|
||||||
This application allows you to
|
This application allows you to create an event in a room booking calendar via a
|
||||||
|
REST API.
|
||||||
1. create an event in a room booking calendar via a REST API.
|
|
||||||
2. delete an event in a room booking calendar via a REST API.
|
|
||||||
3. clear overlapping and canceled events in a room booking calendar via a REST
|
|
||||||
API.
|
|
||||||
|
|
||||||
## Setup
|
## Setup
|
||||||
|
|
||||||
|
|
@ -62,119 +58,3 @@ 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
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
---
|
---
|
||||||
services:
|
services:
|
||||||
app:
|
app:
|
||||||
|
image: git.extrasolar.space/marc/room-booking.git:latest
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
|
|
@ -8,14 +9,7 @@ services:
|
||||||
- "8000:8000"
|
- "8000:8000"
|
||||||
volumes:
|
volumes:
|
||||||
- app-data:/data
|
- app-data:/data
|
||||||
environment:
|
env_file: .env
|
||||||
- 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:
|
||||||
|
|
@ -3,7 +3,6 @@ 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
|
||||||
|
|
@ -11,14 +10,10 @@ 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
|
||||||
|
|
@ -27,8 +22,6 @@ 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
|
||||||
|
|
@ -36,10 +29,7 @@ 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
|
|
||||||
|
|
|
||||||
243
src/booking.py
243
src/booking.py
|
|
@ -3,54 +3,36 @@ 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
|
||||||
|
|
||||||
from clear_bookings import clear
|
DJANGO_SECRET_KEY = os.getenv("DJANGO_SECRET_KEY") \
|
||||||
|
|
||||||
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 DEBUG else None
|
else secrets.token_hex(40) if os.getenv("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"))
|
DATA_DIR = BASE_DIR.parent / "data"
|
||||||
|
|
||||||
# Check if all required values are set
|
# Check if all required values are set
|
||||||
if not SECRET_KEY and not DEBUG:
|
if not DJANGO_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=DJANGO_SECRET_KEY,
|
||||||
TIME_ZONE=os.getenv("TIME_ZONE", "Europe/Berlin"),
|
TIME_ZONE=os.getenv("TIME_ZONE", "Europe/Berlin"),
|
||||||
ALLOWED_HOSTS=["localhost"] + ALLOWED_HOSTS,
|
ALLOWED_HOSTS=["localhost"] + [host for host in
|
||||||
CSRF_TRUSTED_ORIGINS=CSRF_TRUSTED_ORIGINS,
|
os.getenv("ALLOWED_HOSTS", "").split(",")],
|
||||||
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",
|
||||||
|
|
@ -67,45 +49,6 @@ 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.
|
||||||
|
|
@ -116,14 +59,6 @@ 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("/")
|
||||||
|
|
@ -140,19 +75,8 @@ 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.
|
||||||
|
|
@ -163,7 +87,6 @@ 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)
|
||||||
|
|
@ -172,8 +95,6 @@ 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
|
||||||
|
|
@ -228,14 +149,14 @@ class Event(models.Model):
|
||||||
Create ics string
|
Create ics string
|
||||||
:return:
|
:return:
|
||||||
"""
|
"""
|
||||||
|
uuid = self.uuid.__str__()
|
||||||
c = ICS_Calendar()
|
c = ICS_Calendar()
|
||||||
e = ICS_Event()
|
e = ICS_Event()
|
||||||
e.uid = self.uuid.__str__()
|
e.uid = uuid
|
||||||
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}"
|
e.description = f"Meeting-ID: {uuid}"
|
||||||
c.events.add(e)
|
c.events.add(e)
|
||||||
return c.serialize()
|
return c.serialize()
|
||||||
|
|
||||||
|
|
@ -243,105 +164,9 @@ 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):
|
||||||
return self.name
|
string = f"{self.id} - {self.name} - ({self.start:%Y-%m-%d %H:%M} - {self.end:%Y-%m-%d %H:%M})"
|
||||||
|
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',))
|
||||||
|
|
@ -413,7 +238,6 @@ def get_markdown():
|
||||||
md = markdown.Markdown(extensions=["fenced_code"])
|
md = markdown.Markdown(extensions=["fenced_code"])
|
||||||
return md.convert(f.read())
|
return md.convert(f.read())
|
||||||
|
|
||||||
|
|
||||||
def get_version():
|
def get_version():
|
||||||
"""
|
"""
|
||||||
Get the version of the app from the version.txt file.
|
Get the version of the app from the version.txt file.
|
||||||
|
|
@ -424,18 +248,16 @@ def get_version():
|
||||||
|
|
||||||
|
|
||||||
@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),
|
||||||
"calendars": [room.name for room in user.calendars.all()]}
|
"rooms": [room.name for room in user.rooms.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)
|
||||||
|
|
@ -447,7 +269,6 @@ 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)
|
||||||
|
|
@ -459,42 +280,10 @@ 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(),
|
||||||
|
|
|
||||||
|
|
@ -1,479 +0,0 @@
|
||||||
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
|
|
||||||
|
|
@ -1,50 +0,0 @@
|
||||||
# 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",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
|
|
@ -1,18 +0,0 @@
|
||||||
# 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),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
|
|
@ -1,19 +0,0 @@
|
||||||
# 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,
|
|
||||||
),
|
|
||||||
]
|
|
||||||
|
|
@ -1,18 +0,0 @@
|
||||||
# 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(),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
|
|
@ -1,18 +0,0 @@
|
||||||
# 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",
|
|
||||||
),
|
|
||||||
]
|
|
||||||
|
|
@ -1,20 +0,0 @@
|
||||||
# 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"
|
|
||||||
),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
|
|
@ -1,20 +0,0 @@
|
||||||
# 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"
|
|
||||||
),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
|
|
@ -1,20 +0,0 @@
|
||||||
# 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"
|
|
||||||
),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
|
|
@ -1,32 +0,0 @@
|
||||||
# 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"
|
|
||||||
),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
|
|
@ -1,25 +0,0 @@
|
||||||
# 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),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
|
|
@ -1 +1 @@
|
||||||
1.2.0
|
1.0.2
|
||||||
Loading…
Reference in New Issue