From e6ab41594de42ba154f4fb70298f400a37df907a Mon Sep 17 00:00:00 2001 From: Marc Koch Date: Sat, 21 Jun 2025 00:45:45 +0200 Subject: [PATCH 1/4] =?UTF-8?q?=E2=9C=A8=20add=20automatic=20event=20overl?= =?UTF-8?q?ap=20clearing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements a mechanism to automatically clear overlapping events in user calendars. Introduces a new model for email templates, enabling customizable notifications to event organizers when their events are declined due to overlaps. Adds a background task to find overlapping events based on calendar settings and automatically cancels the later event, sending a notification email to the organizer, if configured. --- requirements.txt | 10 + src/booking.py | 69 +++++- src/clear_overlaps.py | 219 ++++++++++++++++++ src/migrations/0002_emailtemplate_and_more.py | 50 ++++ 4 files changed, 347 insertions(+), 1 deletion(-) create mode 100644 src/clear_overlaps.py create mode 100644 src/migrations/0002_emailtemplate_and_more.py diff --git a/requirements.txt b/requirements.txt index d1d5d8b..d98e4c5 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,6 +3,7 @@ arrow==1.3.0 asgiref==3.8.1 attrs==24.3.0 black==24.10.0 +caldav==1.6.0 certifi==2024.12.14 charset-normalizer==3.4.1 click==8.1.8 @@ -10,10 +11,14 @@ Django==5.1.4 django-ninja==1.3.0 gunicorn==23.0.0 h11==0.14.0 +icalendar==6.3.1 ics==0.7.2 idna==3.10 isort==5.13.2 +Jinja2==3.1.6 +lxml==5.4.0 Markdown==3.7 +MarkupSafe==3.0.2 mypy-extensions==1.0.0 nanodjango==0.9.2 packaging==24.2 @@ -22,6 +27,8 @@ platformdirs==4.3.6 pydantic==2.10.4 pydantic_core==2.27.2 python-dateutil==2.9.0.post0 +pytz==2025.2 +recurring-ical-events==3.8.0 requests==2.32.3 shortuuid==1.0.13 six==1.17.0 @@ -29,7 +36,10 @@ sqlparse==0.5.3 TatSu==5.12.2 types-python-dateutil==2.9.0.20241206 typing_extensions==4.12.2 +tzdata==2025.2 urllib3==2.3.0 uvicorn==0.34.0 validators==0.34.0 +vobject==0.9.9 whitenoise==6.8.2 +x-wr-timezone==2.0.1 diff --git a/src/booking.py b/src/booking.py index 4ef463c..9f5b388 100644 --- a/src/booking.py +++ b/src/booking.py @@ -16,6 +16,8 @@ from ics import Calendar as ICS_Calendar, Event as ICS_Event from nanodjango import Django from shortuuid.django_fields import ShortUUIDField +from clear_overlaps import clear_overlaps as clear + DEBUG = os.getenv("DJANGO_DEBUG", False) SECRET_KEY = os.getenv("DJANGO_SECRET_KEY") \ if os.getenv("DJANGO_SECRET_KEY") \ @@ -49,6 +51,34 @@ from ninja.security import HttpBearer, django_auth from ninja.errors import HttpError +@app.admin +class EmailTemplate(models.Model): + """ + Email template model to store email templates for sending notifications. + """ + id = models.AutoField(primary_key=True) + name = models.CharField(max_length=100, unique=True) + subject = models.CharField(max_length=200) + body = models.TextField(help_text="""\ + Email template to use for sending notifications about deleted events.
+ If not set, no email will be sent.
+ Jinja2 template syntax can be used in the subject and body.
+ Available variables:
+ - event_name: The name of the event
+ - event_start: The start time of the event
+ - event_end: The end time of the event
+ - event_created: The time the event was created
+ - overlap_event_name: The name of the overlap event
+ - overlap_event_start: The start time of the overlap event
+ - overlap_event_end: The end time of the overlap event
+ - overlap_event_created: The time the overlap event was created
+ - overlap_event_organizer: The organizer of the overlap event
+ - calendar_name: The name of the calendar""") + + def __str__(self): + return self.name + + @app.admin class Calendar(models.Model): """ @@ -60,6 +90,14 @@ class Calendar(models.Model): calendar_password = models.CharField(max_length=29) users = models.ManyToManyField(app.settings.AUTH_USER_MODEL, related_name='calendars') + auto_clear_overlaps = models.BooleanField(default=False) + auto_clear_overlap_horizon_days = models.IntegerField(default=30) + email_template = models.ForeignKey(EmailTemplate, on_delete=models.SET_NULL, + null=True, blank=True, + related_name='calendars', + help_text="""\ + Email template to use for sending notifications about deleted events.
+ If not set, no email will be sent.""") def save(self, *args, **kwargs): self.url = self.url.rstrip("/") @@ -169,7 +207,7 @@ class Event(models.Model): return string if not self.cancelled else f"{string} - CANCELLED" -@app.admin(readonly_fields = ('key',)) +@app.admin(readonly_fields=('key',)) class APIKey(models.Model): id = models.AutoField(primary_key=True) user = models.ForeignKey(app.settings.AUTH_USER_MODEL, @@ -238,6 +276,7 @@ def get_markdown(): md = markdown.Markdown(extensions=["fenced_code"]) return md.convert(f.read()) + def get_version(): """ Get the version of the app from the version.txt file. @@ -282,6 +321,34 @@ def delete_event(request, calendar: str, event_id: str): event.cancel() return 204, None +@api.delete("/clear-overlaps", response={200: None, 204: None, 401: None, 404: None}) +@csrf_exempt +def clear_overlaps(request, calendar: str = None): + user = get_user(request) + + # Get optional calendar name from the request + if calendar: + cal = get_object_or_404(Calendar, name=calendar, + auto_clear_overlaps=True) + if user not in cal.users.all(): + raise HttpError(401, + f"User not authorised to clear overlaps in calendar '{cal.name}'") + + calendars = [cal] + + # If no calendar is specified, get all calendars for the user that have + # auto_clear_overlaps enabled + else: + calendars = user.calendars.filter(auto_clear_overlaps=True) + + if not calendars: + return 200, "No calendars with auto_clear_overlaps enabled found for user." + + if clear(list(calendars)): + return 204, "Overlaps cleared successfully." + else: + raise HttpError(200, "No overlaps to clear.") + app.route("api/", include=api.urls) diff --git a/src/clear_overlaps.py b/src/clear_overlaps.py new file mode 100644 index 0000000..27ce14e --- /dev/null +++ b/src/clear_overlaps.py @@ -0,0 +1,219 @@ +import os +import smtplib +import ssl +from datetime import datetime, date, timedelta +from email.mime.multipart import MIMEMultipart +from email.mime.text import MIMEText +from typing import NamedTuple +import pytz + +import caldav +from jinja2 import Template + +tz = pytz.timezone(os.getenv("TIME_ZONE", "Europe/Berlin")) + +DavEvent = NamedTuple("DavEvent", [ + ("uid", str), + ("name", str), + ("start", datetime), + ("end", datetime), + ("created", datetime), + ("status", str), + ("organizer", str), + ("obj", caldav.Event), +]) + +def clear_overlaps(target_calendars: list) -> bool: + """ + Clear overlapping events in calendars. + :param target_calendars: List of calendars to check for overlaps. + :return: True if overlaps were cleared, False otherwise. + """ + cleared = False + + dav_client = caldav.DAVClient( + url=target_calendars[0].url, + username=target_calendars[0].username, + password=target_calendars[0].calendar_password, + ) + + principal = dav_client.principal() + + # Filter calendars to only those that are in the target_calendars list + # Note: The id of the objects returned by principal.calendars() corresponds + # to the name of the Django Calendar model instances. + tcal_by_name = {c.name: c for c in target_calendars} + calendars = [cal for cal in principal.calendars() + if cal.id in tcal_by_name.keys()] + + if not calendars: + print("No calendars to clear overlaps in. Exiting.") + return False + + for calendar in calendars: + + # Get events from calendar + print(f"Clearing overlaps in calendar: {calendar.id}") + horizon = tcal_by_name[calendar.id].auto_clear_overlap_horizon_days + events_fetched = calendar.search( + start=datetime.now(), + end=date.today() + timedelta(days=horizon), + event=True, + expand=False, + ) + + events = [] + for event in events_fetched: + for component in event.icalendar_instance.walk(): + if component.name == "VEVENT" and component.get("status") != "CANCELLED": + events.append( + DavEvent( + uid=component.get("uid"), + name=component.get("summary", "No Name"), + start=component.get("dtstart").dt.astimezone(tz), + end=component.get("dtend").dt.astimezone(tz), + created=component.get("created").dt.astimezone(tz), + status=component.get("status", "CONFIRMED"), + organizer=component.get("organizer", "").replace("mailto:", "").strip(), + obj=event, + ) + ) + + # Find overlapping events + overlapping_events = find_overlapping_events(events) + + # Delete overlapping events and send emails to organizers + for overlap in overlapping_events.values(): + event = overlap["event"] + overlaps_with = overlap["overlaps_with"] + try: + # Delete the event + event.obj.delete() + cleared = True + + # Send email to organizer of the event + email_template = tcal_by_name[calendar.id].email_template + if email_template: + try: + send_mail_to_organizer(event, overlaps_with, calendar, + email_template) + except Exception as e: + print("Failed to send email to organizer for event " + f"{event.name}: {e}") + continue + except Exception as e: + print(f"Failed to delete event {event.uid}: {e}") + continue + + return cleared + + +def find_overlapping_events(events: list[DavEvent]) -> dict: + """ + Find overlapping events. + :param events: List of events to check for overlaps. + :return: Dictionary of overlapping events with their UIDs as keys and + a dictionary containing the event and the event it overlaps with + as values. + """ + overlapping_events = {} + checked_events = {} + + # Create a dictionary to make it easier to find an event by its UID + events = {e.uid: e for e in events} + + # Order events by created time + events = dict(sorted(events.items(), key=lambda item: item[1].created)) + + # Define lambda functions to check for overlaps + event_starts_earlier = lambda e1, e2: e1.start <= e2.start <= e1.end + event_inbetween = lambda e1, e2: e2.start <= e1.start <= e1.end <= e2.end + event_ends_later = lambda e1, e2: e1.end >= e2.end >= e1.start + + # Find overlapping events + for event in events.values(): + + # Skip if the event is already in the overlapping events dictionary + if event.uid in overlapping_events.keys(): + continue + + for compare_event in events.values(): + # Skip if the events is: + # 1. The same event as the compare_event + # 2. Already checked + # 3. Already in the overlapping events dictionary + if event.uid == compare_event.uid \ + or compare_event.uid in checked_events.keys() \ + or compare_event.uid in overlapping_events.keys(): + continue + + # Check if the events overlap + if event_starts_earlier(event, compare_event) or \ + event_inbetween(event, compare_event) or \ + event_ends_later(event, compare_event): + # Add to overlapping events dictionary + overlapping_events.update({ + compare_event.uid: {"event": compare_event, + "overlaps_with": event}}) + # Add to checked events dictionary + checked_events.update({event.uid: event}) + + return overlapping_events + +def send_mail_to_organizer( + event: DavEvent, + overlap_event: DavEvent, + calendar: caldav.Calendar, + email_template, +): + """ + Send email to organizer of the event. + :param event: Event that was declined + :param overlap_event: Event that overlaps with the declined event + :param calendar: Calendar to send the email from + :param email_template: Email template to use for the email + """ + # Check if environment variables for SMTP are set + if not all([ + os.getenv('SMTP_EMAIL'), + os.getenv('SMTP_PASSWORD'), + os.getenv('SMTP_SERVER'), + ]): + raise Exception("SMTP environment variables are not set.") + + recipient = event.organizer + + # Prepare the email content + context = { + "event_name": event.name, + "event_start": event.start, + "event_end": event.end, + "event_created": event.created, + "overlap_event_name": overlap_event.name, + "overlap_event_start": overlap_event.start, + "overlap_event_end": overlap_event.end, + "overlap_event_created": overlap_event.created, + "overlap_event_organizer": overlap_event.organizer, + "calendar_name": calendar.get_display_name(), + } + + # Create the email message + message = MIMEMultipart("alternative") + message["From"] = f"{os.getenv('SMTP_SENDER_NAME', 'Room Booking')} <{os.getenv('SMTP_EMAIL')}>" + message["To"] = recipient + message["Date"] = datetime.now().astimezone(tz).strftime( "%d/%m/%Y %H:%M") + + # Try to render subject and body from the email template + message["Subject"] = Template(email_template.subject).render(**context) + body = Template(email_template.body).render(**context) + message.attach(MIMEText(body, "plain")) + + # Create a secure SSL context + ssl_context = ssl.create_default_context() + + # Send the email + with smtplib.SMTP_SSL("mail.extrasolar.space", + os.getenv('SMTP_PORT', 465), + context=ssl_context) as server: + server.login(os.getenv("SMTP_EMAIL"), os.getenv("SMTP_PASSWORD")) + server.sendmail(os.getenv("SMTP_EMAIL"), recipient, message.as_string()) diff --git a/src/migrations/0002_emailtemplate_and_more.py b/src/migrations/0002_emailtemplate_and_more.py new file mode 100644 index 0000000..47087ca --- /dev/null +++ b/src/migrations/0002_emailtemplate_and_more.py @@ -0,0 +1,50 @@ +# Generated by Django 5.1.4 on 2025-06-25 14:08 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("booking", "0001_initial"), + ] + + operations = [ + migrations.CreateModel( + name="EmailTemplate", + fields=[ + ("id", models.AutoField(primary_key=True, serialize=False)), + ("name", models.CharField(max_length=100, unique=True)), + ("subject", models.CharField(max_length=200)), + ( + "body", + models.TextField( + help_text=" Email template to use for sending notifications about deleted events.
\n If not set, no email will be sent.
\n Jinja2 template syntax can be used in the subject and body.
\n Available variables:
\n - event_name: The name of the event
\n - event_start: The start time of the event
\n - event_end: The end time of the event
\n - event_created: The time the event was created
\n - overlap_event_name: The name of the overlap event
\n - overlap_event_start: The start time of the overlap event
\n - overlap_event_end: The end time of the overlap event
\n - overlap_event_created: The time the overlap event was created
\n - overlap_event_organizer: The organizer of the overlap event
\n - calendar_name: The name of the calendar" + ), + ), + ], + ), + migrations.AddField( + model_name="calendar", + name="auto_clear_overlap_horizon_days", + field=models.IntegerField(default=30), + ), + migrations.AddField( + model_name="calendar", + name="auto_clear_overlaps", + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name="calendar", + name="email_template", + field=models.ForeignKey( + blank=True, + help_text=" Email template to use for sending notifications about deleted events.
\n If not set, no email will be sent.", + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="calendars", + to="booking.emailtemplate", + ), + ), + ] From 07ddbb793ec311bb7624eb4f5e999830d6993064 Mon Sep 17 00:00:00 2001 From: Marc Koch Date: Wed, 25 Jun 2025 17:02:50 +0200 Subject: [PATCH 2/4] =?UTF-8?q?=F0=9F=94=A7=20configure=20application=20en?= =?UTF-8?q?vironment=20variables?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sets up environment variables within the docker-compose file, replacing the use of a .env file. This includes defining variables for Django secret key, allowed hosts, SMTP settings, and sender name. This change improves configuration management and clarifies the necessary environment settings. --- docker-compose.yaml | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/docker-compose.yaml b/docker-compose.yaml index 9af0575..3958406 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -1,7 +1,6 @@ --- services: app: - image: git.extrasolar.space/marc/room-booking.git:latest build: context: . dockerfile: Dockerfile @@ -9,7 +8,14 @@ services: - "8000:8000" volumes: - app-data:/data - env_file: .env + 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 volumes: app-data: \ No newline at end of file From 9a44afa6b9cfecda0e39c9225ac1593b1986c98d Mon Sep 17 00:00:00 2001 From: Marc Koch Date: Wed, 25 Jun 2025 17:05:18 +0200 Subject: [PATCH 3/4] =?UTF-8?q?=F0=9F=94=96bump=20version=20to=201.1.0-alp?= =?UTF-8?q?ha1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- version.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/version.txt b/version.txt index a6a3a43..ed60c12 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -1.0.4 \ No newline at end of file +1.1.0-alpha1 \ No newline at end of file From 67ec6ccf5ea2aee1fc240b5fb66f1768284a6c0c Mon Sep 17 00:00:00 2001 From: Marc Koch Date: Thu, 10 Jul 2025 19:58:12 +0200 Subject: [PATCH 4/4] =?UTF-8?q?=E2=9C=A8=20implement=20clearing=20of=20boo?= =?UTF-8?q?kings?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 121 ++++++++- src/booking.py | 69 +++-- src/clear_overlaps.py | 240 ++++++++++++++---- src/migrations/0003_event_created.py | 18 ++ src/migrations/0004_alter_event_created.py | 19 ++ src/migrations/0005_alter_event_created.py | 18 ++ ...r_overlaps_calendar_auto_clear_bookings.py | 18 ++ .../0007_alter_emailtemplate_body.py | 20 ++ .../0008_alter_emailtemplate_body.py | 20 ++ .../0009_alter_emailtemplate_body.py | 20 ++ version.txt | 2 +- 11 files changed, 493 insertions(+), 72 deletions(-) create mode 100644 src/migrations/0003_event_created.py create mode 100644 src/migrations/0004_alter_event_created.py create mode 100644 src/migrations/0005_alter_event_created.py create mode 100644 src/migrations/0006_rename_auto_clear_overlaps_calendar_auto_clear_bookings.py create mode 100644 src/migrations/0007_alter_emailtemplate_body.py create mode 100644 src/migrations/0008_alter_emailtemplate_body.py create mode 100644 src/migrations/0009_alter_emailtemplate_body.py diff --git a/README.md b/README.md index da92240..bc8b07f 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,12 @@ Room Booking --- -This application allows you to create an event in a room booking calendar via a -REST API. +This application allows you to + +1. create an event in a room booking calendar via a REST API. +2. delete an event in a room booking calendar via a REST API. +3. clear overlapping and canceled events in a room booking calendar via a REST + API. ## Setup @@ -57,4 +61,115 @@ curl -s -X DELETE \ localhost:8000/api/test-kalender/event/4zx3QAFzoxV3vaZSKGaH2S ``` -The response will be empty but the status code will be `204`. \ No newline at end of file +The response will be empty but the status code will be `204`. + +### Clear overlapping and canceled events + +To clear overlapping and canceled events, you need to send a DELETE request to +`/api/clear-bookings`. + +The following parameters can be passed as query parameters: + +- `calendar`: The calendar to clear overlapping events from. If not specified, + all calendars of the user will be cleared. +- `test`: If set to `1` or `ok`, the API will not delete any events but only + return what would be deleted. + +Curl example: +```bash +curl -s -X DELETE \ + -H "Authorization: Bearer secrettoken" \ + localhost:8000/api/clear-bookings?calendar=meeting-room-1&test=on" | jq "." +``` + +The response will contain a list of events that would be deleted: + +```json +{ + "deleted_cancelled_events": [], + "deleted_overlapping_events": [ + { + "event": { + "uid": "abeda082-2a4b-48a7-a92a-ba24575622ae", + "name": "Fundraising Weekly", + "start": "2025-07-14T00:00:00+02:00", + "end": "2025-07-19T00:00:00+02:00", + "created": "2025-07-09T15:11:19+02:00", + "organizer": "head-of-fundraising@my-awesome.org" + }, + "overlaps_with": { + "uid": "2051008d-6ce2-4489-b7d9-38d164c5e66e", + "name": "Finance Monthly", + "start": "2025-07-15T10:00:00+02:00", + "end": "2025-07-15T12:00:00+02:00", + "created": "2025-01-14T08:33:20+01:00", + "organizer": "head-of-finance@my-awesome.org" + }, + "calendar": "meeting-room-1" + } + ], + "cancelled_overlapping_recurring_events": [ + { + "event": { + "uid": "be6d2d42-513b-45ca-bb43-663e2a10a1d7", + "name": "Job Interviews", + "start": "2025-07-14T14:00:00+02:00", + "end": "2025-07-14T15:00:00+02:00", + "created": "2025-06-30T12:04:14+02:00", + "organizer": "head-of-hr@my-awesome.org" + }, + "overlaps_with": { + "uid": "f2b7b703-dfba-4ae7-a0e9-bf0e7637c7e4", + "name": "Workers Council Meeting", + "start": "2025-07-14T14:00:00+02:00", + "end": "2025-07-14T15:00:00+02:00", + "created": "2025-02-10T12:33:39+01:00", + "organizer": "workers-council@my-awesome.org" + }, + "calendar": "meeting-room-2" + } + ], + "ignored_overlapping_events": [], + "test_mode": true +} +``` + +#### Email Notifications + +The organizer of the events will be notified via email about the deletion of +their event if an email template is configured for the calendar. + +The following environment variables can be set to configure the application: + +- `SMTP_EMAIL` - The email address to use as the sender for notification emails. +- `SMTP_PASSWORD` - The password for the SMTP email account. +- `SMTP_SERVER` - The SMTP server to use for sending emails. +- `SMTP_USER_NAME` - The username for the SMTP email account. If not set, the + `SMTP_EMAIL` will be used as the username. +- `SMTP_SENDER_NAME` - The name to use as the sender for notification emails. +- `SMTP_BCC` - A comma-separated list of email addresses to BCC on notification + emails. +- `SMTP_STARTTLS` - Whether to use STARTTLS for the SMTP connection. Defaults to + `False`, so SSL is used instead. +- `SMTP_PORT` - The port to use for the SMTP connection. Defaults to `465` for + SSL and + `587` for STARTTLS. + +Email templates can be configured in the Django Admin interface. Jinja2 syntax +is used for the templates. The following variables are available in the +templates: + +- `event_name` - The name of the event +- `event_start` - The start time (datetime) of the event +- `event_end` - The end time (datetime) of the event +- `event_created` - The datetime the event was created +- `event_is_deleted` - Whether the event was deleted +- `event_is_cancelled` - Whether the event was cancelled +- `event_is_recurring` - Whether the event is recurring +- `event_organizer` - The organizer of the event +- `overlap_event_name` - The name of the overlap event +- `overlap_event_start` - The start time (datetime) of the overlap event +- `overlap_event_end` - The end time (datetime) of the overlap event +- `overlap_event_created` - The datetime the overlap event was created +- `overlap_event_organizer` - The organizer of the overlap event +- `calendar_name` - The name of the calendar \ No newline at end of file diff --git a/src/booking.py b/src/booking.py index 9f5b388..43ae964 100644 --- a/src/booking.py +++ b/src/booking.py @@ -30,12 +30,26 @@ if not SECRET_KEY and not DEBUG: print("DJANGO_SECRET_KEY is not set") exit(1) +ALLOWED_HOSTS = [host.strip() for host in + os.getenv("DJANGO_ALLOWED_HOSTS", "").split(",")] + +# Set CSRF_TRUSTED_ORIGINS to allow requests from the allowed hosts +CSRF_TRUSTED_ORIGINS = [ + host if host.startswith(("http://", "https://")) + else f"https://{host}" + for host in ALLOWED_HOSTS +] + +if DEBUG: + print(f"ALLOWED_HOSTS: {ALLOWED_HOSTS}") + print(f"CSRF_TRUSTED_ORIGINS: {CSRF_TRUSTED_ORIGINS}") + # Initialise nanodjango app = Django( SECRET_KEY=SECRET_KEY, TIME_ZONE=os.getenv("TIME_ZONE", "Europe/Berlin"), - ALLOWED_HOSTS=["localhost"] + [host for host in - os.getenv("DJANGO_ALLOWED_HOSTS", "").split(",")], + ALLOWED_HOSTS=["localhost"] + ALLOWED_HOSTS, + CSRF_TRUSTED_ORIGINS=CSRF_TRUSTED_ORIGINS, SQLITE_DATABASE=DATA_DIR / "db.sqlite3", DEBUG=DEBUG, TEMPLATES_DIR=BASE_DIR / "templates", @@ -50,7 +64,6 @@ from ninja import NinjaAPI, ModelSchema from ninja.security import HttpBearer, django_auth from ninja.errors import HttpError - @app.admin class EmailTemplate(models.Model): """ @@ -65,20 +78,23 @@ class EmailTemplate(models.Model): Jinja2 template syntax can be used in the subject and body.
Available variables:
- event_name: The name of the event
- - event_start: The start time of the event
- - event_end: The end time of the event
- - event_created: The time the event was created
+ - event_start: The start time (datetime) of the event
+ - event_end: The end time (datetime) of the event
+ - event_created: The datetime the event was created
+ - event_is_deleted: Whether the event was deleted
+ - event_is_cancelled: Whether the event was cancelled
+ - event_is_recurring: Whether the event is recurring
+ - event_organizer: The organizer of the event
- overlap_event_name: The name of the overlap event
- - overlap_event_start: The start time of the overlap event
- - overlap_event_end: The end time of the overlap event
- - overlap_event_created: The time the overlap event was created
+ - overlap_event_start: The start time (datetime) of the overlap event
+ - overlap_event_end: The end time (datetime) of the overlap event
+ - overlap_event_created: The datetime the overlap event was created
- overlap_event_organizer: The organizer of the overlap event
- calendar_name: The name of the calendar""") def __str__(self): return self.name - @app.admin class Calendar(models.Model): """ @@ -90,7 +106,7 @@ class Calendar(models.Model): calendar_password = models.CharField(max_length=29) users = models.ManyToManyField(app.settings.AUTH_USER_MODEL, related_name='calendars') - auto_clear_overlaps = models.BooleanField(default=False) + auto_clear_bookings = models.BooleanField(default=False) auto_clear_overlap_horizon_days = models.IntegerField(default=30) email_template = models.ForeignKey(EmailTemplate, on_delete=models.SET_NULL, null=True, blank=True, @@ -115,7 +131,7 @@ class Calendar(models.Model): return self.name -@app.admin(ordering=("start", "end", "name")) +@app.admin(ordering=("start", "end", "name"), list_filter=("cancelled",)) class Event(models.Model): """ Event model to store events in a calendar and send them to a CalDAV server. @@ -126,6 +142,7 @@ class Event(models.Model): related_name='events') name = models.CharField(max_length=100) path = models.CharField(max_length=200, editable=False) + created = models.DateTimeField() start = models.DateTimeField() end = models.DateTimeField() ics = models.TextField(blank=True) @@ -134,6 +151,8 @@ class Event(models.Model): def save(self, *args, **kwargs): self.path = f"{self.calendar.url}/{self.uuid}" + if self.created is None: + self.created = datetime.now(tz=timezone.get_current_timezone()) self.ics = self.create_ics() # Send the event to the CalDAV server if it has not been cancelled yet @@ -192,9 +211,10 @@ class Event(models.Model): e = ICS_Event() e.uid = self.uuid.__str__() e.name = self.name + e.created = self.created e.begin = self.start e.end = self.end - e.description = f"Meeting-ID: {self.id}" + e.description = f"Booking-ID: {self.id}" c.events.add(e) return c.serialize() @@ -321,38 +341,37 @@ def delete_event(request, calendar: str, event_id: str): event.cancel() return 204, None -@api.delete("/clear-overlaps", response={200: None, 204: None, 401: None, 404: None}) +@api.delete("/clear-bookings", response={200: dict, 204: None, 401: None, 404: None}) @csrf_exempt -def clear_overlaps(request, calendar: str = None): +def clear_bookings(request, calendar: str = None, test: bool = False): user = get_user(request) # Get optional calendar name from the request if calendar: cal = get_object_or_404(Calendar, name=calendar, - auto_clear_overlaps=True) + auto_clear_bookings=True) if user not in cal.users.all(): raise HttpError(401, - f"User not authorised to clear overlaps in calendar '{cal.name}'") + f"User not authorised to clear bookings in calendar '{cal.name}'") calendars = [cal] # If no calendar is specified, get all calendars for the user that have - # auto_clear_overlaps enabled + # auto_clear_bookings enabled else: - calendars = user.calendars.filter(auto_clear_overlaps=True) + calendars = user.calendars.filter(auto_clear_bookings=True) if not calendars: - return 200, "No calendars with auto_clear_overlaps enabled found for user." + return 204, None # No bookings to clear - if clear(list(calendars)): - return 204, "Overlaps cleared successfully." + result = clear(list(calendars), is_test=test) + if all(len(r) == 0 for r in result.values() if type(r) is list): + return 204, None # No bookings to clear else: - raise HttpError(200, "No overlaps to clear.") - + return 200, result app.route("api/", include=api.urls) - @app.route("/") @csrf_exempt def home(request): diff --git a/src/clear_overlaps.py b/src/clear_overlaps.py index 27ce14e..f345d94 100644 --- a/src/clear_overlaps.py +++ b/src/clear_overlaps.py @@ -6,8 +6,10 @@ from email.mime.multipart import MIMEMultipart from email.mime.text import MIMEText from typing import NamedTuple import pytz +from pprint import pprint import caldav +from caldav import CalendarObjectResource from jinja2 import Template tz = pytz.timezone(os.getenv("TIME_ZONE", "Europe/Berlin")) @@ -23,13 +25,22 @@ DavEvent = NamedTuple("DavEvent", [ ("obj", caldav.Event), ]) -def clear_overlaps(target_calendars: list) -> bool: +def clear_overlaps(target_calendars: list, is_test: bool=False) -> dict: """ Clear overlapping events in calendars. :param target_calendars: List of calendars to check for overlaps. - :return: True if overlaps were cleared, False otherwise. + :param is_test: Do not delete events, just print what would be deleted. + :return: Dictionary with results of the operation. """ - cleared = False + print(f"--- Clear bookings.{' Test mode enabled.' if is_test else ''}") + + results = { + "deleted_cancelled_events": [], + "deleted_overlapping_events": [], + "cancelled_overlapping_recurring_events": [], + "ignored_overlapping_events": [], + "test_mode": is_test, + } dav_client = caldav.DAVClient( url=target_calendars[0].url, @@ -47,32 +58,74 @@ def clear_overlaps(target_calendars: list) -> bool: if cal.id in tcal_by_name.keys()] if not calendars: - print("No calendars to clear overlaps in. Exiting.") - return False + print("--- No calendars to clear overlaps in. Exiting.") + return results for calendar in calendars: # Get events from calendar - print(f"Clearing overlaps in calendar: {calendar.id}") + print(f"--- Clearing cancelled bookings and overlaps in calendar: {calendar.id}") horizon = tcal_by_name[calendar.id].auto_clear_overlap_horizon_days events_fetched = calendar.search( start=datetime.now(), end=date.today() + timedelta(days=horizon), event=True, - expand=False, + expand=True, + #split_expanded=False, ) events = [] for event in events_fetched: for component in event.icalendar_instance.walk(): - if component.name == "VEVENT" and component.get("status") != "CANCELLED": + + # Delete cancelled non-recurring events if not in test mode + if (component.name == "VEVENT" + and is_cancelled_event(event) + and not is_recurring_event(event)): + + if not is_test: + event.delete() + result = { + "uid": component.get("uid"), + "name": component.get("summary", "No Name"), + "calendar": calendar.id, + } + results["deleted_cancelled_events"].append(result) + print("Deleted cancelled event:") + pprint(result) + print("------") + + # Create DavEvent objects for not cancelled events + elif component.name == "VEVENT" and not is_cancelled_event(event): + + # Skip events which miss required fields + required = { + "uid": component.get("uid", False), + "dtstart": component.get("dtstart", False), + "dtend": component.get("dtend", False), + "dtstamp": component.get("dtstamp", False), + } + if not all(required.values()): + result = { + "uid": component.get("uid"), + "name": component.get("summary", "No Name"), + "calendar": calendar.id, + "reason": f"Missing required fields: {', '.join([k for k, v in required.items() if v is False])}", + } + results["ignored_overlapping_events"].append(result) + print("Skipping event:") + pprint(result) + print("------") + continue + + # Create DavEvent object events.append( DavEvent( uid=component.get("uid"), name=component.get("summary", "No Name"), - start=component.get("dtstart").dt.astimezone(tz), - end=component.get("dtend").dt.astimezone(tz), - created=component.get("created").dt.astimezone(tz), + start=handle_date(component.get("dtstart").dt), + end=handle_date(component.get("dtend").dt), + created=component.get("dtstamp").dt.astimezone(tz), status=component.get("status", "CONFIRMED"), organizer=component.get("organizer", "").replace("mailto:", "").strip(), obj=event, @@ -86,16 +139,56 @@ def clear_overlaps(target_calendars: list) -> bool: for overlap in overlapping_events.values(): event = overlap["event"] overlaps_with = overlap["overlaps_with"] + is_deleted = False + is_canceled = False + result = { + "event": { + "uid": event.uid, + "name": event.name, + "start": event.start, + "end": event.end, + "created": event.created, + "organizer": event.organizer, + }, + "overlaps_with": { + "uid": overlaps_with.uid, + "name": overlaps_with.name, + "start": overlaps_with.start, + "end": overlaps_with.end, + "created": overlaps_with.created, + "organizer": overlaps_with.organizer, + }, + "calendar": calendar.id, + } try: - # Delete the event - event.obj.delete() - cleared = True + # If this is not a recurring event, we can delete it directly + if not is_recurring_event(event.obj): + # but only if we are not in test mode + if not is_test: + event.obj.delete() + is_deleted = True + results["deleted_overlapping_events"].append(result) + print("Deleted overlapping event:") + pprint(result) + print("------") - # Send email to organizer of the event + # If this is a recurring event, and not already canceled, + # we need to cancel it now + elif not is_cancelled_event(event.obj): + if not is_test: + event.obj.decline_invite() + is_canceled = True + results["cancelled_overlapping_recurring_events"].append(result) + print("Cancelled overlapping recurring event:") + pprint(result) + print("------") + + # Send email to organizer of the event if not in test mode email_template = tcal_by_name[calendar.id].email_template - if email_template: + if email_template and not is_test: try: send_mail_to_organizer(event, overlaps_with, calendar, + is_deleted, is_canceled, email_template) except Exception as e: print("Failed to send email to organizer for event " @@ -103,10 +196,11 @@ def clear_overlaps(target_calendars: list) -> bool: continue except Exception as e: print(f"Failed to delete event {event.uid}: {e}") + print("------") continue - return cleared - + print(f"--- Clearing completed.{' Test mode enabled.' if is_test else ''}") + return results def find_overlapping_events(events: list[DavEvent]) -> dict: """ @@ -119,51 +213,76 @@ def find_overlapping_events(events: list[DavEvent]) -> dict: overlapping_events = {} checked_events = {} - # Create a dictionary to make it easier to find an event by its UID - events = {e.uid: e for e in events} - # Order events by created time - events = dict(sorted(events.items(), key=lambda item: item[1].created)) + events = sorted(events, key=lambda item: item.created) # Define lambda functions to check for overlaps - event_starts_earlier = lambda e1, e2: e1.start <= e2.start <= e1.end - event_inbetween = lambda e1, e2: e2.start <= e1.start <= e1.end <= e2.end - event_ends_later = lambda e1, e2: e1.end >= e2.end >= e1.start + event_starts_later = lambda e1, e2: e1.end >= e2.end > e1.start + event_starts_earlier = lambda e1, e2: e1.end > e2.start >= e1.start + event_inbetween = lambda e1, e2: e2.start < e1.start < e1.end < e2.end # Find overlapping events - for event in events.values(): + for event in events: # Skip if the event is already in the overlapping events dictionary - if event.uid in overlapping_events.keys(): + if overlapping_events.get(create_event_identifier(event)): continue - for compare_event in events.values(): - # Skip if the events is: - # 1. The same event as the compare_event + for compare_event in events: + # Skip if the compare_events is: + # 1. The same event or belongs to the same series as the compare event # 2. Already checked # 3. Already in the overlapping events dictionary if event.uid == compare_event.uid \ - or compare_event.uid in checked_events.keys() \ - or compare_event.uid in overlapping_events.keys(): + or checked_events.get(create_event_identifier(compare_event)) \ + or overlapping_events.get(create_event_identifier(compare_event)): continue # Check if the events overlap - if event_starts_earlier(event, compare_event) or \ - event_inbetween(event, compare_event) or \ - event_ends_later(event, compare_event): + if event_starts_later(event, compare_event) or \ + event_starts_earlier(event, compare_event) or \ + event_inbetween(event, compare_event): # Add to overlapping events dictionary overlapping_events.update({ - compare_event.uid: {"event": compare_event, + create_event_identifier(compare_event): {"event": compare_event, "overlaps_with": event}}) # Add to checked events dictionary - checked_events.update({event.uid: event}) + checked_events.update({create_event_identifier(event): event}) return overlapping_events +def create_event_identifier(event: DavEvent) -> str: + """ + Create a unique identifier for the event. + This is necessary to distinguish between events of the same series, which + have the same UID but different start and end times. + :param event: DavEvent object + :return: Unique identifier string + """ + return f"{event.uid}_{int(event.start.timestamp())}_{int(event.end.timestamp())}" + +def is_recurring_event(event: CalendarObjectResource) -> bool: + """ + Check if the event is a recurring event. + :param event: CalendarObjectResource object representing the event. + :return: True if the event is recurring, False otherwise. + """ + return 'RECURRENCE-ID' in event.icalendar_component + +def is_cancelled_event(event: CalendarObjectResource) -> bool: + """ + Check if the event is a cancelled event. + :param event: CalendarObjectResource object representing the event. + :return: True if the event is cancelled, False otherwise. + """ + return event.icalendar_component.get("status") == "CANCELLED" + def send_mail_to_organizer( event: DavEvent, overlap_event: DavEvent, calendar: caldav.Calendar, + is_deleted: bool, + is_canceled: bool, email_template, ): """ @@ -171,6 +290,8 @@ def send_mail_to_organizer( :param event: Event that was declined :param overlap_event: Event that overlaps with the declined event :param calendar: Calendar to send the email from + :param is_deleted: True if the event was deleted, False otherwise + :param is_canceled: True if the event was canceled, False otherwise :param email_template: Email template to use for the email """ # Check if environment variables for SMTP are set @@ -189,6 +310,10 @@ def send_mail_to_organizer( "event_start": event.start, "event_end": event.end, "event_created": event.created, + "event_is_deleted": is_deleted, + "event_is_canceled": is_canceled, + "event_is_recurring": is_recurring_event(event.obj), + "event_organizer": event.organizer, "overlap_event_name": overlap_event.name, "overlap_event_start": overlap_event.start, "overlap_event_end": overlap_event.end, @@ -197,10 +322,15 @@ def send_mail_to_organizer( "calendar_name": calendar.get_display_name(), } + recipients = [recipient] + # Create the email message message = MIMEMultipart("alternative") message["From"] = f"{os.getenv('SMTP_SENDER_NAME', 'Room Booking')} <{os.getenv('SMTP_EMAIL')}>" message["To"] = recipient + if bcc := os.getenv("SMTP_BCC"): + message["Bcc"] = bcc + recipients += bcc.split(",") message["Date"] = datetime.now().astimezone(tz).strftime( "%d/%m/%Y %H:%M") # Try to render subject and body from the email template @@ -211,9 +341,33 @@ def send_mail_to_organizer( # Create a secure SSL context ssl_context = ssl.create_default_context() - # Send the email - with smtplib.SMTP_SSL("mail.extrasolar.space", - os.getenv('SMTP_PORT', 465), - context=ssl_context) as server: - server.login(os.getenv("SMTP_EMAIL"), os.getenv("SMTP_PASSWORD")) - server.sendmail(os.getenv("SMTP_EMAIL"), recipient, message.as_string()) + # Send the email with starttls or SSL based on environment variable + print(f"Sending email to {', '.join(recipients)} about event '{event.name}' " + f"overlap with '{overlap_event.name}'.") + if os.getenv('SMTP_STARTTLS', False): + print("Using STARTTLS for SMTP connection.") + with smtplib.SMTP(os.getenv('SMTP_SERVER'), + os.getenv('SMTP_PORT', 587)) as server: + server.starttls(context=ssl_context) + server.login(os.getenv("SMTP_USER_NAME", os.getenv("SMTP_EMAIL")), os.getenv("SMTP_PASSWORD")) + server.sendmail(os.getenv("SMTP_EMAIL"), recipients, message.as_string()) + else: + print("Using SSL for SMTP connection.") + with smtplib.SMTP_SSL("mail.extrasolar.space", + os.getenv('SMTP_PORT', 465), + context=ssl_context) as server: + server.login(os.getenv("SMTP_USER_NAME", os.getenv("SMTP_EMAIL")), os.getenv("SMTP_PASSWORD")) + server.sendmail(os.getenv("SMTP_EMAIL"), recipients, message.as_string()) + +def handle_date(date_value: datetime | date) -> datetime: + """ + Date objects will be converted to datetime objects in the specified timezone. + """ + if isinstance(date_value, datetime): + date_value = date_value.astimezone(tz) + elif isinstance(date_value, date): + date_value = tz.localize(datetime.combine(date_value, datetime.min.time())) + else: + raise ValueError(f"date_value must be a datetime or date object, {type(date_value)} given.") + + return date_value diff --git a/src/migrations/0003_event_created.py b/src/migrations/0003_event_created.py new file mode 100644 index 0000000..002ea1d --- /dev/null +++ b/src/migrations/0003_event_created.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1.4 on 2025-06-27 13:19 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("booking", "0002_emailtemplate_and_more"), + ] + + operations = [ + migrations.AddField( + model_name="event", + name="created", + field=models.DateTimeField(auto_now_add=True, null=True), + ), + ] diff --git a/src/migrations/0004_alter_event_created.py b/src/migrations/0004_alter_event_created.py new file mode 100644 index 0000000..711b7b9 --- /dev/null +++ b/src/migrations/0004_alter_event_created.py @@ -0,0 +1,19 @@ +# Generated by Django 5.1.4 on 2025-06-27 16:11 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("booking", "0003_event_created"), + ] + + operations = [ + migrations.AlterField( + model_name="event", + name="created", + field=models.DateTimeField(auto_now_add=True, default=None), + preserve_default=False, + ), + ] diff --git a/src/migrations/0005_alter_event_created.py b/src/migrations/0005_alter_event_created.py new file mode 100644 index 0000000..7313a59 --- /dev/null +++ b/src/migrations/0005_alter_event_created.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1.4 on 2025-06-27 16:14 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("booking", "0004_alter_event_created"), + ] + + operations = [ + migrations.AlterField( + model_name="event", + name="created", + field=models.DateTimeField(), + ), + ] diff --git a/src/migrations/0006_rename_auto_clear_overlaps_calendar_auto_clear_bookings.py b/src/migrations/0006_rename_auto_clear_overlaps_calendar_auto_clear_bookings.py new file mode 100644 index 0000000..18f310e --- /dev/null +++ b/src/migrations/0006_rename_auto_clear_overlaps_calendar_auto_clear_bookings.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1.4 on 2025-06-30 16:08 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("booking", "0005_alter_event_created"), + ] + + operations = [ + migrations.RenameField( + model_name="calendar", + old_name="auto_clear_overlaps", + new_name="auto_clear_bookings", + ), + ] diff --git a/src/migrations/0007_alter_emailtemplate_body.py b/src/migrations/0007_alter_emailtemplate_body.py new file mode 100644 index 0000000..6d3ae73 --- /dev/null +++ b/src/migrations/0007_alter_emailtemplate_body.py @@ -0,0 +1,20 @@ +# Generated by Django 5.1.4 on 2025-07-10 16:19 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("booking", "0006_rename_auto_clear_overlaps_calendar_auto_clear_bookings"), + ] + + operations = [ + migrations.AlterField( + model_name="emailtemplate", + name="body", + field=models.TextField( + help_text=" Email template to use for sending notifications about deleted events.
\n If not set, no email will be sent.
\n Jinja2 template syntax can be used in the subject and body.
\n Available variables:
\n - event_name: The name of the event
\n - event_start: The start time of the event
\n - event_end: The end time of the event
\n - event_created: The time the event was created
\n - event_is_deleted: Whether the event was deleted
\n - event_is_cancelled: Whether the event was cancelled
\n - overlap_event_name: The name of the overlap event
\n - overlap_event_start: The start time of the overlap event
\n - overlap_event_end: The end time of the overlap event
\n - overlap_event_created: The time the overlap event was created
\n - overlap_event_organizer: The organizer of the overlap event
\n - calendar_name: The name of the calendar" + ), + ), + ] diff --git a/src/migrations/0008_alter_emailtemplate_body.py b/src/migrations/0008_alter_emailtemplate_body.py new file mode 100644 index 0000000..6ff66e5 --- /dev/null +++ b/src/migrations/0008_alter_emailtemplate_body.py @@ -0,0 +1,20 @@ +# Generated by Django 5.1.4 on 2025-07-10 16:24 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("booking", "0007_alter_emailtemplate_body"), + ] + + operations = [ + migrations.AlterField( + model_name="emailtemplate", + name="body", + field=models.TextField( + help_text=" Email template to use for sending notifications about deleted events.
\n If not set, no email will be sent.
\n Jinja2 template syntax can be used in the subject and body.
\n Available variables:
\n - event_name: The name of the event
\n - event_start: The start time of the event
\n - event_end: The end time of the event
\n - event_created: The time the event was created
\n - event_is_deleted: Whether the event was deleted
\n - event_is_cancelled: Whether the event was cancelled
\n - event_is_recurring: Whether the event is recurring
\n - event_organizer: The organizer of the event
\n - overlap_event_name: The name of the overlap event
\n - overlap_event_start: The start time of the overlap event
\n - overlap_event_end: The end time of the overlap event
\n - overlap_event_created: The time the overlap event was created
\n - overlap_event_organizer: The organizer of the overlap event
\n - calendar_name: The name of the calendar" + ), + ), + ] diff --git a/src/migrations/0009_alter_emailtemplate_body.py b/src/migrations/0009_alter_emailtemplate_body.py new file mode 100644 index 0000000..e52c686 --- /dev/null +++ b/src/migrations/0009_alter_emailtemplate_body.py @@ -0,0 +1,20 @@ +# Generated by Django 5.1.4 on 2025-07-10 17:21 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("booking", "0008_alter_emailtemplate_body"), + ] + + operations = [ + migrations.AlterField( + model_name="emailtemplate", + name="body", + field=models.TextField( + help_text=" Email template to use for sending notifications about deleted events.
\n If not set, no email will be sent.
\n Jinja2 template syntax can be used in the subject and body.
\n Available variables:
\n - event_name: The name of the event
\n - event_start: The start time (datetime) of the event
\n - event_end: The end time (datetime) of the event
\n - event_created: The datetime the event was created
\n - event_is_deleted: Whether the event was deleted
\n - event_is_cancelled: Whether the event was cancelled
\n - event_is_recurring: Whether the event is recurring
\n - event_organizer: The organizer of the event
\n - overlap_event_name: The name of the overlap event
\n - overlap_event_start: The start time (datetime) of the overlap event
\n - overlap_event_end: The end time (datetime) of the overlap event
\n - overlap_event_created: The datetime the overlap event was created
\n - overlap_event_organizer: The organizer of the overlap event
\n - calendar_name: The name of the calendar" + ), + ), + ] diff --git a/version.txt b/version.txt index ed60c12..1cc5f65 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -1.1.0-alpha1 \ No newline at end of file +1.1.0 \ No newline at end of file