Compare commits
No commits in common. "main" and "1.0.3" have entirely different histories.
126
README.md
126
README.md
|
|
@ -1,12 +1,8 @@
|
|||
Room Booking
|
||||
---
|
||||
|
||||
This application allows you to
|
||||
|
||||
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.
|
||||
This application allows you to create an event in a room booking calendar via a
|
||||
REST API.
|
||||
|
||||
## Setup
|
||||
|
||||
|
|
@ -61,120 +57,4 @@ curl -s -X DELETE \
|
|||
localhost:8000/api/test-kalender/event/4zx3QAFzoxV3vaZSKGaH2S
|
||||
```
|
||||
|
||||
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
|
||||
The response will be empty but the status code will be `204`.
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
---
|
||||
services:
|
||||
app:
|
||||
image: git.extrasolar.space/marc/room-booking.git:latest
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
|
|
@ -8,14 +9,7 @@ services:
|
|||
- "8000:8000"
|
||||
volumes:
|
||||
- app-data:/data
|
||||
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
|
||||
env_file: .env
|
||||
|
||||
volumes:
|
||||
app-data:
|
||||
|
|
@ -3,7 +3,6 @@ arrow==1.3.0
|
|||
asgiref==3.8.1
|
||||
attrs==24.3.0
|
||||
black==24.10.0
|
||||
caldav==1.6.0
|
||||
certifi==2024.12.14
|
||||
charset-normalizer==3.4.1
|
||||
click==8.1.8
|
||||
|
|
@ -11,14 +10,10 @@ Django==5.1.4
|
|||
django-ninja==1.3.0
|
||||
gunicorn==23.0.0
|
||||
h11==0.14.0
|
||||
icalendar==6.3.1
|
||||
ics==0.7.2
|
||||
idna==3.10
|
||||
isort==5.13.2
|
||||
Jinja2==3.1.6
|
||||
lxml==5.4.0
|
||||
Markdown==3.7
|
||||
MarkupSafe==3.0.2
|
||||
mypy-extensions==1.0.0
|
||||
nanodjango==0.9.2
|
||||
packaging==24.2
|
||||
|
|
@ -27,8 +22,6 @@ platformdirs==4.3.6
|
|||
pydantic==2.10.4
|
||||
pydantic_core==2.27.2
|
||||
python-dateutil==2.9.0.post0
|
||||
pytz==2025.2
|
||||
recurring-ical-events==3.8.0
|
||||
requests==2.32.3
|
||||
shortuuid==1.0.13
|
||||
six==1.17.0
|
||||
|
|
@ -36,10 +29,7 @@ sqlparse==0.5.3
|
|||
TatSu==5.12.2
|
||||
types-python-dateutil==2.9.0.20241206
|
||||
typing_extensions==4.12.2
|
||||
tzdata==2025.2
|
||||
urllib3==2.3.0
|
||||
uvicorn==0.34.0
|
||||
validators==0.34.0
|
||||
vobject==0.9.9
|
||||
whitenoise==6.8.2
|
||||
x-wr-timezone==2.0.1
|
||||
|
|
|
|||
248
src/booking.py
248
src/booking.py
|
|
@ -3,60 +3,45 @@ import secrets
|
|||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from uuid import UUID
|
||||
|
||||
import markdown
|
||||
|
||||
import requests
|
||||
import shortuuid
|
||||
from django.core.signing import Signer, BadSignature
|
||||
from django.conf.global_settings import CSRF_TRUSTED_ORIGINS
|
||||
from django.core.validators import URLValidator
|
||||
from django.db import models
|
||||
from django.shortcuts import render, get_object_or_404
|
||||
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 nanodjango import Django
|
||||
from shortuuid.django_fields import ShortUUIDField
|
||||
|
||||
from clear_bookings import clear
|
||||
|
||||
DEBUG = os.getenv("DJANGO_DEBUG", False)
|
||||
SECRET_KEY = os.getenv("DJANGO_SECRET_KEY") \
|
||||
DJANGO_SECRET_KEY = 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
|
||||
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
|
||||
if not SECRET_KEY and not DEBUG:
|
||||
if not DJANGO_SECRET_KEY and not DEBUG:
|
||||
print("DJANGO_SECRET_KEY is not set")
|
||||
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
|
||||
app = Django(
|
||||
SECRET_KEY=SECRET_KEY,
|
||||
SECRET_KEY=DJANGO_SECRET_KEY,
|
||||
TIME_ZONE=os.getenv("TIME_ZONE", "Europe/Berlin"),
|
||||
ALLOWED_HOSTS=["localhost"] + ALLOWED_HOSTS,
|
||||
CSRF_TRUSTED_ORIGINS=CSRF_TRUSTED_ORIGINS,
|
||||
ALLOWED_HOSTS=["localhost"] + [host for host in
|
||||
os.getenv("DJANGO_ALLOWED_HOSTS", "").split(",")],
|
||||
SQLITE_DATABASE=DATA_DIR / "db.sqlite3",
|
||||
DEBUG=DEBUG,
|
||||
TEMPLATES_DIR=BASE_DIR / "templates",
|
||||
STATICFILES_DIRS=[
|
||||
BASE_DIR / "static",
|
||||
],
|
||||
CSRF_TRUSTED_ORIGINS=[host for host in
|
||||
os.getenv("DJANGO_ALLOWED_HOSTS", "").split(",")]
|
||||
)
|
||||
|
||||
# Import ninja after nanodjango has been initialised to avoid this error:
|
||||
|
|
@ -67,45 +52,6 @@ from ninja.errors import HttpError
|
|||
|
||||
|
||||
@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):
|
||||
"""
|
||||
A calendar model to store events.
|
||||
|
|
@ -116,14 +62,6 @@ class Calendar(models.Model):
|
|||
calendar_password = models.CharField(max_length=29)
|
||||
users = models.ManyToManyField(app.settings.AUTH_USER_MODEL,
|
||||
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):
|
||||
self.url = self.url.rstrip("/")
|
||||
|
|
@ -140,19 +78,8 @@ class Calendar(models.Model):
|
|||
def __str__(self):
|
||||
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", "calendar", "name"),
|
||||
list_filter=("cancelled",),
|
||||
list_display=("name", "calendar", "created", "duration", "start",
|
||||
"end"))
|
||||
@app.admin(ordering=("start", "end", "name"))
|
||||
class Event(models.Model):
|
||||
"""
|
||||
Event model to store events in a calendar and send them to a CalDAV server.
|
||||
|
|
@ -163,7 +90,6 @@ class Event(models.Model):
|
|||
related_name='events')
|
||||
name = models.CharField(max_length=100)
|
||||
path = models.CharField(max_length=200, editable=False)
|
||||
created = models.DateTimeField()
|
||||
start = models.DateTimeField()
|
||||
end = models.DateTimeField()
|
||||
ics = models.TextField(blank=True)
|
||||
|
|
@ -172,8 +98,6 @@ class Event(models.Model):
|
|||
|
||||
def save(self, *args, **kwargs):
|
||||
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()
|
||||
|
||||
# Send the event to the CalDAV server if it has not been cancelled yet
|
||||
|
|
@ -228,14 +152,14 @@ class Event(models.Model):
|
|||
Create ics string
|
||||
:return:
|
||||
"""
|
||||
uuid = self.uuid.__str__()
|
||||
c = ICS_Calendar()
|
||||
e = ICS_Event()
|
||||
e.uid = self.uuid.__str__()
|
||||
e.uid = uuid
|
||||
e.name = self.name
|
||||
e.created = self.created
|
||||
e.begin = self.start
|
||||
e.end = self.end
|
||||
e.description = f"Booking-ID: {self.id}"
|
||||
e.description = f"Meeting-ID: {uuid}"
|
||||
c.events.add(e)
|
||||
return c.serialize()
|
||||
|
||||
|
|
@ -243,108 +167,12 @@ class Event(models.Model):
|
|||
def uuid(self) -> UUID:
|
||||
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):
|
||||
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',))
|
||||
class APIKey(models.Model):
|
||||
id = models.AutoField(primary_key=True)
|
||||
user = models.ForeignKey(app.settings.AUTH_USER_MODEL,
|
||||
|
|
@ -413,7 +241,6 @@ def get_markdown():
|
|||
md = markdown.Markdown(extensions=["fenced_code"])
|
||||
return md.convert(f.read())
|
||||
|
||||
|
||||
def get_version():
|
||||
"""
|
||||
Get the version of the app from the version.txt file.
|
||||
|
|
@ -424,18 +251,16 @@ def get_version():
|
|||
|
||||
|
||||
@api.get("/info")
|
||||
@csrf_exempt
|
||||
def info(request):
|
||||
if request.user.is_anonymous:
|
||||
user = APIKey.objects.get(key=request.auth.key).user
|
||||
else:
|
||||
user = request.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})
|
||||
@csrf_exempt
|
||||
def create_event(request, calendar: str, event: EventSchemaIn):
|
||||
user = get_user(request)
|
||||
cal = get_object_or_404(Calendar, name=calendar)
|
||||
|
|
@ -447,7 +272,6 @@ def create_event(request, calendar: str, event: EventSchemaIn):
|
|||
|
||||
|
||||
@api.delete("/{calendar}/event/{event_id}", response={204: None})
|
||||
@csrf_exempt
|
||||
def delete_event(request, calendar: str, event_id: str):
|
||||
user = get_user(request)
|
||||
cal = get_object_or_404(Calendar, name=calendar)
|
||||
|
|
@ -459,42 +283,10 @@ def delete_event(request, calendar: str, event_id: str):
|
|||
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("/")
|
||||
@csrf_exempt
|
||||
def home(request):
|
||||
return render(request, "index.html", {
|
||||
"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.3
|
||||
Loading…
Reference in New Issue