Compare commits
8 Commits
| Author | SHA1 | Date |
|---|---|---|
|
|
0b3edff6f0 | |
|
|
76c2bf1631 | |
|
|
e3e9ac3c58 | |
|
|
14dd63ad72 | |
|
|
f053f4456f | |
|
|
3fa2763ebf | |
|
|
c3b1d42cef | |
|
|
4bd038bf10 |
31
README.md
31
README.md
|
|
@ -159,17 +159,22 @@ Email templates can be configured in the Django Admin interface. Jinja2 syntax
|
||||||
is used for the templates. The following variables are available in the
|
is used for the templates. The following variables are available in the
|
||||||
templates:
|
templates:
|
||||||
|
|
||||||
- `event_name` - The name of the event
|
- `name` - The name of the event
|
||||||
- `event_start` - The start time (datetime) of the event
|
- `start` - The start time (datetime) of the event
|
||||||
- `event_end` - The end time (datetime) of the event
|
- `end` - The end time (datetime) of the event
|
||||||
- `event_created` - The datetime the event was created
|
- `created` - The datetime the event was created
|
||||||
- `event_is_deleted` - Whether the event was deleted
|
- `is_deleted` - Whether the event was deleted
|
||||||
- `event_is_cancelled` - Whether the event was cancelled
|
- `is_cancelled` - Whether the event was cancelled
|
||||||
- `event_is_recurring` - Whether the event is recurring
|
- `is_recurring` - Whether the event is recurring
|
||||||
- `event_organizer` - The organizer of the event
|
- `is_priorized` - Whether the event is prioritized
|
||||||
- `overlap_event_name` - The name of the overlap event
|
- `is_deprioritized` - Whether the event is deprioritized in favor of another
|
||||||
- `overlap_event_start` - The start time (datetime) of the overlap event
|
event
|
||||||
- `overlap_event_end` - The end time (datetime) of the overlap event
|
- `organizer` - The organizer of the event
|
||||||
- `overlap_event_created` - The datetime the overlap event was created
|
- `overlap_events` - A list of overlap events, each containing:
|
||||||
- `overlap_event_organizer` - The organizer of the overlap event
|
- `name` - The name of the overlap event
|
||||||
|
- `start` - The start time (datetime) of the overlap event
|
||||||
|
- `end` - The end time (datetime) of the overlap event
|
||||||
|
- `created` - The datetime the overlap event was created
|
||||||
|
- `organizer` - The organizer of the overlap event
|
||||||
|
- `is_priorized` - Whether the overlap event is prioritized
|
||||||
- `calendar_name` - The name of the calendar
|
- `calendar_name` - The name of the calendar
|
||||||
169
src/booking.py
169
src/booking.py
|
|
@ -3,20 +3,21 @@ import secrets
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
import markdown
|
|
||||||
|
|
||||||
|
import markdown
|
||||||
import requests
|
import requests
|
||||||
import shortuuid
|
import shortuuid
|
||||||
from django.views.decorators.csrf import csrf_exempt
|
from django.core.signing import Signer, BadSignature
|
||||||
from django.core.validators import URLValidator
|
from django.core.validators import URLValidator
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django.shortcuts import render, get_object_or_404
|
from django.shortcuts import render, get_object_or_404
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
from django.views.decorators.csrf import csrf_exempt
|
||||||
from ics import Calendar as ICS_Calendar, Event as ICS_Event
|
from ics import Calendar as ICS_Calendar, Event as ICS_Event
|
||||||
from nanodjango import Django
|
from nanodjango import Django
|
||||||
from shortuuid.django_fields import ShortUUIDField
|
from shortuuid.django_fields import ShortUUIDField
|
||||||
|
|
||||||
from clear_overlaps import clear_overlaps as clear
|
from clear_bookings import clear
|
||||||
|
|
||||||
DEBUG = os.getenv("DJANGO_DEBUG", False)
|
DEBUG = os.getenv("DJANGO_DEBUG", False)
|
||||||
SECRET_KEY = os.getenv("DJANGO_SECRET_KEY") \
|
SECRET_KEY = os.getenv("DJANGO_SECRET_KEY") \
|
||||||
|
|
@ -64,6 +65,7 @@ from ninja import NinjaAPI, ModelSchema
|
||||||
from ninja.security import HttpBearer, django_auth
|
from ninja.security import HttpBearer, django_auth
|
||||||
from ninja.errors import HttpError
|
from ninja.errors import HttpError
|
||||||
|
|
||||||
|
|
||||||
@app.admin
|
@app.admin
|
||||||
class EmailTemplate(models.Model):
|
class EmailTemplate(models.Model):
|
||||||
"""
|
"""
|
||||||
|
|
@ -77,25 +79,33 @@ class EmailTemplate(models.Model):
|
||||||
If not set, no email will be sent.<br>
|
If not set, no email will be sent.<br>
|
||||||
Jinja2 template syntax can be used in the subject and body.<br>
|
Jinja2 template syntax can be used in the subject and body.<br>
|
||||||
Available variables:<br>
|
Available variables:<br>
|
||||||
- event_name: The name of the event<br>
|
- name: The name of the event<br>
|
||||||
- event_start: The start time (datetime) of the event<br>
|
- start: The start time (datetime) of the event<br>
|
||||||
- event_end: The end time (datetime) of the event<br>
|
- end: The end time (datetime) of the event<br>
|
||||||
- event_created: The datetime the event was created<br>
|
- created: The datetime the event was created<br>
|
||||||
- event_is_deleted: Whether the event was deleted<br>
|
- is_deleted: Whether the event was deleted<br>
|
||||||
- event_is_cancelled: Whether the event was cancelled<br>
|
- is_cancelled: Whether the event was cancelled<br>
|
||||||
- event_is_recurring: Whether the event is recurring<br>
|
- is_recurring: Whether the event is recurring<br>
|
||||||
- event_organizer: The organizer of the event<br>
|
- organizer: The organizer of the event<br>
|
||||||
- overlap_event_name: The name of the overlap event<br>
|
- is_prioritized: Whether the event is prioritized<br>
|
||||||
- overlap_event_start: The start time (datetime) of the overlap event<br>
|
- is_deprioritized: Whether the event was deprioritized in favor of another event<br>
|
||||||
- overlap_event_end: The end time (datetime) of the overlap event<br>
|
- overlap_events: List of overlap events, each with:<br>
|
||||||
- overlap_event_created: The datetime the overlap event was created<br>
|
- name: The name of the overlap event<br>
|
||||||
- overlap_event_organizer: The organizer of the overlap event<br>
|
- start: The start time (datetime) of the overlap event<br>
|
||||||
|
- end: The end time (datetime) of the overlap event<br>
|
||||||
|
- created: The datetime the overlap event was created<br>
|
||||||
|
- organizer: The organizer of the overlap event<br>
|
||||||
|
- is_prioritized: Whether the overlap event is prioritized<br>
|
||||||
- calendar_name: The name of the calendar""")
|
- calendar_name: The name of the calendar""")
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return self.name
|
return self.name
|
||||||
|
|
||||||
@app.admin
|
|
||||||
|
@app.admin(
|
||||||
|
list_display=("name", "all_users", "auto_clear_bookings",
|
||||||
|
"auto_clear_overlap_horizon_days", "email_template"),
|
||||||
|
list_filter=("auto_clear_bookings",))
|
||||||
class Calendar(models.Model):
|
class Calendar(models.Model):
|
||||||
"""
|
"""
|
||||||
A calendar model to store events.
|
A calendar model to store events.
|
||||||
|
|
@ -130,8 +140,19 @@ class Calendar(models.Model):
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return self.name
|
return self.name
|
||||||
|
|
||||||
|
def all_users(self) -> str:
|
||||||
|
"""
|
||||||
|
Get the users of the calendar.
|
||||||
|
It's kind of inefficient, but in small databases it should be fine.
|
||||||
|
:return: A list of users.
|
||||||
|
"""
|
||||||
|
return ", ".join([u.username for u in self.users.all()])
|
||||||
|
|
||||||
@app.admin(ordering=("start", "end", "name"), list_filter=("cancelled",))
|
|
||||||
|
@app.admin(ordering=("start", "end", "calendar", "name"),
|
||||||
|
list_filter=("cancelled",),
|
||||||
|
list_display=("name", "calendar", "created", "duration", "start",
|
||||||
|
"end"))
|
||||||
class Event(models.Model):
|
class Event(models.Model):
|
||||||
"""
|
"""
|
||||||
Event model to store events in a calendar and send them to a CalDAV server.
|
Event model to store events in a calendar and send them to a CalDAV server.
|
||||||
|
|
@ -222,9 +243,105 @@ class Event(models.Model):
|
||||||
def uuid(self) -> UUID:
|
def uuid(self) -> UUID:
|
||||||
return shortuuid.decode(self.id.__str__())
|
return shortuuid.decode(self.id.__str__())
|
||||||
|
|
||||||
|
def duration(self) -> str:
|
||||||
|
"""
|
||||||
|
Calculate the duration of the event.
|
||||||
|
:return: The duration in minutes.
|
||||||
|
"""
|
||||||
|
duration = (self.end - self.start).total_seconds() // 60
|
||||||
|
return f"{duration:.0f}m"
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
string = f"{self.id} - {self.name} - ({self.start:%Y-%m-%d %H:%M} - {self.end:%Y-%m-%d %H:%M})"
|
return self.name
|
||||||
return string if not self.cancelled else f"{string} - CANCELLED"
|
|
||||||
|
|
||||||
|
@app.admin(
|
||||||
|
list_display=("token", "is_used", "created_at", "updated_at", "notes"),
|
||||||
|
list_filter=("is_used",),
|
||||||
|
read_only_fields=("created_at", "token", "signature"))
|
||||||
|
class PriorityEventToken(models.Model):
|
||||||
|
"""
|
||||||
|
Priority event token model to store tokens for priority events.
|
||||||
|
"""
|
||||||
|
id = models.AutoField(primary_key=True)
|
||||||
|
token = models.CharField(max_length=14, unique=True, editable=False)
|
||||||
|
signature = models.CharField(max_length=80, blank=True, editable=False)
|
||||||
|
notes = models.TextField(blank=True)
|
||||||
|
is_used = models.BooleanField(default=False)
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def generate_token(cls):
|
||||||
|
return f"#{secrets.token_hex(6).upper()}#"
|
||||||
|
|
||||||
|
def save(self, *args, **kwargs):
|
||||||
|
if not self.token:
|
||||||
|
self.token = self.generate_token()
|
||||||
|
return super().save(*args, **kwargs)
|
||||||
|
|
||||||
|
def redeem(self, uuid: str) -> str | bool:
|
||||||
|
"""
|
||||||
|
Use the token to prioritize the event.
|
||||||
|
:return: Returns the signature or False if the token or the uuid is not
|
||||||
|
valid.
|
||||||
|
"""
|
||||||
|
# Check for a valid uuid
|
||||||
|
try:
|
||||||
|
uuid = UUID(uuid, version=4)
|
||||||
|
except ValueError:
|
||||||
|
print(f"Failed to decode UUID {uuid}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
if not self.is_used:
|
||||||
|
self.signature = self._sign(uuid)
|
||||||
|
self.is_used = True
|
||||||
|
self.save()
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
def validate_signature(self, uuid: str) -> bool:
|
||||||
|
"""
|
||||||
|
Validate the signature of the uuid.
|
||||||
|
:param uuid: The uuid4 of the priority event.
|
||||||
|
:return:
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
uuid = UUID(uuid, version=4)
|
||||||
|
except ValueError:
|
||||||
|
print(f"Failed to decode UUID {uuid}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
if not self.signature:
|
||||||
|
print(f"No signature found for token '{self.token}'")
|
||||||
|
return False
|
||||||
|
|
||||||
|
return self._validate(uuid, self.signature)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _sign(uuid: UUID) -> str:
|
||||||
|
"""
|
||||||
|
Generate a signed uuid.
|
||||||
|
:return: The signature.
|
||||||
|
"""
|
||||||
|
signer = Signer()
|
||||||
|
return signer.sign(uuid.__str__())
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _validate(uuid: UUID, signature: str) -> bool:
|
||||||
|
"""
|
||||||
|
Validate the signature of the uuid.
|
||||||
|
:return: True if the signature matches the uuid, False otherwise.
|
||||||
|
"""
|
||||||
|
signer = Signer()
|
||||||
|
try:
|
||||||
|
return signer.unsign(signature) == uuid.__str__()
|
||||||
|
except BadSignature as e:
|
||||||
|
print(e)
|
||||||
|
return False
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.token
|
||||||
|
|
||||||
|
|
||||||
@app.admin(readonly_fields=('key',))
|
@app.admin(readonly_fields=('key',))
|
||||||
|
|
@ -341,7 +458,9 @@ def delete_event(request, calendar: str, event_id: str):
|
||||||
event.cancel()
|
event.cancel()
|
||||||
return 204, None
|
return 204, None
|
||||||
|
|
||||||
@api.delete("/clear-bookings", response={200: dict, 204: None, 401: None, 404: None})
|
|
||||||
|
@api.delete("/clear-bookings",
|
||||||
|
response={200: dict, 204: None, 401: None, 404: None})
|
||||||
@csrf_exempt
|
@csrf_exempt
|
||||||
def clear_bookings(request, calendar: str = None, test: bool = False):
|
def clear_bookings(request, calendar: str = None, test: bool = False):
|
||||||
user = get_user(request)
|
user = get_user(request)
|
||||||
|
|
@ -349,7 +468,7 @@ def clear_bookings(request, calendar: str = None, test: bool = False):
|
||||||
# Get optional calendar name from the request
|
# Get optional calendar name from the request
|
||||||
if calendar:
|
if calendar:
|
||||||
cal = get_object_or_404(Calendar, name=calendar,
|
cal = get_object_or_404(Calendar, name=calendar,
|
||||||
auto_clear_bookings=True)
|
auto_clear_bookings=True)
|
||||||
if user not in cal.users.all():
|
if user not in cal.users.all():
|
||||||
raise HttpError(401,
|
raise HttpError(401,
|
||||||
f"User not authorised to clear bookings in calendar '{cal.name}'")
|
f"User not authorised to clear bookings in calendar '{cal.name}'")
|
||||||
|
|
@ -362,16 +481,18 @@ def clear_bookings(request, calendar: str = None, test: bool = False):
|
||||||
calendars = user.calendars.filter(auto_clear_bookings=True)
|
calendars = user.calendars.filter(auto_clear_bookings=True)
|
||||||
|
|
||||||
if not calendars:
|
if not calendars:
|
||||||
return 204, None # No bookings to clear
|
return 204, None # No bookings to clear
|
||||||
|
|
||||||
result = clear(list(calendars), is_test=test)
|
result = clear(list(calendars), is_test=test)
|
||||||
if all(len(r) == 0 for r in result.values() if type(r) is list):
|
if all(len(r) == 0 for r in result.values() if type(r) is list):
|
||||||
return 204, None # No bookings to clear
|
return 204, None # No bookings to clear
|
||||||
else:
|
else:
|
||||||
return 200, result
|
return 200, result
|
||||||
|
|
||||||
|
|
||||||
app.route("api/", include=api.urls)
|
app.route("api/", include=api.urls)
|
||||||
|
|
||||||
|
|
||||||
@app.route("/")
|
@app.route("/")
|
||||||
@csrf_exempt
|
@csrf_exempt
|
||||||
def home(request):
|
def home(request):
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,16 @@
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
import smtplib
|
import smtplib
|
||||||
import ssl
|
import ssl
|
||||||
from datetime import datetime, date, timedelta
|
from datetime import datetime, date, timedelta
|
||||||
from email.mime.multipart import MIMEMultipart
|
from email.mime.multipart import MIMEMultipart
|
||||||
from email.mime.text import MIMEText
|
from email.mime.text import MIMEText
|
||||||
from typing import NamedTuple
|
from email.utils import format_datetime
|
||||||
import pytz
|
|
||||||
from pprint import pprint
|
from pprint import pprint
|
||||||
|
from typing import NamedTuple
|
||||||
|
|
||||||
import caldav
|
import caldav
|
||||||
|
import pytz
|
||||||
from caldav import CalendarObjectResource
|
from caldav import CalendarObjectResource
|
||||||
from jinja2 import Template
|
from jinja2 import Template
|
||||||
|
|
||||||
|
|
@ -23,11 +25,14 @@ DavEvent = NamedTuple("DavEvent", [
|
||||||
("status", str),
|
("status", str),
|
||||||
("organizer", str),
|
("organizer", str),
|
||||||
("obj", caldav.Event),
|
("obj", caldav.Event),
|
||||||
|
("is_cancelled", bool),
|
||||||
|
("is_recurring", bool),
|
||||||
|
("is_prioritized", bool),
|
||||||
])
|
])
|
||||||
|
|
||||||
def clear_overlaps(target_calendars: list, is_test: bool=False) -> dict:
|
def clear(target_calendars: list, is_test: bool=False) -> dict:
|
||||||
"""
|
"""
|
||||||
Clear overlapping events in calendars.
|
Clear overlapping and cancelled events in calendars.
|
||||||
:param target_calendars: List of calendars to check for overlaps.
|
:param target_calendars: List of calendars to check for overlaps.
|
||||||
:param is_test: Do not delete events, just print what would be deleted.
|
:param is_test: Do not delete events, just print what would be deleted.
|
||||||
:return: Dictionary with results of the operation.
|
:return: Dictionary with results of the operation.
|
||||||
|
|
@ -71,7 +76,7 @@ def clear_overlaps(target_calendars: list, is_test: bool=False) -> dict:
|
||||||
end=date.today() + timedelta(days=horizon),
|
end=date.today() + timedelta(days=horizon),
|
||||||
event=True,
|
event=True,
|
||||||
expand=True,
|
expand=True,
|
||||||
#split_expanded=False,
|
split_expanded=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
events = []
|
events = []
|
||||||
|
|
@ -129,6 +134,9 @@ def clear_overlaps(target_calendars: list, is_test: bool=False) -> dict:
|
||||||
status=component.get("status", "CONFIRMED"),
|
status=component.get("status", "CONFIRMED"),
|
||||||
organizer=component.get("organizer", "").replace("mailto:", "").strip(),
|
organizer=component.get("organizer", "").replace("mailto:", "").strip(),
|
||||||
obj=event,
|
obj=event,
|
||||||
|
is_cancelled=is_cancelled_event(event),
|
||||||
|
is_recurring=is_recurring_event(event),
|
||||||
|
is_prioritized=is_prioritized_event(event),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -140,7 +148,8 @@ def clear_overlaps(target_calendars: list, is_test: bool=False) -> dict:
|
||||||
event = overlap["event"]
|
event = overlap["event"]
|
||||||
overlaps_with = overlap["overlaps_with"]
|
overlaps_with = overlap["overlaps_with"]
|
||||||
is_deleted = False
|
is_deleted = False
|
||||||
is_canceled = False
|
is_cancelled = False
|
||||||
|
is_deprioritized = any(ov.is_prioritized for ov in overlaps_with)
|
||||||
result = {
|
result = {
|
||||||
"event": {
|
"event": {
|
||||||
"uid": event.uid,
|
"uid": event.uid,
|
||||||
|
|
@ -149,51 +158,72 @@ def clear_overlaps(target_calendars: list, is_test: bool=False) -> dict:
|
||||||
"end": event.end,
|
"end": event.end,
|
||||||
"created": event.created,
|
"created": event.created,
|
||||||
"organizer": event.organizer,
|
"organizer": event.organizer,
|
||||||
|
"is_recurring": event.is_recurring,
|
||||||
|
"is_prioritized": event.is_prioritized,
|
||||||
|
"is_deprioritized": is_deprioritized,
|
||||||
},
|
},
|
||||||
"overlaps_with": {
|
"overlaps_with": [{
|
||||||
"uid": overlaps_with.uid,
|
"uid": ov.uid,
|
||||||
"name": overlaps_with.name,
|
"name": ov.name,
|
||||||
"start": overlaps_with.start,
|
"start": ov.start,
|
||||||
"end": overlaps_with.end,
|
"end": ov.end,
|
||||||
"created": overlaps_with.created,
|
"created": ov.created,
|
||||||
"organizer": overlaps_with.organizer,
|
"organizer": ov.organizer,
|
||||||
},
|
"is_recurring": ov.is_recurring,
|
||||||
|
"is_prioritized": ov.is_prioritized,
|
||||||
|
} for ov in overlaps_with],
|
||||||
"calendar": calendar.id,
|
"calendar": calendar.id,
|
||||||
}
|
}
|
||||||
try:
|
try:
|
||||||
# If this is not a recurring event, we can delete it directly
|
# If this is not a recurring event, we can delete it directly
|
||||||
if not is_recurring_event(event.obj):
|
if not event.is_recurring:
|
||||||
# but only if we are not in test mode
|
# but only if we are not in test mode
|
||||||
if not is_test:
|
if not is_test:
|
||||||
event.obj.delete()
|
event.obj.delete()
|
||||||
is_deleted = True
|
is_deleted = True
|
||||||
|
result["cancellation_type"] = "deleted"
|
||||||
results["deleted_overlapping_events"].append(result)
|
results["deleted_overlapping_events"].append(result)
|
||||||
print("Deleted overlapping event:")
|
print("Deleted overlapping event:")
|
||||||
pprint(result)
|
|
||||||
print("------")
|
|
||||||
|
|
||||||
# If this is a recurring event, and not already canceled,
|
# If this is a recurring event, and not already cancelled,
|
||||||
# we need to cancel it now
|
# we need to cancel it now
|
||||||
elif not is_cancelled_event(event.obj):
|
elif not event.is_cancelled:
|
||||||
if not is_test:
|
if not is_test:
|
||||||
event.obj.decline_invite()
|
event.obj.decline_invite()
|
||||||
is_canceled = True
|
event.obj.icalendar_component["status"] = "CANCELLED"
|
||||||
|
event.obj.save()
|
||||||
|
is_cancelled = True
|
||||||
|
result["cancellation_type"] = "cancelled"
|
||||||
results["cancelled_overlapping_recurring_events"].append(result)
|
results["cancelled_overlapping_recurring_events"].append(result)
|
||||||
print("Cancelled overlapping recurring event:")
|
print("Cancelled overlapping recurring event:")
|
||||||
pprint(result)
|
|
||||||
print("------")
|
|
||||||
|
|
||||||
# Send email to organizer of the event if not in test mode
|
# Send email to organizer of the event if not in test mode
|
||||||
email_template = tcal_by_name[calendar.id].email_template
|
email_template = tcal_by_name[calendar.id].email_template
|
||||||
if email_template and not is_test:
|
if email_template and not is_test:
|
||||||
try:
|
try:
|
||||||
send_mail_to_organizer(event, overlaps_with, calendar,
|
send_mail_to_organizer(event, overlaps_with, calendar,
|
||||||
is_deleted, is_canceled,
|
is_deleted, is_cancelled,
|
||||||
email_template)
|
is_deprioritized, email_template)
|
||||||
|
result["email_sent"] = True
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print("Failed to send email to organizer for event "
|
print("Failed to send email to organizer for event "
|
||||||
f"{event.name}: {e}")
|
f"{event.name}: {e}")
|
||||||
continue
|
result["email_sent"] = False
|
||||||
|
elif email_template and is_test:
|
||||||
|
print("Email template found, but not sending email in test mode.")
|
||||||
|
send_mail_to_organizer(event, overlaps_with, calendar,
|
||||||
|
is_deleted, is_cancelled,
|
||||||
|
is_deprioritized, email_template,
|
||||||
|
is_test=True)
|
||||||
|
result["email_sent"] = True
|
||||||
|
else:
|
||||||
|
result["email_sent"] = False
|
||||||
|
|
||||||
|
# Print results
|
||||||
|
pprint(result)
|
||||||
|
print("------")
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Failed to delete event {event.uid}: {e}")
|
print(f"Failed to delete event {event.uid}: {e}")
|
||||||
print("------")
|
print("------")
|
||||||
|
|
@ -211,7 +241,6 @@ def find_overlapping_events(events: list[DavEvent]) -> dict:
|
||||||
as values.
|
as values.
|
||||||
"""
|
"""
|
||||||
overlapping_events = {}
|
overlapping_events = {}
|
||||||
checked_events = {}
|
|
||||||
|
|
||||||
# Order events by created time
|
# Order events by created time
|
||||||
events = sorted(events, key=lambda item: item.created)
|
events = sorted(events, key=lambda item: item.created)
|
||||||
|
|
@ -229,25 +258,40 @@ def find_overlapping_events(events: list[DavEvent]) -> dict:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
for compare_event in events:
|
for compare_event in events:
|
||||||
# Skip if the compare_events is:
|
# Skip if the compare_event is same event or belongs to the same
|
||||||
# 1. The same event or belongs to the same series as the compare event
|
# series as the compare event
|
||||||
# 2. Already checked
|
if event.uid == compare_event.uid:
|
||||||
# 3. Already in the overlapping events dictionary
|
|
||||||
if event.uid == compare_event.uid \
|
|
||||||
or checked_events.get(create_event_identifier(compare_event)) \
|
|
||||||
or overlapping_events.get(create_event_identifier(compare_event)):
|
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Check if the events overlap
|
# Check if the events overlap
|
||||||
if event_starts_later(event, compare_event) or \
|
if event_starts_later(event, compare_event) or \
|
||||||
event_starts_earlier(event, compare_event) or \
|
event_starts_earlier(event, compare_event) or \
|
||||||
event_inbetween(event, compare_event):
|
event_inbetween(event, compare_event):
|
||||||
|
|
||||||
|
# If both events are prioritized, skip the overlap
|
||||||
|
if compare_event.is_prioritized and event.is_prioritized:
|
||||||
|
print(f"Skipping overlap between prioritized events: "
|
||||||
|
f"'{event.name}' and '{compare_event.name}'")
|
||||||
|
continue
|
||||||
|
|
||||||
|
# If the compare_event is prioritized and the event is not,
|
||||||
|
# skip the overlap
|
||||||
|
elif compare_event.is_prioritized and not event.is_prioritized:
|
||||||
|
print(f"Skipping overlap between event '{event.name}' "
|
||||||
|
f"and prioritized event '{compare_event.name}'")
|
||||||
|
continue
|
||||||
|
|
||||||
# Add to overlapping events dictionary
|
# Add to overlapping events dictionary
|
||||||
overlapping_events.update({
|
event_identifier = create_event_identifier(compare_event)
|
||||||
create_event_identifier(compare_event): {"event": compare_event,
|
if e := overlapping_events.get(event_identifier):
|
||||||
"overlaps_with": event}})
|
# If the event is already in the dictionary, extend the overlaps
|
||||||
# Add to checked events dictionary
|
e["overlaps_with"].append(event)
|
||||||
checked_events.update({create_event_identifier(event): event})
|
else:
|
||||||
|
# Create a new entry for the overlapping event
|
||||||
|
overlapping_events[event_identifier] = {
|
||||||
|
"event": compare_event,
|
||||||
|
"overlaps_with": [event]
|
||||||
|
}
|
||||||
|
|
||||||
return overlapping_events
|
return overlapping_events
|
||||||
|
|
||||||
|
|
@ -277,22 +321,74 @@ def is_cancelled_event(event: CalendarObjectResource) -> bool:
|
||||||
"""
|
"""
|
||||||
return event.icalendar_component.get("status") == "CANCELLED"
|
return event.icalendar_component.get("status") == "CANCELLED"
|
||||||
|
|
||||||
|
def is_prioritized_event(event: CalendarObjectResource) -> bool:
|
||||||
|
"""
|
||||||
|
Check if the event is a prioritized event by finding and validating the
|
||||||
|
priority token.
|
||||||
|
:param event:
|
||||||
|
:return:
|
||||||
|
"""
|
||||||
|
from booking import PriorityEventToken
|
||||||
|
description = event.icalendar_component.get("DESCRIPTION", "")
|
||||||
|
uid = event.icalendar_component.get("uid")
|
||||||
|
|
||||||
|
# Check if there is a token in the event description
|
||||||
|
token_pattern = r"\#[A-Z1-9]{12}\#"
|
||||||
|
match = re.search(token_pattern, description)
|
||||||
|
if match:
|
||||||
|
token = match.group()
|
||||||
|
print(f"Priority token found in event description: {token}")
|
||||||
|
token_obj = PriorityEventToken.objects.get(token=token)
|
||||||
|
|
||||||
|
# Check if the token object exists in the database
|
||||||
|
if not token_obj:
|
||||||
|
print(f"Priority token '{token}' not found in database.")
|
||||||
|
return False
|
||||||
|
|
||||||
|
# If token is already used, verify signature
|
||||||
|
if token_obj.is_used:
|
||||||
|
# Check if the signature is valid
|
||||||
|
|
||||||
|
if token_obj.validate_signature(uid):
|
||||||
|
print(f"Priority token '{token}' is valid.")
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
print(f"Priority token '{token}' is invalid.")
|
||||||
|
# TODO: Notify about invalid token usage
|
||||||
|
return False
|
||||||
|
|
||||||
|
# If the token hasn't been used yet, redeem it
|
||||||
|
else:
|
||||||
|
print(f"Redeeming priority token '{token}' for event '{uid}'.")
|
||||||
|
token_obj.redeem(uid)
|
||||||
|
# TODO: Notify about token redemption
|
||||||
|
return True
|
||||||
|
|
||||||
|
# If no token is found, return False
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
def send_mail_to_organizer(
|
def send_mail_to_organizer(
|
||||||
event: DavEvent,
|
event: DavEvent,
|
||||||
overlap_event: DavEvent,
|
overlap_events: list[DavEvent],
|
||||||
calendar: caldav.Calendar,
|
calendar: caldav.Calendar,
|
||||||
is_deleted: bool,
|
is_deleted: bool,
|
||||||
is_canceled: bool,
|
is_cancelled: bool,
|
||||||
|
is_deprioritized: bool,
|
||||||
email_template,
|
email_template,
|
||||||
|
is_test: bool=False
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
Send email to organizer of the event.
|
Send email to organizer of the event.
|
||||||
:param event: Event that was declined
|
:param event: Event that was declined
|
||||||
:param overlap_event: Event that overlaps with the declined event
|
:param overlap_events: List of events which overlap with the declined event
|
||||||
:param calendar: Calendar to send the email from
|
:param calendar: Calendar to send the email from
|
||||||
:param is_deleted: True if the event was deleted, False otherwise
|
:param is_deleted: True if the event was deleted, False otherwise
|
||||||
:param is_canceled: True if the event was canceled, False otherwise
|
:param is_cancelled: True if the event was cancelled, False otherwise
|
||||||
|
:param is_deprioritized: True if the event was deprioritized in favor of
|
||||||
|
another event, False otherwise
|
||||||
:param email_template: Email template to use for the email
|
:param email_template: Email template to use for the email
|
||||||
|
:param is_test: If True, do not send the email, just print it
|
||||||
"""
|
"""
|
||||||
# Check if environment variables for SMTP are set
|
# Check if environment variables for SMTP are set
|
||||||
if not all([
|
if not all([
|
||||||
|
|
@ -306,19 +402,24 @@ def send_mail_to_organizer(
|
||||||
|
|
||||||
# Prepare the email content
|
# Prepare the email content
|
||||||
context = {
|
context = {
|
||||||
"event_name": event.name,
|
"name": event.name,
|
||||||
"event_start": event.start,
|
"start": event.start,
|
||||||
"event_end": event.end,
|
"end": event.end,
|
||||||
"event_created": event.created,
|
"created": event.created,
|
||||||
"event_is_deleted": is_deleted,
|
"is_deleted": is_deleted,
|
||||||
"event_is_canceled": is_canceled,
|
"is_cancelled": is_cancelled,
|
||||||
"event_is_recurring": is_recurring_event(event.obj),
|
"is_recurring": is_recurring_event(event.obj),
|
||||||
"event_organizer": event.organizer,
|
"organizer": event.organizer,
|
||||||
"overlap_event_name": overlap_event.name,
|
"is_prioritized": event.is_prioritized,
|
||||||
"overlap_event_start": overlap_event.start,
|
"is_deprioritized": is_deprioritized,
|
||||||
"overlap_event_end": overlap_event.end,
|
"overlap_events": [{
|
||||||
"overlap_event_created": overlap_event.created,
|
"name": overlap_event.name,
|
||||||
"overlap_event_organizer": overlap_event.organizer,
|
"start": overlap_event.start,
|
||||||
|
"end": overlap_event.end,
|
||||||
|
"created": overlap_event.created,
|
||||||
|
"organizer": overlap_event.organizer,
|
||||||
|
"is_prioritized": overlap_event.is_prioritized,
|
||||||
|
} for overlap_event in overlap_events],
|
||||||
"calendar_name": calendar.get_display_name(),
|
"calendar_name": calendar.get_display_name(),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -331,7 +432,7 @@ def send_mail_to_organizer(
|
||||||
if bcc := os.getenv("SMTP_BCC"):
|
if bcc := os.getenv("SMTP_BCC"):
|
||||||
message["Bcc"] = bcc
|
message["Bcc"] = bcc
|
||||||
recipients += bcc.split(",")
|
recipients += bcc.split(",")
|
||||||
message["Date"] = datetime.now().astimezone(tz).strftime( "%d/%m/%Y %H:%M")
|
message["Date"] = format_datetime(datetime.now().astimezone(tz))
|
||||||
|
|
||||||
# Try to render subject and body from the email template
|
# Try to render subject and body from the email template
|
||||||
message["Subject"] = Template(email_template.subject).render(**context)
|
message["Subject"] = Template(email_template.subject).render(**context)
|
||||||
|
|
@ -342,22 +443,27 @@ def send_mail_to_organizer(
|
||||||
ssl_context = ssl.create_default_context()
|
ssl_context = ssl.create_default_context()
|
||||||
|
|
||||||
# Send the email with starttls or SSL based on environment variable
|
# Send the email with starttls or SSL based on environment variable
|
||||||
print(f"Sending email to {', '.join(recipients)} about event '{event.name}' "
|
overlaps = ", ".join([f"'{overlap.name}'" for overlap in overlap_events])
|
||||||
f"overlap with '{overlap_event.name}'.")
|
print(f"Sending email to {', '.join(recipients)} with subject: {message['Subject']}:")
|
||||||
|
print('"""')
|
||||||
|
print(body)
|
||||||
|
print('"""')
|
||||||
if os.getenv('SMTP_STARTTLS', False):
|
if os.getenv('SMTP_STARTTLS', False):
|
||||||
print("Using STARTTLS for SMTP connection.")
|
print("Using STARTTLS for SMTP connection.")
|
||||||
with smtplib.SMTP(os.getenv('SMTP_SERVER'),
|
if not is_test:
|
||||||
os.getenv('SMTP_PORT', 587)) as server:
|
with smtplib.SMTP(os.getenv('SMTP_SERVER'),
|
||||||
server.starttls(context=ssl_context)
|
os.getenv('SMTP_PORT', 587)) as server:
|
||||||
server.login(os.getenv("SMTP_USER_NAME", os.getenv("SMTP_EMAIL")), os.getenv("SMTP_PASSWORD"))
|
server.starttls(context=ssl_context)
|
||||||
server.sendmail(os.getenv("SMTP_EMAIL"), recipients, message.as_string())
|
server.login(os.getenv(" ", os.getenv("SMTP_EMAIL")), os.getenv("SMTP_PASSWORD"))
|
||||||
|
server.sendmail(os.getenv("SMTP_EMAIL"), recipients, message.as_string())
|
||||||
else:
|
else:
|
||||||
print("Using SSL for SMTP connection.")
|
print("Using SSL for SMTP connection.")
|
||||||
with smtplib.SMTP_SSL("mail.extrasolar.space",
|
if not is_test:
|
||||||
os.getenv('SMTP_PORT', 465),
|
with smtplib.SMTP_SSL("mail.extrasolar.space",
|
||||||
context=ssl_context) as server:
|
os.getenv('SMTP_PORT', 465),
|
||||||
server.login(os.getenv("SMTP_USER_NAME", os.getenv("SMTP_EMAIL")), os.getenv("SMTP_PASSWORD"))
|
context=ssl_context) as server:
|
||||||
server.sendmail(os.getenv("SMTP_EMAIL"), recipients, message.as_string())
|
server.login(os.getenv("SMTP_USER_NAME", os.getenv("SMTP_EMAIL")), os.getenv("SMTP_PASSWORD"))
|
||||||
|
server.sendmail(os.getenv("SMTP_EMAIL"), recipients, message.as_string())
|
||||||
|
|
||||||
def handle_date(date_value: datetime | date) -> datetime:
|
def handle_date(date_value: datetime | date) -> datetime:
|
||||||
"""
|
"""
|
||||||
|
|
@ -0,0 +1,32 @@
|
||||||
|
# Generated by Django 5.1.4 on 2025-07-11 16:46
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("booking", "0009_alter_emailtemplate_body"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="PriorityEventToken",
|
||||||
|
fields=[
|
||||||
|
("id", models.AutoField(primary_key=True, serialize=False)),
|
||||||
|
("token", models.CharField(editable=False, max_length=14, unique=True)),
|
||||||
|
("signature", models.CharField(blank=True, max_length=80)),
|
||||||
|
("notes", models.TextField(blank=True)),
|
||||||
|
("is_used", models.BooleanField(default=False)),
|
||||||
|
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||||
|
("updated_at", models.DateTimeField(auto_now=True)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="emailtemplate",
|
||||||
|
name="body",
|
||||||
|
field=models.TextField(
|
||||||
|
help_text=" Email template to use for sending notifications about deleted events.<br>\n If not set, no email will be sent.<br>\n Jinja2 template syntax can be used in the subject and body.<br>\n Available variables:<br>\n - name: The name of the event<br>\n - start: The start time (datetime) of the event<br>\n - end: The end time (datetime) of the event<br>\n - created: The datetime the event was created<br>\n - is_deleted: Whether the event was deleted<br>\n - is_cancelled: Whether the event was cancelled<br>\n - is_recurring: Whether the event is recurring<br>\n - organizer: The organizer of the event<br>\n - is_prioritized: Whether the event is prioritized<br>\n - overlap_events: List of overlap events, each with:<br>\n - name: The name of the overlap event<br>\n - start: The start time (datetime) of the overlap event<br>\n - end: The end time (datetime) of the overlap event<br>\n - created: The datetime the overlap event was created<br>\n - organizer: The organizer of the overlap event<br>\n - is_prioritized: Whether the overlap event is prioritized<br>\n - calendar_name: The name of the calendar"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
@ -0,0 +1,25 @@
|
||||||
|
# Generated by Django 5.1.4 on 2025-07-14 09:09
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("booking", "0010_priorityeventtoken_alter_emailtemplate_body"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="emailtemplate",
|
||||||
|
name="body",
|
||||||
|
field=models.TextField(
|
||||||
|
help_text=" Email template to use for sending notifications about deleted events.<br>\n If not set, no email will be sent.<br>\n Jinja2 template syntax can be used in the subject and body.<br>\n Available variables:<br>\n - name: The name of the event<br>\n - start: The start time (datetime) of the event<br>\n - end: The end time (datetime) of the event<br>\n - created: The datetime the event was created<br>\n - is_deleted: Whether the event was deleted<br>\n - is_cancelled: Whether the event was cancelled<br>\n - is_recurring: Whether the event is recurring<br>\n - organizer: The organizer of the event<br>\n - is_prioritized: Whether the event is prioritized<br>\n - is_deprioritized: Whether the event was deprioritized in favor of another event<br>\n - overlap_events: List of overlap events, each with:<br>\n - name: The name of the overlap event<br>\n - start: The start time (datetime) of the overlap event<br>\n - end: The end time (datetime) of the overlap event<br>\n - created: The datetime the overlap event was created<br>\n - organizer: The organizer of the overlap event<br>\n - is_prioritized: Whether the overlap event is prioritized<br>\n - calendar_name: The name of the calendar"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="priorityeventtoken",
|
||||||
|
name="signature",
|
||||||
|
field=models.CharField(blank=True, editable=False, max_length=80),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
@ -1 +1 @@
|
||||||
1.1.0
|
1.2.0
|
||||||
Loading…
Reference in New Issue