✨ implement clearing of bookings
This commit is contained in:
parent
9a44afa6b9
commit
67ec6ccf5e
119
README.md
119
README.md
|
|
@ -1,8 +1,12 @@
|
|||
Room Booking
|
||||
---
|
||||
|
||||
This application allows you to create an event in a room booking calendar via a
|
||||
REST API.
|
||||
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.
|
||||
|
||||
## Setup
|
||||
|
||||
|
|
@ -58,3 +62,114 @@ curl -s -X DELETE \
|
|||
```
|
||||
|
||||
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:
|
||||
|
||||
- `event_name` - The name of the event
|
||||
- `event_start` - The start time (datetime) of the event
|
||||
- `event_end` - The end time (datetime) of the event
|
||||
- `event_created` - The datetime the event was created
|
||||
- `event_is_deleted` - Whether the event was deleted
|
||||
- `event_is_cancelled` - Whether the event was cancelled
|
||||
- `event_is_recurring` - Whether the event is recurring
|
||||
- `event_organizer` - The organizer of the event
|
||||
- `overlap_event_name` - The name of the overlap event
|
||||
- `overlap_event_start` - The start time (datetime) of the overlap event
|
||||
- `overlap_event_end` - The end time (datetime) of the overlap event
|
||||
- `overlap_event_created` - The datetime the overlap event was created
|
||||
- `overlap_event_organizer` - The organizer of the overlap event
|
||||
- `calendar_name` - The name of the calendar
|
||||
|
|
@ -30,12 +30,26 @@ if not 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,
|
||||
TIME_ZONE=os.getenv("TIME_ZONE", "Europe/Berlin"),
|
||||
ALLOWED_HOSTS=["localhost"] + [host for host in
|
||||
os.getenv("DJANGO_ALLOWED_HOSTS", "").split(",")],
|
||||
ALLOWED_HOSTS=["localhost"] + ALLOWED_HOSTS,
|
||||
CSRF_TRUSTED_ORIGINS=CSRF_TRUSTED_ORIGINS,
|
||||
SQLITE_DATABASE=DATA_DIR / "db.sqlite3",
|
||||
DEBUG=DEBUG,
|
||||
TEMPLATES_DIR=BASE_DIR / "templates",
|
||||
|
|
@ -50,7 +64,6 @@ from ninja import NinjaAPI, ModelSchema
|
|||
from ninja.security import HttpBearer, django_auth
|
||||
from ninja.errors import HttpError
|
||||
|
||||
|
||||
@app.admin
|
||||
class EmailTemplate(models.Model):
|
||||
"""
|
||||
|
|
@ -65,20 +78,23 @@ class EmailTemplate(models.Model):
|
|||
Jinja2 template syntax can be used in the subject and body.<br>
|
||||
Available variables:<br>
|
||||
- event_name: The name of the event<br>
|
||||
- event_start: The start time of the event<br>
|
||||
- event_end: The end time of the event<br>
|
||||
- event_created: The time the event was created<br>
|
||||
- event_start: The start time (datetime) of the event<br>
|
||||
- event_end: The end time (datetime) of the event<br>
|
||||
- event_created: The datetime the event was created<br>
|
||||
- event_is_deleted: Whether the event was deleted<br>
|
||||
- event_is_cancelled: Whether the event was cancelled<br>
|
||||
- event_is_recurring: Whether the event is recurring<br>
|
||||
- event_organizer: The organizer of the event<br>
|
||||
- overlap_event_name: The name of the overlap event<br>
|
||||
- overlap_event_start: The start time of the overlap event<br>
|
||||
- overlap_event_end: The end time of the overlap event<br>
|
||||
- overlap_event_created: The time the overlap event was created<br>
|
||||
- overlap_event_start: The start time (datetime) of the overlap event<br>
|
||||
- overlap_event_end: The end time (datetime) of the overlap event<br>
|
||||
- overlap_event_created: The datetime the overlap event was created<br>
|
||||
- overlap_event_organizer: The organizer of the overlap event<br>
|
||||
- calendar_name: The name of the calendar""")
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
|
||||
@app.admin
|
||||
class Calendar(models.Model):
|
||||
"""
|
||||
|
|
@ -90,7 +106,7 @@ class Calendar(models.Model):
|
|||
calendar_password = models.CharField(max_length=29)
|
||||
users = models.ManyToManyField(app.settings.AUTH_USER_MODEL,
|
||||
related_name='calendars')
|
||||
auto_clear_overlaps = models.BooleanField(default=False)
|
||||
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,
|
||||
|
|
@ -115,7 +131,7 @@ class Calendar(models.Model):
|
|||
return self.name
|
||||
|
||||
|
||||
@app.admin(ordering=("start", "end", "name"))
|
||||
@app.admin(ordering=("start", "end", "name"), list_filter=("cancelled",))
|
||||
class Event(models.Model):
|
||||
"""
|
||||
Event model to store events in a calendar and send them to a CalDAV server.
|
||||
|
|
@ -126,6 +142,7 @@ 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)
|
||||
|
|
@ -134,6 +151,8 @@ 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
|
||||
|
|
@ -192,9 +211,10 @@ class Event(models.Model):
|
|||
e = ICS_Event()
|
||||
e.uid = self.uuid.__str__()
|
||||
e.name = self.name
|
||||
e.created = self.created
|
||||
e.begin = self.start
|
||||
e.end = self.end
|
||||
e.description = f"Meeting-ID: {self.id}"
|
||||
e.description = f"Booking-ID: {self.id}"
|
||||
c.events.add(e)
|
||||
return c.serialize()
|
||||
|
||||
|
|
@ -321,38 +341,37 @@ def delete_event(request, calendar: str, event_id: str):
|
|||
event.cancel()
|
||||
return 204, None
|
||||
|
||||
@api.delete("/clear-overlaps", response={200: None, 204: None, 401: None, 404: None})
|
||||
@api.delete("/clear-bookings", response={200: dict, 204: None, 401: None, 404: None})
|
||||
@csrf_exempt
|
||||
def clear_overlaps(request, calendar: str = None):
|
||||
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_overlaps=True)
|
||||
auto_clear_bookings=True)
|
||||
if user not in cal.users.all():
|
||||
raise HttpError(401,
|
||||
f"User not authorised to clear overlaps in calendar '{cal.name}'")
|
||||
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_overlaps enabled
|
||||
# auto_clear_bookings enabled
|
||||
else:
|
||||
calendars = user.calendars.filter(auto_clear_overlaps=True)
|
||||
calendars = user.calendars.filter(auto_clear_bookings=True)
|
||||
|
||||
if not calendars:
|
||||
return 200, "No calendars with auto_clear_overlaps enabled found for user."
|
||||
return 204, None # No bookings to clear
|
||||
|
||||
if clear(list(calendars)):
|
||||
return 204, "Overlaps cleared successfully."
|
||||
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:
|
||||
raise HttpError(200, "No overlaps to clear.")
|
||||
|
||||
return 200, result
|
||||
|
||||
app.route("api/", include=api.urls)
|
||||
|
||||
|
||||
@app.route("/")
|
||||
@csrf_exempt
|
||||
def home(request):
|
||||
|
|
|
|||
|
|
@ -6,8 +6,10 @@ from email.mime.multipart import MIMEMultipart
|
|||
from email.mime.text import MIMEText
|
||||
from typing import NamedTuple
|
||||
import pytz
|
||||
from pprint import pprint
|
||||
|
||||
import caldav
|
||||
from caldav import CalendarObjectResource
|
||||
from jinja2 import Template
|
||||
|
||||
tz = pytz.timezone(os.getenv("TIME_ZONE", "Europe/Berlin"))
|
||||
|
|
@ -23,13 +25,22 @@ DavEvent = NamedTuple("DavEvent", [
|
|||
("obj", caldav.Event),
|
||||
])
|
||||
|
||||
def clear_overlaps(target_calendars: list) -> bool:
|
||||
def clear_overlaps(target_calendars: list, is_test: bool=False) -> dict:
|
||||
"""
|
||||
Clear overlapping events in calendars.
|
||||
:param target_calendars: List of calendars to check for overlaps.
|
||||
:return: True if overlaps were cleared, False otherwise.
|
||||
:param is_test: Do not delete events, just print what would be deleted.
|
||||
:return: Dictionary with results of the operation.
|
||||
"""
|
||||
cleared = False
|
||||
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,
|
||||
|
|
@ -47,32 +58,74 @@ def clear_overlaps(target_calendars: list) -> bool:
|
|||
if cal.id in tcal_by_name.keys()]
|
||||
|
||||
if not calendars:
|
||||
print("No calendars to clear overlaps in. Exiting.")
|
||||
return False
|
||||
print("--- No calendars to clear overlaps in. Exiting.")
|
||||
return results
|
||||
|
||||
for calendar in calendars:
|
||||
|
||||
# Get events from calendar
|
||||
print(f"Clearing overlaps in calendar: {calendar.id}")
|
||||
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=False,
|
||||
expand=True,
|
||||
#split_expanded=False,
|
||||
)
|
||||
|
||||
events = []
|
||||
for event in events_fetched:
|
||||
for component in event.icalendar_instance.walk():
|
||||
if component.name == "VEVENT" and component.get("status") != "CANCELLED":
|
||||
|
||||
# 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=component.get("dtstart").dt.astimezone(tz),
|
||||
end=component.get("dtend").dt.astimezone(tz),
|
||||
created=component.get("created").dt.astimezone(tz),
|
||||
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,
|
||||
|
|
@ -86,16 +139,56 @@ def clear_overlaps(target_calendars: list) -> bool:
|
|||
for overlap in overlapping_events.values():
|
||||
event = overlap["event"]
|
||||
overlaps_with = overlap["overlaps_with"]
|
||||
is_deleted = False
|
||||
is_canceled = False
|
||||
result = {
|
||||
"event": {
|
||||
"uid": event.uid,
|
||||
"name": event.name,
|
||||
"start": event.start,
|
||||
"end": event.end,
|
||||
"created": event.created,
|
||||
"organizer": event.organizer,
|
||||
},
|
||||
"overlaps_with": {
|
||||
"uid": overlaps_with.uid,
|
||||
"name": overlaps_with.name,
|
||||
"start": overlaps_with.start,
|
||||
"end": overlaps_with.end,
|
||||
"created": overlaps_with.created,
|
||||
"organizer": overlaps_with.organizer,
|
||||
},
|
||||
"calendar": calendar.id,
|
||||
}
|
||||
try:
|
||||
# Delete the event
|
||||
# If this is not a recurring event, we can delete it directly
|
||||
if not is_recurring_event(event.obj):
|
||||
# but only if we are not in test mode
|
||||
if not is_test:
|
||||
event.obj.delete()
|
||||
cleared = True
|
||||
is_deleted = True
|
||||
results["deleted_overlapping_events"].append(result)
|
||||
print("Deleted overlapping event:")
|
||||
pprint(result)
|
||||
print("------")
|
||||
|
||||
# Send email to organizer of the event
|
||||
# If this is a recurring event, and not already canceled,
|
||||
# we need to cancel it now
|
||||
elif not is_cancelled_event(event.obj):
|
||||
if not is_test:
|
||||
event.obj.decline_invite()
|
||||
is_canceled = True
|
||||
results["cancelled_overlapping_recurring_events"].append(result)
|
||||
print("Cancelled overlapping recurring event:")
|
||||
pprint(result)
|
||||
print("------")
|
||||
|
||||
# Send email to organizer of the event if not in test mode
|
||||
email_template = tcal_by_name[calendar.id].email_template
|
||||
if email_template:
|
||||
if email_template and not is_test:
|
||||
try:
|
||||
send_mail_to_organizer(event, overlaps_with, calendar,
|
||||
is_deleted, is_canceled,
|
||||
email_template)
|
||||
except Exception as e:
|
||||
print("Failed to send email to organizer for event "
|
||||
|
|
@ -103,10 +196,11 @@ def clear_overlaps(target_calendars: list) -> bool:
|
|||
continue
|
||||
except Exception as e:
|
||||
print(f"Failed to delete event {event.uid}: {e}")
|
||||
print("------")
|
||||
continue
|
||||
|
||||
return cleared
|
||||
|
||||
print(f"--- Clearing completed.{' Test mode enabled.' if is_test else ''}")
|
||||
return results
|
||||
|
||||
def find_overlapping_events(events: list[DavEvent]) -> dict:
|
||||
"""
|
||||
|
|
@ -119,51 +213,76 @@ def find_overlapping_events(events: list[DavEvent]) -> dict:
|
|||
overlapping_events = {}
|
||||
checked_events = {}
|
||||
|
||||
# Create a dictionary to make it easier to find an event by its UID
|
||||
events = {e.uid: e for e in events}
|
||||
|
||||
# Order events by created time
|
||||
events = dict(sorted(events.items(), key=lambda item: item[1].created))
|
||||
events = sorted(events, key=lambda item: item.created)
|
||||
|
||||
# Define lambda functions to check for overlaps
|
||||
event_starts_earlier = lambda e1, e2: e1.start <= e2.start <= e1.end
|
||||
event_inbetween = lambda e1, e2: e2.start <= e1.start <= e1.end <= e2.end
|
||||
event_ends_later = lambda e1, e2: e1.end >= e2.end >= e1.start
|
||||
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.values():
|
||||
for event in events:
|
||||
|
||||
# Skip if the event is already in the overlapping events dictionary
|
||||
if event.uid in overlapping_events.keys():
|
||||
if overlapping_events.get(create_event_identifier(event)):
|
||||
continue
|
||||
|
||||
for compare_event in events.values():
|
||||
# Skip if the events is:
|
||||
# 1. The same event as the compare_event
|
||||
for compare_event in events:
|
||||
# Skip if the compare_events is:
|
||||
# 1. The same event or belongs to the same series as the compare event
|
||||
# 2. Already checked
|
||||
# 3. Already in the overlapping events dictionary
|
||||
if event.uid == compare_event.uid \
|
||||
or compare_event.uid in checked_events.keys() \
|
||||
or compare_event.uid in overlapping_events.keys():
|
||||
or checked_events.get(create_event_identifier(compare_event)) \
|
||||
or overlapping_events.get(create_event_identifier(compare_event)):
|
||||
continue
|
||||
|
||||
# Check if the events overlap
|
||||
if event_starts_earlier(event, compare_event) or \
|
||||
event_inbetween(event, compare_event) or \
|
||||
event_ends_later(event, compare_event):
|
||||
if event_starts_later(event, compare_event) or \
|
||||
event_starts_earlier(event, compare_event) or \
|
||||
event_inbetween(event, compare_event):
|
||||
# Add to overlapping events dictionary
|
||||
overlapping_events.update({
|
||||
compare_event.uid: {"event": compare_event,
|
||||
create_event_identifier(compare_event): {"event": compare_event,
|
||||
"overlaps_with": event}})
|
||||
# Add to checked events dictionary
|
||||
checked_events.update({event.uid: event})
|
||||
checked_events.update({create_event_identifier(event): 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 send_mail_to_organizer(
|
||||
event: DavEvent,
|
||||
overlap_event: DavEvent,
|
||||
calendar: caldav.Calendar,
|
||||
is_deleted: bool,
|
||||
is_canceled: bool,
|
||||
email_template,
|
||||
):
|
||||
"""
|
||||
|
|
@ -171,6 +290,8 @@ def send_mail_to_organizer(
|
|||
:param event: Event that was declined
|
||||
:param overlap_event: Event that overlaps 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_canceled: True if the event was canceled, False otherwise
|
||||
:param email_template: Email template to use for the email
|
||||
"""
|
||||
# Check if environment variables for SMTP are set
|
||||
|
|
@ -189,6 +310,10 @@ def send_mail_to_organizer(
|
|||
"event_start": event.start,
|
||||
"event_end": event.end,
|
||||
"event_created": event.created,
|
||||
"event_is_deleted": is_deleted,
|
||||
"event_is_canceled": is_canceled,
|
||||
"event_is_recurring": is_recurring_event(event.obj),
|
||||
"event_organizer": event.organizer,
|
||||
"overlap_event_name": overlap_event.name,
|
||||
"overlap_event_start": overlap_event.start,
|
||||
"overlap_event_end": overlap_event.end,
|
||||
|
|
@ -197,10 +322,15 @@ def send_mail_to_organizer(
|
|||
"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"] = datetime.now().astimezone(tz).strftime( "%d/%m/%Y %H:%M")
|
||||
|
||||
# Try to render subject and body from the email template
|
||||
|
|
@ -211,9 +341,33 @@ def send_mail_to_organizer(
|
|||
# Create a secure SSL context
|
||||
ssl_context = ssl.create_default_context()
|
||||
|
||||
# Send the email
|
||||
# Send the email with starttls or SSL based on environment variable
|
||||
print(f"Sending email to {', '.join(recipients)} about event '{event.name}' "
|
||||
f"overlap with '{overlap_event.name}'.")
|
||||
if os.getenv('SMTP_STARTTLS', False):
|
||||
print("Using STARTTLS for SMTP connection.")
|
||||
with smtplib.SMTP(os.getenv('SMTP_SERVER'),
|
||||
os.getenv('SMTP_PORT', 587)) as server:
|
||||
server.starttls(context=ssl_context)
|
||||
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())
|
||||
else:
|
||||
print("Using SSL for SMTP connection.")
|
||||
with smtplib.SMTP_SSL("mail.extrasolar.space",
|
||||
os.getenv('SMTP_PORT', 465),
|
||||
context=ssl_context) as server:
|
||||
server.login(os.getenv("SMTP_EMAIL"), os.getenv("SMTP_PASSWORD"))
|
||||
server.sendmail(os.getenv("SMTP_EMAIL"), recipient, 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:
|
||||
"""
|
||||
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
|
||||
|
|
|
|||
|
|
@ -0,0 +1,18 @@
|
|||
# Generated by Django 5.1.4 on 2025-06-27 13:19
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("booking", "0002_emailtemplate_and_more"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="event",
|
||||
name="created",
|
||||
field=models.DateTimeField(auto_now_add=True, null=True),
|
||||
),
|
||||
]
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
# Generated by Django 5.1.4 on 2025-06-27 16:11
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("booking", "0003_event_created"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="event",
|
||||
name="created",
|
||||
field=models.DateTimeField(auto_now_add=True, default=None),
|
||||
preserve_default=False,
|
||||
),
|
||||
]
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
# Generated by Django 5.1.4 on 2025-06-27 16:14
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("booking", "0004_alter_event_created"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="event",
|
||||
name="created",
|
||||
field=models.DateTimeField(),
|
||||
),
|
||||
]
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
# Generated by Django 5.1.4 on 2025-06-30 16:08
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("booking", "0005_alter_event_created"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RenameField(
|
||||
model_name="calendar",
|
||||
old_name="auto_clear_overlaps",
|
||||
new_name="auto_clear_bookings",
|
||||
),
|
||||
]
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
# Generated by Django 5.1.4 on 2025-07-10 16:19
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("booking", "0006_rename_auto_clear_overlaps_calendar_auto_clear_bookings"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="emailtemplate",
|
||||
name="body",
|
||||
field=models.TextField(
|
||||
help_text=" Email template to use for sending notifications about deleted events.<br>\n If not set, no email will be sent.<br>\n Jinja2 template syntax can be used in the subject and body.<br>\n Available variables:<br>\n - event_name: The name of the event<br>\n - event_start: The start time of the event<br>\n - event_end: The end time of the event<br>\n - event_created: The time the event was created<br>\n - event_is_deleted: Whether the event was deleted<br>\n - event_is_cancelled: Whether the event was cancelled<br>\n - overlap_event_name: The name of the overlap event<br>\n - overlap_event_start: The start time of the overlap event<br>\n - overlap_event_end: The end time of the overlap event<br>\n - overlap_event_created: The time the overlap event was created<br>\n - overlap_event_organizer: The organizer of the overlap event<br>\n - calendar_name: The name of the calendar"
|
||||
),
|
||||
),
|
||||
]
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
# Generated by Django 5.1.4 on 2025-07-10 16:24
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("booking", "0007_alter_emailtemplate_body"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="emailtemplate",
|
||||
name="body",
|
||||
field=models.TextField(
|
||||
help_text=" Email template to use for sending notifications about deleted events.<br>\n If not set, no email will be sent.<br>\n Jinja2 template syntax can be used in the subject and body.<br>\n Available variables:<br>\n - event_name: The name of the event<br>\n - event_start: The start time of the event<br>\n - event_end: The end time of the event<br>\n - event_created: The time the event was created<br>\n - event_is_deleted: Whether the event was deleted<br>\n - event_is_cancelled: Whether the event was cancelled<br>\n - event_is_recurring: Whether the event is recurring<br>\n - event_organizer: The organizer of the event<br>\n - overlap_event_name: The name of the overlap event<br>\n - overlap_event_start: The start time of the overlap event<br>\n - overlap_event_end: The end time of the overlap event<br>\n - overlap_event_created: The time the overlap event was created<br>\n - overlap_event_organizer: The organizer of the overlap event<br>\n - calendar_name: The name of the calendar"
|
||||
),
|
||||
),
|
||||
]
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
# Generated by Django 5.1.4 on 2025-07-10 17:21
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("booking", "0008_alter_emailtemplate_body"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="emailtemplate",
|
||||
name="body",
|
||||
field=models.TextField(
|
||||
help_text=" Email template to use for sending notifications about deleted events.<br>\n If not set, no email will be sent.<br>\n Jinja2 template syntax can be used in the subject and body.<br>\n Available variables:<br>\n - event_name: The name of the event<br>\n - event_start: The start time (datetime) of the event<br>\n - event_end: The end time (datetime) of the event<br>\n - event_created: The datetime the event was created<br>\n - event_is_deleted: Whether the event was deleted<br>\n - event_is_cancelled: Whether the event was cancelled<br>\n - event_is_recurring: Whether the event is recurring<br>\n - event_organizer: The organizer of the event<br>\n - overlap_event_name: The name of the overlap event<br>\n - overlap_event_start: The start time (datetime) of the overlap event<br>\n - overlap_event_end: The end time (datetime) of the overlap event<br>\n - overlap_event_created: The datetime the overlap event was created<br>\n - overlap_event_organizer: The organizer of the overlap event<br>\n - calendar_name: The name of the calendar"
|
||||
),
|
||||
),
|
||||
]
|
||||
|
|
@ -1 +1 @@
|
|||
1.1.0-alpha1
|
||||
1.1.0
|
||||
Loading…
Reference in New Issue