diff --git a/.idea/RoomBooking.iml b/.idea/RoomBooking.iml
index c920625..44d2b86 100644
--- a/.idea/RoomBooking.iml
+++ b/.idea/RoomBooking.iml
@@ -4,7 +4,7 @@
-
+
\ No newline at end of file
diff --git a/.idea/dataSources.xml b/.idea/dataSources.xml
index a564911..28a02cc 100644
--- a/.idea/dataSources.xml
+++ b/.idea/dataSources.xml
@@ -1,11 +1,11 @@
-
+ sqlite.xerialtrueorg.sqlite.JDBC
- jdbc:sqlite:$PROJECT_DIR$/src/db.sqlite3
+ jdbc:sqlite:$PROJECT_DIR$/src/data/db.sqlite3$ProjectFileDir$
@@ -16,7 +16,7 @@
-
+ sqlite.xerialtrueorg.sqlite.JDBC
@@ -29,6 +29,33 @@
file://$APPLICATION_CONFIG_DIR$/jdbc-drivers/Xerial SQLiteJDBC/3.45.1/org/slf4j/slf4j-api/1.7.36/slf4j-api-1.7.36.jar
+
+ file://$APPLICATION_CONFIG_DIR$/jdbc-drivers/Xerial SQLiteJDBC/3.45.1/org/xerial/sqlite-jdbc/3.45.1.0/sqlite-jdbc-3.45.1.0.jar
+
+
+ file://$APPLICATION_CONFIG_DIR$/jdbc-drivers/Xerial SQLiteJDBC/3.45.1/org/slf4j/slf4j-api/1.7.36/slf4j-api-1.7.36.jar
+
+
+
+
+ sqlite.xerial
+ true
+ org.sqlite.JDBC
+ jdbc:sqlite:$PROJECT_DIR$/src/db.sqlite3
+ $ProjectFileDir$
+
+
+ file://$APPLICATION_CONFIG_DIR$/jdbc-drivers/Xerial SQLiteJDBC/3.45.1/org/xerial/sqlite-jdbc/3.45.1.0/sqlite-jdbc-3.45.1.0.jar
+
+
+ file://$APPLICATION_CONFIG_DIR$/jdbc-drivers/Xerial SQLiteJDBC/3.45.1/org/slf4j/slf4j-api/1.7.36/slf4j-api-1.7.36.jar
+
+
+ file://$APPLICATION_CONFIG_DIR$/jdbc-drivers/Xerial SQLiteJDBC/3.45.1/org/xerial/sqlite-jdbc/3.45.1.0/sqlite-jdbc-3.45.1.0.jar
+
+
+ file://$APPLICATION_CONFIG_DIR$/jdbc-drivers/Xerial SQLiteJDBC/3.45.1/org/slf4j/slf4j-api/1.7.36/slf4j-api-1.7.36.jar
+
diff --git a/.idea/misc.xml b/.idea/misc.xml
index 0dd2de7..2369665 100644
--- a/.idea/misc.xml
+++ b/.idea/misc.xml
@@ -6,5 +6,5 @@
-
+
\ No newline at end of file
diff --git a/Dockerfile b/Dockerfile
index ed045d7..4990129 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,27 +1,41 @@
-FROM python:3.12-slim-bookworm
-LABEL authors="Marc Koch"
+FROM ghcr.io/astral-sh/uv:python3.12-bookworm-slim AS builder
-ENV PYTHONDONTWRITEBYTECODE 1
-ENV PYTHONUNBUFFERED 1
+ENV PYTHONDONTWRITEBYTECODE=1
+ENV PYTHONUNBUFFERED=1
WORKDIR /app
+RUN --mount=type=cache,target=/root/.cache/uv \
+ --mount=type=bind,source=uv.lock,target=uv.lock \
+ --mount=type=bind,source=pyproject.toml,target=pyproject.toml \
+ uv sync --frozen --no-install-project --no-dev
+
+COPY src/ /app
+COPY README.md /app/index.md
+COPY version.txt /app/
+
+FROM python:3.12-slim-bookworm
+LABEL authors="Marc Koch"
+
+ENV PYTHONDONTWRITEBYTECODE=1
+ENV PYTHONUNBUFFERED=1
+
RUN mkdir /data
+RUN mkdir /static-collected
-COPY src/ ./
-COPY requirements.txt .
-COPY README.md index.md
-COPY version.txt .
-
-RUN pip install --no-cache-dir -r requirements.txt
+COPY --from=builder --chown=app-user:app-user /app /app
RUN groupadd -r app-user && useradd -r -g app-user app-user && \
chown -R app-user:app-user /app && \
- chown -R app-user:app-user /data
+ chown -R app-user:app-user /data && \
+ chown -R app-user:app-user /static-collected && \
+ chmod +x /app/clear_bookings.py
+
+
+WORKDIR /app
+
+ENV PATH="/app/.venv/bin:$PATH"
USER app-user
EXPOSE 8000
-
-#CMD ["nanodjango", "serve", "booking.py"]
-CMD ["gunicorn", "--timeout", "180", "--bind", "0.0.0.0:8000", "booking:app"]
diff --git a/README.md b/README.md
index c363b43..8ad7fd9 100644
--- a/README.md
+++ b/README.md
@@ -5,8 +5,7 @@ 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.
+3. clear colliding and canceled events in a room booking calendar via script.
## Setup
@@ -68,76 +67,26 @@ curl -s -X DELETE \
The response will be empty but the status code will be `204`.
-### Clear overlapping and canceled events
+### Clear colliding 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:
+To clear colliding and canceled events, you can run the `clear_bookings.py`
+script.
```bash
-curl -s -X DELETE \
- -H "Authorization: Bearer secrettoken" \
- localhost:8000/api/clear-bookings?calendar=meeting-room-1&test=on" | jq "."
+python clear_bookings.py [--calendar CALENDAR] [--dry-run DRY_RUN]
```
-The response will contain a list of events that would be deleted:
+The following parameters can be passed as arguments:
-```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
-}
+- `--calendar`: The calendars to clear collisions events from. If not
+ specified, all calendars (marked for auto clearing) will be cleared.
+- `--dry-run`: If set, the script will only simulate the clearing and not
+ actually delete any events.
+
+Execute script as short-lived docker container:
+
+```bash
+docker docker compose -f path/to/docker-compose.yaml run --rm room-booking python clear_bookings.py
```
#### Email Notifications
@@ -161,6 +110,8 @@ The following environment variables can be set to configure the application:
SSL and
`587` for STARTTLS.
+##### Email Template Variables
+
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:
@@ -168,16 +119,19 @@ templates:
- `booking` - The booking that was declined
- `calendar_name` - The name of the calendar
-**Lists**
-- `overlap_bookings` - A list of overlap bookings, each containing:
+###### Lists
+
+- `colliding_bookings` - A list of colliding bookings
- `other_bookings` - A list of other bookings at the same day(s) as the declined
- `overview` - A list of all bookings in the calendar for the day(s) of the
declined booking
- `alternatives` - List of alternative calendars (rooms) for the time slot of
the
-**Attributes**
+###### Attributes
+
Each event has the following attributes:
+
- `uid` - The unique ID of the event
- `name` - The name of the event
- `start` - The start time (datetime) of the event
@@ -191,4 +145,7 @@ Each event has the following attributes:
- `is_prioritized` - Whether the event is prioritized
- `is_deleted` - Whether the event was deleted
- `is_deprioritized` - Whether the event is deprioritized in favor of another
- event
\ No newline at end of file
+- `is_collision` - Whether the event is a collision with the booking
+- `is_other_booking` - Whether the event is another booking at the same day(s)
+ as the booking but not colliding.
+- `is_declined` - Whether the event was declined due to a collision
\ No newline at end of file
diff --git a/docker-compose.yaml b/docker-compose.yaml
old mode 100644
new mode 100755
index 4665646..bc3f5d4
--- a/docker-compose.yaml
+++ b/docker-compose.yaml
@@ -8,15 +8,9 @@ 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
- - MAX_SEARCH_HORIZON=14 # adjust maximal range of days (longer search periods will get split into multiple requests)
+ env_file:
+ - .env
+ command: ".venv/bin/python -m nanodjango serve booking.py 0.0.0.0:8000"
volumes:
app-data:
\ No newline at end of file
diff --git a/src/booking.py b/src/booking.py
index f411fa0..22ff441 100644
--- a/src/booking.py
+++ b/src/booking.py
@@ -17,8 +17,6 @@ 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") \
if os.getenv("DJANGO_SECRET_KEY") \
@@ -26,6 +24,11 @@ SECRET_KEY = os.getenv("DJANGO_SECRET_KEY") \
BASE_DIR = Path(__file__).resolve().parent
DATA_DIR = Path(os.getenv("DJANGO_DATA_DIR", BASE_DIR.parent / "data"))
+if DEBUG:
+ print(f"DEBUG mode is {'on' if DEBUG else 'off'}")
+ print(f"BASE_DIR: {BASE_DIR}")
+ print(f"DATA_DIR: {DATA_DIR}")
+
# Check if all required values are set
if not SECRET_KEY and not DEBUG:
print("DJANGO_SECRET_KEY is not set")
@@ -87,7 +90,7 @@ class EmailTemplate(models.Model):
@app.admin(
list_display=("name", "all_users", "auto_clear_bookings",
- "auto_clear_overlap_horizon_days", "email_template",
+ "auto_clear_collision_horizon_days", "email_template",
"all_alternatives"),
list_filter=("auto_clear_bookings",))
class Calendar(models.Model):
@@ -101,7 +104,7 @@ class Calendar(models.Model):
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)
+ auto_clear_collision_horizon_days = models.IntegerField(default=30)
email_template = models.ForeignKey(EmailTemplate, on_delete=models.SET_NULL,
null=True, blank=True,
related_name='calendars',
@@ -458,37 +461,6 @@ 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)
diff --git a/src/clear_bookings.py b/src/clear_bookings.py
index a2bb325..176a600 100644
--- a/src/clear_bookings.py
+++ b/src/clear_bookings.py
@@ -1,5 +1,8 @@
+#! /app/.venv/bin/python
+
import email.utils
import json
+import logging
import os
import re
import smtplib
@@ -8,17 +11,27 @@ 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
import caldav
+import click
import pytz
from caldav import CalendarObjectResource, Principal, Calendar, DAVObject
-from jinja2 import Template
from django.core.exceptions import ObjectDoesNotExist
+from jinja2 import Template
+
+from booking import PriorityEventToken, Calendar as RoomBookingCalendar
+from mylogger.logger import setup_logging
tz = pytz.timezone(os.getenv("TIME_ZONE", "Europe/Berlin"))
MAX_SEARCH_HORIZON = int(os.getenv("MAX_SEARCH_HORIZON", 14))
+FILE_LOG_LEVEL = os.getenv("FILE_LOG_LEVEL", "INFO")
+STDOUT_LOG_LEVEL = os.getenv("STDOUT_LOG_LEVEL", "DEBUG")
+LOG_DIR = os.getenv("LOG_DIR", "/data/room-booking.log.jsonl")
+LOGROTATE_BACKUP_COUNT = int(os.getenv("LOGROTATE_BACKUP_COUNT", 6))
+LOGROTATE_MAX_BYTES = int(os.getenv("LOGROTATE_MAX_BYTES", 10 * 1024 * 1024))
+
+
class DavEvent:
"""
Wrapper for calendar events fetched from a CalDAV server.
@@ -51,18 +64,26 @@ class DavEvent:
"dtstamp": component.get("dtstamp", False),
}
if not all(required.values()):
- self.missing_required_fields = [k for k, v in required.items() if v is False]
+ self.missing_required_fields = [k for k, v in
+ required.items() if
+ v is False]
else:
self.missing_required_fields = []
self.uid = event.icalendar_component.get("uid")
self.name = event.icalendar_component.get("summary", "No Name")
- self.start = self._handle_date(event.icalendar_component.get("dtstart").dt)
- self.end = self._handle_date(event.icalendar_component.get("dtend").dt)
+ self.start = self._handle_date(
+ event.icalendar_component.get("dtstart").dt)
+ self.end = self._handle_date(
+ event.icalendar_component.get("dtend").dt)
self.duration = self.end - self.start
- self.created = self._handle_date(event.icalendar_component.get("dtstamp").dt)
- self.status = event.icalendar_component.get("status", "CONFIRMED")
- self.organizer = event.icalendar_component.get("organizer", "").replace("mailto:", "").strip()
+ self.created = self._handle_date(
+ event.icalendar_component.get("dtstamp").dt)
+ self.status = event.icalendar_component.get("status",
+ "CONFIRMED")
+ self.organizer = event.icalendar_component.get("organizer",
+ "").replace(
+ "mailto:", "").strip()
self.calendar = event.parent
self.obj = event
self.is_deprioritized = False
@@ -111,7 +132,6 @@ class DavEvent:
priority token.
:return:
"""
- from booking import PriorityEventToken
description = self.obj.icalendar_component.get("DESCRIPTION", "")
uid = self.uid
@@ -165,34 +185,13 @@ class DavEvent:
self.obj.icalendar_component["status"] = "CANCELLED"
self.obj.save(no_create=True, increase_seqno=False)
- def serialize(self) -> dict[str, str | datetime | timedelta]:
+ def dumps(self, datetime_format="%Y-%m-%d %X") -> dict:
"""
- Serialize the event to a dictionary.
- :return:
- """
- return {
- "uid": self.uid,
- "name": self.name,
- "start": self.start,
- "end": self.end,
- "duration": self.duration,
- "created": self.created,
- "status": self.status,
- "organizer": self.organizer,
- "calendar_id": self.calendar.id,
- "calendar_name": self.calendar.name,
- "is_cancelled": self.is_cancelled,
- "is_recurring": self.is_recurring,
- "is_prioritized": self.is_prioritized,
- "missing_required_fields": self.missing_required_fields,
- }
-
- def dumps(self, indent=4, datetime_format="%Y-%m-%d %X") -> str:
- """
- Dump a json string with the event data.
+ Dump a dictionary with the event data.
+ :param datetime_format: Format for datetime fields
:return: string with json data
"""
- return json.dumps({
+ return {
"uid": self.uid,
"name": self.name,
"start": self.start.strftime(datetime_format),
@@ -207,7 +206,7 @@ class DavEvent:
"is_recurring": self.is_recurring,
"is_prioritized": self.is_prioritized,
"missing_required_fields": self.missing_required_fields,
- }, indent=indent)
+ }
@staticmethod
def _handle_date(date_value: datetime | date) -> datetime:
@@ -217,52 +216,72 @@ class DavEvent:
if isinstance(date_value, datetime):
date_value = date_value.astimezone(tz)
else:
- date_value = tz.localize(datetime.combine(date_value, datetime.min.time()))
+ date_value = tz.localize(
+ datetime.combine(date_value, datetime.min.time()))
return date_value
-def clear(target_calendars: list, is_test: bool=False) -> dict:
+@click.command()
+@click.option("--calendars", "-c", multiple=True, required=False,
+ help="Calendars to check for collisions (can be specified multiple times).")
+@click.option("--dry-run", is_flag=True,
+ help="Run in test mode (do not delete events, just print what would be deleted).",
+ default=False)
+def clear_bookings(calendars: list, dry_run: bool = False):
"""
- 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.
+ Clear colliding and cancelled events in calendars.
+ :param calendars: List of calendars to check for collisions.
+ :param dry_run: Do not delete events, just print what would be deleted.
"""
- 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,
- }
+ logger = logging.getLogger(__name__)
+ setup_logging(file_log_level=FILE_LOG_LEVEL,
+ stdout_log_level=STDOUT_LOG_LEVEL,
+ logdir=LOG_DIR)
+
+ logger.debug(f"Clear bookings.{' Test mode enabled.' if dry_run else ''}")
+ # Fetch calendar objects from database
+ if len(calendars) > 0:
+ calendars_found = list(
+ RoomBookingCalendar.objects.filter(name__in=calendars))
+ for cal in calendars:
+ if not cal in [c.name for c in calendars_found]:
+ logger.debug(f"Calendar '{cal}' not found in database. Skipping.")
+ else:
+ calendars_found = list(RoomBookingCalendar.objects.all())
+
+ if not calendars_found:
+ logger.debug("No valid calendars found. Exiting.")
+ exit(0)
+
+ calendars = calendars_found
dav_client = caldav.DAVClient(
- url=target_calendars[0].url,
- username=target_calendars[0].username,
- password=target_calendars[0].calendar_password,
+ url=calendars[0].url,
+ username=calendars[0].username,
+ password=calendars[0].calendar_password,
)
principal = dav_client.principal()
- # Filter calendars to only those that are in the target_calendars list
+ # Filter calendars to only those that are in the 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()]
+ # to the name f the Django Calendar model instances.
+ tcal_by_name = {c.name: c for c in calendars}
+ calendars_filtered = [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
+ if not calendars_filtered:
+ logger.debug("No calendars to clear collisions in. Exiting.")
+ exit(0)
- for calendar in calendars:
+ for calendar in calendars_filtered:
# 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
+ logger.debug(
+ f"Clearing cancelled bookings and collisions in calendar: {calendar.id}")
+ horizon = tcal_by_name[calendar.id].auto_clear_collision_horizon_days
# Split horizon search into multiple requests if horizon is bigger
# than MAX_SEARCH_HORIZON
@@ -289,7 +308,13 @@ def clear(target_calendars: list, is_test: bool=False) -> dict:
split_expanded=True,
))
except Exception as e:
- print(f"--- Failed to fetch events for calendar: {calendar.id}: {e}")
+ logger.exception(
+ f"Failed to fetch events for calendar: {calendar.id}: {e}",
+ extra={
+ "calendar": calendar.id,
+ "max_search_horizon": MAX_SEARCH_HORIZON,
+ "username": calendars[0].username,
+ })
continue
start_delta += h
@@ -297,9 +322,12 @@ def clear(target_calendars: list, is_test: bool=False) -> dict:
events = []
for event in events_fetched:
try:
- events.append(DavEvent(event))
+ events.append(DavEvent(event))
except ValueError as e:
- print(f"Error creating DavEvent object: {e}")
+ logger.exception(f"Could not create DavEvent object.",
+ exc_info=e, extra={
+ "event": str(event)
+ })
continue
# Filter out events that are missing required fields
@@ -307,94 +335,92 @@ def clear(target_calendars: list, is_test: bool=False) -> dict:
event for event in events if event.missing_required_fields
]
for event in events_missing_required_fields:
- result = {
+ event_result = {
"uid": event.uid or "No UID",
"name": event.name or "No Name",
"reason": f"Missing required fields: {', '.join(event.missing_required_fields)}",
"calendar": calendar.id,
}
- results["ignored_overlapping_events"].append(result)
- print("Skipping event:")
- pprint(result)
- print("------")
+ logger.warning("Skipping event because missing required fields",
+ extra=event_result)
continue
- events = [event for event in events if not event.missing_required_fields]
+ events = [event for event in events if
+ not event.missing_required_fields]
- # Delete cancelled non-recurring events if not in test mode
+ # Delete cancelled non-recurring events if not in dry_run mode
for event in events:
if event.is_cancelled and not event.is_recurring:
- if not is_test:
+ if not dry_run:
event.obj.delete()
- result = {
+ event_result = {
"uid": event.uid,
"name": event.name or "No Name",
"calendar": event.calendar.id,
}
- results["deleted_cancelled_events"].append(result)
- print("Deleted cancelled event:")
- pprint(result)
- print("------")
+ logger.info("Deleted cancelled event.", extra=event_result)
- # Find overlapping events
- overlapping_events = find_overlapping_events(events)
- overlapping_events_json = json.dumps([json.loads(o.get("event").dumps()) for _, o in overlapping_events.items()], indent=2)
- print(f"Found overlapping events:\n{overlapping_events_json}")
+ # Find collisions
+ collisions = find_collisions(events)
+ logger.info(
+ f"Found {len(collisions)} event collisions.", extra={
+ "calendar": calendar.id,
+ "collisions": [{"event": ov["event"].dumps(),
+ "collides_with": [cw.dumps() for cw in
+ ov['collides_with']]} for ov
+ in collisions.values()],
+ })
- # Delete overlapping events and send emails to organizers
- for overlap in overlapping_events.values():
- event = overlap["event"]
- overlaps_with = overlap["overlaps_with"]
- event.is_deprioritized = any(ov.is_prioritized for ov in overlaps_with)
- result = {
- "event": event.serialize(),
- "overlaps_with": [ov.serialize() for ov in overlaps_with],
+ # Delete colliding events and send emails to organizers
+ for collision in collisions.values():
+ event = collision["event"]
+ collides_with = collision["collides_with"]
+ event.is_deprioritized = any(
+ ov.is_prioritized for ov in collides_with)
+ collision_result = {
+ "event": event.dumps(),
+ "collides_with": [ov.dumps() for ov in collides_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:
+ # but only if we are not in dry_run mode
+ if not dry_run:
event.delete()
-
- result["cancellation_type"] = "deleted"
- results["deleted_overlapping_events"].append(result)
- print("Deleted overlapping event:")
+ collision_result["cancellation_type"] = "deleted"
# 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:
+ if not dry_run:
event.cancel()
- result["cancellation_type"] = "cancelled"
- results["cancelled_overlapping_recurring_events"].append(result)
- print("Cancelled overlapping recurring event:")
+ collision_result["cancellation_type"] = "cancelled"
# Get all events of the affected calendar for the days of the
# declined event
other_bookings = find_other_bookings(event, calendar)
# Find free alternative rooms for the time slot of the declined event
- alternatives = find_alternatives(event, principal, target_calendars)
+ alternatives = find_alternatives(event, principal, calendars)
# Create a set for all events as an overview
event.is_declined = True
- event.is_overlapping = False
+ event.is_collision = False
event.is_other_booking = False
overview = {event}
- # Add the overlapping events to the overview
- for ov in overlaps_with:
- ov.is_declined = False
- ov.is_overlapping = True
- ov.is_other_booking = False
- overview.add(ov)
+ # Add the colliding events to the overview
+ for cw in collides_with:
+ cw.is_declined = False
+ cw.is_collision = True
+ cw.is_other_booking = False
+ overview.add(cw)
# Add the other bookings to the overview
for ob in other_bookings:
if not ob.is_cancelled:
ob.is_declined = False
- ob.is_overlapping = False
+ ob.is_collision = False
ob.is_other_booking = True
overview.add(ob)
@@ -404,66 +430,89 @@ def clear(target_calendars: list, is_test: bool=False) -> dict:
# Sort the overview by start time
overview.sort(key=lambda ev: ev.start)
- # Send email to organizer of the event if not in test mode
+ # Send email to organizer of the event if not in dry_run mode
email_template = tcal_by_name[calendar.id].email_template
if email_template:
try:
send_mail_to_organizer(
booking=event,
- overlap_bookings=overlaps_with,
+ colliding_bookings=collides_with,
event_overview=overview,
other_bookings=other_bookings,
alternatives=alternatives,
calendar=calendar,
email_template=email_template,
- is_test=is_test,
+ is_test=dry_run,
)
- result["email_sent"] = True
+ collision_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
+ logger.exception(
+ "Failed to send email to organizer"
+ f" for event '{event.name}'",
+ exc_info=e,
+ extra={
+ "calendar": calendar.id,
+ "event": event.dumps(),
+ "colliding_with": [cw.dumps() for cw in
+ collides_with],
+ "overview": [ov.dumps() for ov in overview],
+ "alternatives": [cal.id for cal in alternatives],
+ "email_template": email_template.id,
+ })
+ collision_result["email_sent"] = False
else:
- print(f"No email template found for calendar {calendar.id}. "
- "Skipping email sending.")
- result["email_sent"] = False
+ logger.debug(
+ f"No email template found for calendar '{calendar.id}'. "
+ "Skipping email sending.",
+ extra={"calendar": calendar.id}
+ )
- # Print results
- pprint(result)
- print("------")
+ collision_result["email_sent"] = False
+
+ # Log results
+ logger.info("Processed colliding event.",
+ extra=collision_result)
except Exception as e:
- print(f"Failed to delete event {event.uid}: {e}")
+ logger.exception(
+ f"Failed to process colliding event '{event.uid}'.",
+ exc_info=e, extra={
+ "calendar": calendar.id,
+ "event": event.dumps(),
+ "collides_with": [ov.dumps() for ov in collides_with],
+ "dry_run": dry_run,
+ })
print("------")
continue
- print(f"--- Clearing completed.{' Test mode enabled.' if is_test else ''}")
- return results
+ logger.debug(f"Clearing completed.{' Test mode enabled.' if dry_run else ''}")
-def find_overlapping_events(events: list[DavEvent]) -> dict:
+
+def find_collisions(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
+ Find colliding events.
+ :param events: List of events to check for collisions.
+ :return: Dictionary of colliding events with their UIDs as keys and
+ a dictionary containing the event and the event it collides with
as values.
"""
- overlapping_events = {}
+ logger = logging.getLogger(__name__)
+ colliding_events = {}
# Order events by created time
events = sorted(events, key=lambda item: item.created)
- # Define lambda functions to check for overlaps
+ # Define lambda functions to check for collisions
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
+ # Find colliding events
for event in events:
- # Skip if the event is already in the overlapping events dictionary
+ # Skip if the event is already in the colliding events dictionary
# or if it is already canceled
- if overlapping_events.get(event.extended_uid) or event.is_cancelled:
+ if colliding_events.get(event.extended_uid) or event.is_cancelled:
continue
for compare_event in events:
@@ -476,53 +525,59 @@ def find_overlapping_events(events: list[DavEvent]) -> dict:
if compare_event.is_cancelled:
continue
- # Check if the events overlap
+ # Check if the events collide
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 both events are prioritized, skip the collision
if compare_event.is_prioritized and event.is_prioritized:
- print(f"Skipping overlap between prioritized events: "
- f"'{event.name}' and '{compare_event.name}'")
+ logger.info("Skipping collisions detection between "
+ "two prioritized events "
+ f"'{event.name}' and '{compare_event.name}'",
+ extra={"event_1": event.dumps(),
+ "event_2": compare_event.dumps()})
continue
# If the compare_event is prioritized and the event is not,
- # skip the overlap
+ # skip the collision
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}'")
+ logger.debug("Skipping collisions detection between "
+ f"event '{event.name}' and prioritized "
+ f"event '{compare_event.name}'",
+ extra={"event_1": event.dumps(),
+ "event_2": compare_event.dumps()})
continue
- # Add to overlapping events dictionary
- if e := overlapping_events.get(compare_event.extended_uid):
- # If the event is already in the dictionary, extend the overlaps
- e["overlaps_with"].append(event)
+ # Add to colliding events dictionary
+ if e := colliding_events.get(compare_event.extended_uid):
+ # If the event is already in the dictionary, extend the collisions
+ e["collides_with"].append(event)
else:
- # Create a new entry for the overlapping event
- overlapping_events[compare_event.extended_uid] = {
+ # Create a new entry for the colliding event
+ colliding_events[compare_event.extended_uid] = {
"event": compare_event,
- "overlaps_with": [event]
+ "collides_with": [event]
}
- return overlapping_events
+ return colliding_events
def send_mail_to_organizer(
booking: DavEvent,
- overlap_bookings: list[DavEvent],
+ colliding_bookings: list[DavEvent],
event_overview: list[DavEvent],
calendar: caldav.Calendar,
email_template,
- other_bookings: list[DavEvent]=None,
- alternatives: list[Calendar]=None,
- is_test: bool=False
+ other_bookings: list[DavEvent] = None,
+ alternatives: list[Calendar] = None,
+ is_test: bool = False
):
"""
Send email to organizer of the event.
:param booking: Booking that was declined
- :param overlap_bookings: List of bookings which overlap with the declined event
- :param event_overview: Sorted list of all events in the calendar, including the declined event and overlaps
+ :param colliding_bookings: List of bookings which collide with the declined event
+ :param event_overview: Sorted list of all events in the calendar, including the declined event and collisions
:param calendar: Calendar to send the email from
another event, False otherwise
:param email_template: Email template to use for the email
@@ -530,8 +585,10 @@ def send_mail_to_organizer(
:param alternatives: List of alternative calendars (rooms) for the time slot of the declined booking
:param is_test: If True, do not send the email, just print it
"""
+ logger = logging.getLogger(__name__)
+
if is_test:
- print("Not sending email in test mode.")
+ logger.debug("Not sending email in test mode.")
# Check if environment variables for SMTP are set
if not all([
@@ -543,10 +600,15 @@ def send_mail_to_organizer(
recipient = booking.organizer
+ # Do not send email if recipient is empty
+ if not recipient or recipient == "":
+ logger.debug("Not sending email to empty recipient.")
+ return
+
# Prepare the email content
context = {
"booking": booking,
- "overlap_bookings": overlap_bookings,
+ "colliding_bookings": colliding_bookings,
"other_bookings": other_bookings if other_bookings else [],
"overview": event_overview,
"alternatives": alternatives if alternatives else [],
@@ -557,7 +619,9 @@ def send_mail_to_organizer(
# Create the email message
message = MIMEMultipart("alternative")
- message["From"] = email.utils.formataddr((os.getenv('SMTP_SENDER_NAME', 'Room Booking'), os.getenv('SMTP_EMAIL')))
+ message["From"] = email.utils.formataddr(
+ (os.getenv('SMTP_SENDER_NAME', 'Room Booking'),
+ os.getenv('SMTP_EMAIL')))
message["To"] = email.utils.formataddr((None, recipient))
if bcc := os.getenv("SMTP_BCC"):
message["Bcc"] = bcc
@@ -575,28 +639,44 @@ def send_mail_to_organizer(
ssl_context = ssl.create_default_context()
# Send the email with starttls or SSL based on environment variable
- print(f"Sending email to {', '.join(recipients)} with subject: {message['Subject']}")
+ logger.info(f"Sending email.",
+ extra={
+ "to": recipients,
+ "subject": message["Subject"],
+ "calendar": calendar.id,
+ "email_template": email_template.id,
+ "plaintext": plaintext,
+ "html": html,
+ "is_test": is_test,
+ })
if os.getenv('SMTP_STARTTLS', False):
- print("Using STARTTLS for SMTP connection.")
+ logger.debug("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())
+ 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.")
+ logger.debug("Using SSL for SMTP connection.")
if not is_test:
with smtplib.SMTP_SSL(os.getenv('SMTP_SERVER'),
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())
+ 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 find_other_bookings(event: DavEvent, calendar: caldav.Calendar) -> list[DavEvent]:
+
+def find_other_bookings(event: DavEvent, calendar: caldav.Calendar) -> list[
+ DavEvent]:
"""
Find other bookings in the calendar that are at the same day(s) as the declined event.
- :param event: The event to check for overlaps.
+ :param event: The event to check for collisions.
:param calendar: The calendar to search in.
:return: List of DavEvent objects.
"""
@@ -621,6 +701,7 @@ def find_other_bookings(event: DavEvent, calendar: caldav.Calendar) -> list[DavE
and e.extended_uid != event.extended_uid
and not e.missing_required_fields]
+
def find_alternatives(event: DavEvent, principal: Principal,
target_calendars: list) -> list[Calendar]:
"""
@@ -630,13 +711,15 @@ def find_alternatives(event: DavEvent, principal: Principal,
:param target_calendars: Calendars administrated by the current user
:return: List of alternative calendars that are available for the time slot.
"""
+ logger = logging.getLogger(__name__)
+
# Get all calendars of the principal
calendars = principal.calendars()
alternative_calendars = {
calendar.name: [c.name for c in calendar.alternatives.all()]
for calendar in target_calendars
}
- print("Alternative calendars for event calendar: "
+ logger.debug("Alternative calendars for event calendar: "
f"{', '.join([a for a in alternative_calendars[event.obj.parent.id]])}")
alternatives = []
@@ -654,8 +737,7 @@ def find_alternatives(event: DavEvent, principal: Principal,
if calendar.id not in alternative_calendars[event.obj.parent.id]:
continue
-
- # Search for events in the alternative calendar that overlap with the
+ # Search for events in the alternative calendar that collide with the
# declined event
events_fetched = calendar.search(
start=event.start,
@@ -676,8 +758,13 @@ def find_alternatives(event: DavEvent, principal: Principal,
if not blocked:
alternatives.append(calendar)
- print("Available alternative calendars for event: "
- f"{', '.join([a.id for a in alternatives])}")
+ logger.debug("Found available alternatives for event.",
+ extra={
+ "event": event.dumps(),
+ "alternatives": [a.id for a in alternatives],
+ })
return alternatives
+if __name__ == "__main__":
+ clear_bookings()
diff --git a/src/migrations/0014_rename_auto_clear_overlap_horizon_days_calendar_auto_clear_collision_horizon_days.py b/src/migrations/0014_rename_auto_clear_overlap_horizon_days_calendar_auto_clear_collision_horizon_days.py
new file mode 100644
index 0000000..54095f4
--- /dev/null
+++ b/src/migrations/0014_rename_auto_clear_overlap_horizon_days_calendar_auto_clear_collision_horizon_days.py
@@ -0,0 +1,18 @@
+# Generated by Django 5.2.6 on 2025-09-15 11:36
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("booking", "0013_remove_emailtemplate_body_emailtemplate_html_and_more"),
+ ]
+
+ operations = [
+ migrations.RenameField(
+ model_name="calendar",
+ old_name="auto_clear_overlap_horizon_days",
+ new_name="auto_clear_collision_horizon_days",
+ ),
+ ]
diff --git a/src/mylogger/__init__.py b/src/mylogger/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/src/mylogger/logger.py b/src/mylogger/logger.py
new file mode 100755
index 0000000..ac740f7
--- /dev/null
+++ b/src/mylogger/logger.py
@@ -0,0 +1,165 @@
+import atexit
+import datetime as dt
+import json
+import logging
+import logging.config
+from pathlib import Path
+from typing import override
+
+PACKAGE_PATH = Path(__file__).parent
+LOG_RECORD_BUILTIN_ATTRS = {
+ "args",
+ "asctime",
+ "created",
+ "exc_info",
+ "exc_text",
+ "filename",
+ "funcName",
+ "levelname",
+ "levelno",
+ "lineno",
+ "module",
+ "msecs",
+ "message",
+ "msg",
+ "name",
+ "pathname",
+ "process",
+ "processName",
+ "relativeCreated",
+ "stack_info",
+ "thread",
+ "threadName",
+ "taskName",
+}
+
+
+def setup_logging(
+ logdir: Path | str | None = None,
+ **kwargs,
+):
+ """
+ Setup logging configuration
+ :param logdir: Directory to store the log file
+ :keyword file_log_level: Log level for the file handler
+ :keyword stdout_log_level: Log level for the stdout handler
+ :return:
+ """
+ handlers = ["file", "stdout"]
+
+ config_file = PACKAGE_PATH / "resources" / "logging_config.json"
+ with open(config_file, "r") as file:
+ config = json.load(file)
+
+ # Override log level if provided
+ for handler in handlers:
+ if log_level := kwargs.get(f"{handler}_log_level"):
+ level_str = logging.getLevelName(log_level.upper())
+ if not isinstance(level_str, str):
+ level_str = logging.getLevelName(level_str)
+ config["handlers"][handler]["level"] = level_str
+
+ # Set log file path to user log directory
+ if logdir:
+ logdir = Path(logdir)
+ if logdir.is_dir():
+ config["handlers"]["file"]["filename"] = logdir / "room-booking.log.jsonl"
+ else:
+ config["handlers"]["file"]["filename"] = logdir
+
+ # Set max backup count if provided
+ if backup_count := kwargs.get("LOGROTATE_BACKUP_COUNT"):
+ config["handlers"]["file"]["backupCount"] = int(backup_count)
+
+ # Set max bytes if provided
+ if max_bytes := kwargs.get("LOGROTATE_MAX_BYTES"):
+ config["handlers"]["file"]["maxBytes"] = int(max_bytes)
+
+ # Create path and file if it does not exist
+ Path(config["handlers"]["file"]["filename"]).parent.mkdir(
+ parents=True, exist_ok=True)
+ Path(config["handlers"]["file"]["filename"]).touch()
+
+ logging.config.dictConfig(config)
+ queue_handler = logging.getHandlerByName("queue_handler")
+ if queue_handler is not None:
+ queue_handler.listener.start()
+ atexit.register(queue_handler.listener.stop)
+
+
+class JSONFormatter(logging.Formatter):
+ """
+ A custom JSON formatter for logging
+ """
+ HIDE_KEYS = ["password", "token", "api_key", "site_key"]
+
+
+ def __init__(
+ self,
+ *,
+ fmt_keys: dict[str, str] | None = None,
+ ):
+ super().__init__()
+ self.fmt_keys = fmt_keys if fmt_keys is not None else {}
+
+ @override
+ def format(self, record: logging.LogRecord) -> str:
+ message = self._prepare_log_dict(record)
+
+ # Exclude passwords from the log
+ self._hide_passwords(record)
+
+ return json.dumps(message, default=str)
+
+ def _hide_passwords(self, log_record: logging.LogRecord|dict):
+ """
+ Recursively replace all values with keys containing 'password',
+ 'token', etc. with '********'
+ :param log_record:
+ :return:
+ """
+ if not isinstance(log_record, dict):
+ dict_obj = log_record.__dict__
+ else:
+ dict_obj = log_record
+
+ for key, value in dict_obj.items():
+ if isinstance(value, dict):
+ dict_obj = self._hide_passwords(value)
+ elif any(hide_key in key.lower() for hide_key in self.HIDE_KEYS):
+ dict_obj[key] = "********"
+
+ if isinstance(log_record, logging.LogRecord):
+ for key, value in dict_obj.items():
+ setattr(log_record, key, value)
+ return log_record
+ else:
+ return dict_obj
+
+ def _prepare_log_dict(self, record: logging.LogRecord) -> dict:
+ always_fields = {
+ "message": record.getMessage(),
+ "timestamp": dt.datetime.fromtimestamp(
+ record.created, tz=dt.timezone.utc
+ ).isoformat() if type(record.created) == float else record.created,
+ }
+ if record.exc_info is not None:
+ always_fields["exc_info"] = self.formatException(record.exc_info)
+
+ if record.stack_info is not None:
+ always_fields["stack_info"] = self.formatStack(record.stack_info)
+
+ message = {
+ key: msg_val
+ if (msg_val := always_fields.pop(val, None)) is not None
+ else getattr(record, val)
+ for key, val in self.fmt_keys.items()
+ }
+ message.update(always_fields)
+
+ # Include all other attributes
+ for key, val, in record.__dict__.items():
+ if key not in LOG_RECORD_BUILTIN_ATTRS:
+ message[key] = val
+
+ return message
\ No newline at end of file
diff --git a/src/mylogger/resources/logging_config.json b/src/mylogger/resources/logging_config.json
new file mode 100755
index 0000000..9bd2fff
--- /dev/null
+++ b/src/mylogger/resources/logging_config.json
@@ -0,0 +1,55 @@
+{
+ "version": 1,
+ "disable_existing_loggers": false,
+ "formatters": {
+ "simple": {
+ "format": "%(levelname)s - %(message)s",
+ "datefmt": "%Y-%m-%dT%H:%M:%Sz"
+ },
+ "json": {
+ "()": "mylogger.logger.JSONFormatter",
+ "fmt_keys": {
+ "level": "levelname",
+ "message": "message",
+ "timestamp": "timestamp",
+ "logger": "name",
+ "module": "module",
+ "function": "funcName",
+ "line": "lineno",
+ "thread_name": "threadName"
+ }
+ }
+ },
+ "handlers": {
+ "stdout": {
+ "class": "logging.StreamHandler",
+ "formatter": "simple",
+ "stream": "ext://sys.stdout",
+ "level": "INFO"
+ },
+ "file": {
+ "class": "logging.handlers.RotatingFileHandler",
+ "formatter": "json",
+ "filename": "room-booking.log.jsonl",
+ "level": "INFO",
+ "maxBytes": 10000000,
+ "backupCount": 6
+ },
+ "queue_handler": {
+ "class": "logging.handlers.QueueHandler",
+ "handlers": [
+ "stdout",
+ "file"
+ ],
+ "respect_handler_level": true
+ }
+ },
+ "loggers": {
+ "root": {
+ "handlers": [
+ "queue_handler"
+ ],
+ "level": "DEBUG"
+ }
+ }
+}
\ No newline at end of file