💥 execute room booking clearing in separate script
This commit is contained in:
parent
9c4d699036
commit
81eb7de254
|
|
@ -4,7 +4,7 @@
|
|||
<content url="file://$MODULE_DIR$">
|
||||
<excludeFolder url="file://$MODULE_DIR$/.venv" />
|
||||
</content>
|
||||
<orderEntry type="jdk" jdkName="Python 3.12 (RoomBooking)" jdkType="Python SDK" />
|
||||
<orderEntry type="jdk" jdkName="uv (RoomBooking)" jdkType="Python SDK" />
|
||||
<orderEntry type="sourceFolder" forTests="false" />
|
||||
</component>
|
||||
</module>
|
||||
|
|
@ -1,11 +1,11 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="DataSourceManagerImpl" format="xml" multifile-model="true">
|
||||
<data-source source="LOCAL" name="db" uuid="37a129b8-8038-4c13-bba8-73cf8fa77538">
|
||||
<data-source source="LOCAL" name="db" uuid="582add31-3085-4739-9171-fdd62b5bf3f9">
|
||||
<driver-ref>sqlite.xerial</driver-ref>
|
||||
<synchronize>true</synchronize>
|
||||
<jdbc-driver>org.sqlite.JDBC</jdbc-driver>
|
||||
<jdbc-url>jdbc:sqlite:$PROJECT_DIR$/src/db.sqlite3</jdbc-url>
|
||||
<jdbc-url>jdbc:sqlite:$PROJECT_DIR$/src/data/db.sqlite3</jdbc-url>
|
||||
<working-dir>$ProjectFileDir$</working-dir>
|
||||
<libraries>
|
||||
<library>
|
||||
|
|
@ -16,7 +16,7 @@
|
|||
</library>
|
||||
</libraries>
|
||||
</data-source>
|
||||
<data-source source="LOCAL" name="db [2]" uuid="653863ac-b4c8-4525-be5f-881f75f27fff">
|
||||
<data-source source="LOCAL" name="db [2]" uuid="d0501638-9467-459e-9ea1-5972b821732f">
|
||||
<driver-ref>sqlite.xerial</driver-ref>
|
||||
<synchronize>true</synchronize>
|
||||
<jdbc-driver>org.sqlite.JDBC</jdbc-driver>
|
||||
|
|
@ -29,6 +29,33 @@
|
|||
<library>
|
||||
<url>file://$APPLICATION_CONFIG_DIR$/jdbc-drivers/Xerial SQLiteJDBC/3.45.1/org/slf4j/slf4j-api/1.7.36/slf4j-api-1.7.36.jar</url>
|
||||
</library>
|
||||
<library>
|
||||
<url>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</url>
|
||||
</library>
|
||||
<library>
|
||||
<url>file://$APPLICATION_CONFIG_DIR$/jdbc-drivers/Xerial SQLiteJDBC/3.45.1/org/slf4j/slf4j-api/1.7.36/slf4j-api-1.7.36.jar</url>
|
||||
</library>
|
||||
</libraries>
|
||||
</data-source>
|
||||
<data-source source="LOCAL" name="db [3]" uuid="1e9a9b86-5cdd-41ef-8a57-22badc9a40b8">
|
||||
<driver-ref>sqlite.xerial</driver-ref>
|
||||
<synchronize>true</synchronize>
|
||||
<jdbc-driver>org.sqlite.JDBC</jdbc-driver>
|
||||
<jdbc-url>jdbc:sqlite:$PROJECT_DIR$/src/db.sqlite3</jdbc-url>
|
||||
<working-dir>$ProjectFileDir$</working-dir>
|
||||
<libraries>
|
||||
<library>
|
||||
<url>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</url>
|
||||
</library>
|
||||
<library>
|
||||
<url>file://$APPLICATION_CONFIG_DIR$/jdbc-drivers/Xerial SQLiteJDBC/3.45.1/org/slf4j/slf4j-api/1.7.36/slf4j-api-1.7.36.jar</url>
|
||||
</library>
|
||||
<library>
|
||||
<url>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</url>
|
||||
</library>
|
||||
<library>
|
||||
<url>file://$APPLICATION_CONFIG_DIR$/jdbc-drivers/Xerial SQLiteJDBC/3.45.1/org/slf4j/slf4j-api/1.7.36/slf4j-api-1.7.36.jar</url>
|
||||
</library>
|
||||
</libraries>
|
||||
</data-source>
|
||||
</component>
|
||||
|
|
|
|||
|
|
@ -6,5 +6,5 @@
|
|||
<component name="MarkdownSettingsMigration">
|
||||
<option name="stateVersion" value="1" />
|
||||
</component>
|
||||
<component name="ProjectRootManager" version="2" project-jdk-name="Python 3.12 (RoomBooking)" project-jdk-type="Python SDK" />
|
||||
<component name="ProjectRootManager" version="2" project-jdk-name="uv (RoomBooking)" project-jdk-type="Python SDK" />
|
||||
</project>
|
||||
42
Dockerfile
42
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"]
|
||||
|
|
|
|||
97
README.md
97
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
|
||||
- `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
|
||||
|
|
@ -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:
|
||||
|
|
@ -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)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
# 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
|
||||
|
||||
|
|
@ -299,7 +324,10 @@ def clear(target_calendars: list, is_test: bool=False) -> dict:
|
|||
try:
|
||||
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()
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
),
|
||||
]
|
||||
|
|
@ -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
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue