From d78f69abe9537e35201916b395b19204bf40ec05 Mon Sep 17 00:00:00 2001 From: Kevin Alberts Date: Wed, 11 Nov 2020 16:31:11 +0100 Subject: [PATCH] Initial commit --- .gitignore | 221 ++++++++++++++++++ README.md | 102 ++++++++ davinci/__init__.py | 0 davinci/asgi.py | 16 ++ davinci/caldav/__init__.py | 0 davinci/caldav/admin.py | 45 ++++ davinci/caldav/apps.py | 5 + davinci/caldav/forms.py | 60 +++++ davinci/caldav/migrations/0001_initial.py | 44 ++++ davinci/caldav/migrations/__init__.py | 0 davinci/caldav/models.py | 117 ++++++++++ .../templates/forms/calendar_wizard.html | 55 +++++ davinci/caldav/tests.py | 3 + davinci/caldav/views.py | 3 + davinci/icalendar/__init__.py | 0 davinci/icalendar/admin.py | 9 + davinci/icalendar/apps.py | 5 + davinci/icalendar/forms.py | 13 ++ davinci/icalendar/ical_utils.py | 13 ++ davinci/icalendar/management/__init__.py | 0 .../icalendar/management/commands/__init__.py | 0 .../management/commands/ical_sync.py | 33 +++ davinci/icalendar/migrations/0001_initial.py | 34 +++ davinci/icalendar/migrations/__init__.py | 0 davinci/icalendar/models.py | 29 +++ davinci/icalendar/tests.py | 3 + davinci/icalendar/views.py | 3 + davinci/local.py.default.py | 4 + davinci/settings.py | 131 +++++++++++ davinci/urls.py | 23 ++ davinci/wsgi.py | 16 ++ manage.py | 22 ++ requirements.txt | 6 + 33 files changed, 1015 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 davinci/__init__.py create mode 100644 davinci/asgi.py create mode 100644 davinci/caldav/__init__.py create mode 100644 davinci/caldav/admin.py create mode 100644 davinci/caldav/apps.py create mode 100644 davinci/caldav/forms.py create mode 100644 davinci/caldav/migrations/0001_initial.py create mode 100644 davinci/caldav/migrations/__init__.py create mode 100644 davinci/caldav/models.py create mode 100644 davinci/caldav/templates/forms/calendar_wizard.html create mode 100644 davinci/caldav/tests.py create mode 100644 davinci/caldav/views.py create mode 100644 davinci/icalendar/__init__.py create mode 100644 davinci/icalendar/admin.py create mode 100644 davinci/icalendar/apps.py create mode 100644 davinci/icalendar/forms.py create mode 100644 davinci/icalendar/ical_utils.py create mode 100644 davinci/icalendar/management/__init__.py create mode 100644 davinci/icalendar/management/commands/__init__.py create mode 100644 davinci/icalendar/management/commands/ical_sync.py create mode 100644 davinci/icalendar/migrations/0001_initial.py create mode 100644 davinci/icalendar/migrations/__init__.py create mode 100644 davinci/icalendar/models.py create mode 100644 davinci/icalendar/tests.py create mode 100644 davinci/icalendar/views.py create mode 100644 davinci/local.py.default.py create mode 100644 davinci/settings.py create mode 100644 davinci/urls.py create mode 100644 davinci/wsgi.py create mode 100755 manage.py create mode 100644 requirements.txt diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..75efcf1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,221 @@ +# Created by .ignore support plugin (hsz.mobi) +### Python template +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +### JetBrains template +# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider +# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 + +# User-specific stuff +.idea/**/workspace.xml +.idea/**/tasks.xml +.idea/**/usage.statistics.xml +.idea/**/dictionaries +.idea/**/shelf + +# Generated files +.idea/**/contentModel.xml + +# Sensitive or high-churn files +.idea/**/dataSources/ +.idea/**/dataSources.ids +.idea/**/dataSources.local.xml +.idea/**/sqlDataSources.xml +.idea/**/dynamic.xml +.idea/**/uiDesigner.xml +.idea/**/dbnavigator.xml + +# Gradle +.idea/**/gradle.xml +.idea/**/libraries + +# Gradle and Maven with auto-import +# When using Gradle or Maven with auto-import, you should exclude module files, +# since they will be recreated, and may cause churn. Uncomment if using +# auto-import. +# .idea/artifacts +# .idea/compiler.xml +# .idea/jarRepositories.xml +# .idea/modules.xml +# .idea/*.iml +# .idea/modules +# *.iml +# *.ipr + +# CMake +cmake-build-*/ + +# Mongo Explorer plugin +.idea/**/mongoSettings.xml + +# File-based project format +*.iws + +# IntelliJ +out/ + +# mpeltonen/sbt-idea plugin +.idea_modules/ + +# JIRA plugin +atlassian-ide-plugin.xml + +# Cursive Clojure plugin +.idea/replstate.xml + +# Crashlytics plugin (for Android Studio and IntelliJ) +com_crashlytics_export_strings.xml +crashlytics.properties +crashlytics-build.properties +fabric.properties + +# Editor-based Rest Client +.idea/httpRequests + +# Android studio 3.1+ serialized cache file +.idea/caches/build_file_checksums.ser + +.idea/ + +venv/ + +davinci/local.py + +*.sqlite3 diff --git a/README.md b/README.md new file mode 100644 index 0000000..6ded88f --- /dev/null +++ b/README.md @@ -0,0 +1,102 @@ +# DAVinci +DAVinci is a Django application that can keep remote iCalendar files (.ics) synchronized with a CalDAV calendar. It allows for multiple remote ICS files being synchronized to multiple calendars. + +## Requirements +- Python 3.8 +- Django 3.1 + +## Installing +- Clone the repository + +```bash +git clone https://git.kurocon.nl/Kurocon/DAVinci.git +cd DAVinci +``` +- Create a virtualenv + +```bash +virtualenv -p /usr/bin/python3 venv +source ./davinci/bin/activate +``` +- Install requirements + +```bash +pip instal -r requirements.txt +``` +- Setup local settings + +```bash +cp davinci/local.py.default davinci/local.py +vim davinci/local.py + +``` +- Run migrations + +```bash +python manage.py migrate +``` +- Create admin account + +```bash +python manage.py createsuperuser +``` + +Then either run it locally using `python manage.py runserver 0.0.0.0:8000`, or deploy it on a webserver, +for example using [Daphne](https://docs.djangoproject.com/en/3.1/howto/deployment/asgi/daphne/). + +When deploying, be sure to set the `STATIC_ROOT` setting, run `python manage.py collectstatic` and +serve the static files directory under `/static` on the web server. + +### Configuring auto-sync +To enable the synchronisation, please configure the following command to be executed every minute (to allow update intervals down to 1 minute). +You can run the command every hour as well, but then the minimum update interval will be 1 hour. + +```bash +python manage.py ical_sync +``` + +The configuration can be done for example by using cron: + +``` +# Run ical sync every minute +* * * * * /path/to/python /path/to/manage.py ical_sync +``` + +Or via a SystemD timer: + +davinci_ical_syc.service + +``` +[Unit] +Description=Synchronises configured DAVinci iCalendars with CalDAV +Wants=davinci_ical_sync.timer + +[Service] +Type=oneshot +ExecStart=/path/to/python /path/to/manage.py ical_sync + +[Install] +WantedBy=multi-user.target +``` + +davinci_ical_syc.service + +``` +[Unit] +Description=Run the DAVinci iCal sync service every minute +Requires=davinci_ical_syc.service + +[Timer] +Unit=davinci_ical_syc.service +OnBootSec=5min +OnCalendar=*:0/1 + +[Install] +WantedBy=timers.target +``` + +## Usage +Visit `/admin` and login with your created admin account to manage the synchronisations. + +First, add a CalDAV server. Then, add the CalDAV Calendars that you want to sync to, and lastly, create iCalSync objects for each remote `.ics` file. + diff --git a/davinci/__init__.py b/davinci/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/davinci/asgi.py b/davinci/asgi.py new file mode 100644 index 0000000..0ec3cb1 --- /dev/null +++ b/davinci/asgi.py @@ -0,0 +1,16 @@ +""" +ASGI config for davinci project. + +It exposes the ASGI callable as a module-level variable named ``application``. + +For more information on this file, see +https://docs.djangoproject.com/en/3.1/howto/deployment/asgi/ +""" + +import os + +from django.core.asgi import get_asgi_application + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'davinci.settings') + +application = get_asgi_application() diff --git a/davinci/caldav/__init__.py b/davinci/caldav/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/davinci/caldav/admin.py b/davinci/caldav/admin.py new file mode 100644 index 0000000..6644fc6 --- /dev/null +++ b/davinci/caldav/admin.py @@ -0,0 +1,45 @@ +from functools import update_wrapper + +from django.conf.urls import url +from django.contrib import admin + +from davinci.caldav.forms import CalDAVServerForm, CalDAVCalendarWizard, SelectCalDAVServerForm, \ + SelectCalDAVCalendarForm +from davinci.caldav.models import CalDAVServer, CalDAVCalendar + + +class CalDAVServerAdmin(admin.ModelAdmin): + form = CalDAVServerForm + list_display = ['name', 'url', 'username', 'ssl_verify'] + + +class CalDAVCalendarAdmin(admin.ModelAdmin): + list_display = ['name', 'server', 'calendar_url'] + + def get_urls(self): + urls = super(CalDAVCalendarAdmin, self).get_urls() + opts = self.model._meta + context = { + 'title': f'Add {opts.verbose_name}', + 'current_app': self.admin_site.name, + 'add': True, + 'opts': opts, + 'app_label': opts.app_label, + } + view = CalDAVCalendarWizard.as_view( + extra_context=context, + form_list=[SelectCalDAVServerForm, SelectCalDAVCalendarForm] + ) + + def wrap(view): + def wrapper(*args, **kwargs): + kwargs['admin'] = self + return self.admin_site.admin_view(view)(*args, **kwargs) + return update_wrapper(wrapper, view) + + add_url = url(r"add/$", wrap(view), name="formwizard_add") + change_url = url(r"^(.+)/change/$", wrap(view), name="formwizard_change") + return [add_url, change_url] + urls + +admin.site.register(CalDAVServer, CalDAVServerAdmin) +admin.site.register(CalDAVCalendar, CalDAVCalendarAdmin) diff --git a/davinci/caldav/apps.py b/davinci/caldav/apps.py new file mode 100644 index 0000000..fe9d1e3 --- /dev/null +++ b/davinci/caldav/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class CaldavConfig(AppConfig): + name = 'caldav' diff --git a/davinci/caldav/forms.py b/davinci/caldav/forms.py new file mode 100644 index 0000000..bfa8103 --- /dev/null +++ b/davinci/caldav/forms.py @@ -0,0 +1,60 @@ +from typing import List + +import caldav +from django import forms +from formtools.wizard.views import SessionWizardView + +from davinci.caldav.models import CalDAVServer, CalDAVCalendar + + +class CalDAVServerForm(forms.ModelForm): + class Meta: + model = CalDAVServer + widgets = { + 'password': forms.PasswordInput + } + fields = "__all__" + + +class SelectCalDAVServerForm(forms.ModelForm): + class Meta: + model = CalDAVCalendar + fields = ['server'] + + +class SelectCalDAVCalendarForm(forms.ModelForm): + class Meta: + model = CalDAVCalendar + fields = ['calendar_url'] + + +class CalDAVCalendarWizard(SessionWizardView): + template_name = "forms/calendar_wizard.html" + + def dispatch(self, request, *args, admin=None, **kwargs): + self._model_admin = admin + return super(CalDAVCalendarWizard, self).dispatch(request, *args, **kwargs) + + def get_form(self, step=None, data=None, files=None): + form = super(CalDAVCalendarWizard, self).get_form(step, data, files) + if step is None: + step = self.steps.current + + # Add choices data to calendar ID field on step 1 + if step == '1': + d = self.get_cleaned_data_for_step('0') + cals: List[caldav.Calendar] = d['server'].get_calendars() + existing_cals = [x.calendar_url for x in CalDAVCalendar.objects.filter(server=d['server'])] + cals = [x for x in cals if x.url not in existing_cals] + form.fields['calendar_url'] = forms.ChoiceField(choices=[ + (x.url, x.name) for x in cals + ]) + + return form + + def done(self, form_list, form_dict, **kwargs): + server = form_dict['0'].cleaned_data['server'] + url = form_dict['1'].cleaned_data['calendar_url'] + calendar = CalDAVCalendar.objects.create(server=server, calendar_url=url, + name=server.get_calendar_name(url)) + return self._model_admin.response_add(self.request, calendar) diff --git a/davinci/caldav/migrations/0001_initial.py b/davinci/caldav/migrations/0001_initial.py new file mode 100644 index 0000000..6ce648c --- /dev/null +++ b/davinci/caldav/migrations/0001_initial.py @@ -0,0 +1,44 @@ +# Generated by Django 3.1.3 on 2020-11-11 15:52 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='CalDAVServer', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=191, unique=True, verbose_name='Server name')), + ('url', models.URLField(verbose_name='CalDAV server URL')), + ('username', models.CharField(max_length=191, verbose_name='CalDAV username')), + ('password', models.CharField(max_length=191, verbose_name='CalDAV password')), + ('ssl_verify', models.BooleanField(default=True, verbose_name='Verify SSL')), + ], + options={ + 'verbose_name': 'CalDAV Server', + 'verbose_name_plural': 'CalDAV Servers', + }, + ), + migrations.CreateModel( + name='CalDAVCalendar', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('calendar_url', models.CharField(max_length=191, verbose_name='Calendar URL')), + ('name', models.CharField(max_length=191, verbose_name='Calendar Name')), + ('server', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='caldav.caldavserver', verbose_name='CalDAV server')), + ], + options={ + 'verbose_name': 'CalDAV Calendar', + 'verbose_name_plural': 'CalDAV Calendars', + 'unique_together': {('server', 'calendar_url')}, + }, + ), + ] diff --git a/davinci/caldav/migrations/__init__.py b/davinci/caldav/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/davinci/caldav/models.py b/davinci/caldav/models.py new file mode 100644 index 0000000..f19a79e --- /dev/null +++ b/davinci/caldav/models.py @@ -0,0 +1,117 @@ +from typing import List, Tuple, Optional + +import caldav +from caldav import Calendar, vcal +from django.db import models + +# Create your models here. +from ics import Event + + +class CalDAVServer(models.Model): + name = models.CharField(max_length=191, verbose_name="Server name", unique=True) + url = models.URLField(verbose_name="CalDAV server URL") + username = models.CharField(max_length=191, verbose_name="CalDAV username") + password = models.CharField(max_length=191, verbose_name="CalDAV password") + ssl_verify = models.BooleanField(verbose_name="Verify SSL", default=True) + + class Meta: + verbose_name = "CalDAV Server" + verbose_name_plural = "CalDAV Servers" + + def __str__(self): + return self.name + + def get_client(self): + return caldav.DAVClient(url=self.url, username=self.username, password=self.password, + ssl_verify_cert=self.ssl_verify) + + def test_connection(self) -> Tuple[bool, str]: + try: + self.get_client().principal().calendars() + return True, "" + except Exception as e: + return False, str(e) + + def get_calendars(self) -> List[caldav.Calendar]: + return self.get_client().principal().calendars() + + def get_calendar_name(self, url): + cals = self.get_client().principal().calendars() + for cal in cals: + if cal.url == url: + return cal.name + return None + + +class CalDAVCalendar(models.Model): + server = models.ForeignKey(to='CalDAVServer', on_delete=models.CASCADE, verbose_name="CalDAV server") + calendar_url = models.CharField(max_length=191, verbose_name="Calendar URL") + name = models.CharField(max_length=191, verbose_name="Calendar Name") + + class Meta: + verbose_name = "CalDAV Calendar" + verbose_name_plural = "CalDAV Calendars" + unique_together = ['server', 'calendar_url'] + + def __str__(self): + return f"{self.name} on {self.server}" + + def get_calendar(self) -> Optional[Calendar]: + cals = self.server.get_client().principal().calendars() + for cal in cals: + if cal.url == self.calendar_url: + return cal + return None + + def upload_events(self, events: List[Event], purge: bool = True) -> Tuple[int, int, int]: + """ + Upload events to this calendar. If purge is True, all old events will be removed. + :param events: The events to upload + :type events: List[Event] + :param purge: If old events in the calendar should be purged or not. + :type purge: bool + :return Amount of events added, updated, and deleted + :rtype Tuple[int, int, int] + """ + calendar = self.get_calendar() + if calendar is None: + raise ValueError(f"No calendar found on url '{self.calendar_url}'") + + # Keep track of which events were already in the calendar + purge_uids = [] + if purge: + purge_uids = [e.instance.vevent.uid.value for e in calendar.events()] + + updated = 0 + added = 0 + + # Import events + for event in events: + ev = f"BEGIN:VCALENDAR\n" \ + f"VERSION:2.0\n" \ + f"CALSCALE:GREGORIAN\n" \ + f"PRODID:DAVinci.ical_utils\n" \ + f"{event}\n" \ + f"END:VCALENDAR" + + if event.uid in purge_uids: + # Need to overwrite + cevent = calendar.event_by_uid(event.uid) + cevent.data = vcal.fix(ev) + cevent.save() + + purge_uids.remove(event.uid) + updated += 1 + + + else: + calendar.save_event(ev) + added += 1 + + # Purge old events + deleted = len(purge_uids) + for uid in purge_uids: + calendar.event_by_uid(uid).delete() + + return added, updated, deleted diff --git a/davinci/caldav/templates/forms/calendar_wizard.html b/davinci/caldav/templates/forms/calendar_wizard.html new file mode 100644 index 0000000..2c491a1 --- /dev/null +++ b/davinci/caldav/templates/forms/calendar_wizard.html @@ -0,0 +1,55 @@ +{% extends "admin/change_form.html" %} +{% load i18n %} + +{% block content %} +
+
+
+ {% if form.form.errors %} +

+ {% blocktrans count form.form.errors|length as counter %}Please correct the error below.{% plural %}Please correct the errors below.{% endblocktrans %} +

+
    + {% for error in form.form.non_field_errors %} +
  • {{ error }}
  • {% endfor %} +
+ {% endif %} + + {% csrf_token %} + {{ wizard.management_form }} + {{ wizard.form }} + +
+ {% if wizard.steps.prev %} + + {% endif %} + +
+ + +
+
+
+{% endblock %} + +{% block extrastyle %} + {{ block.super }} + +{% endblock %} \ No newline at end of file diff --git a/davinci/caldav/tests.py b/davinci/caldav/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/davinci/caldav/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/davinci/caldav/views.py b/davinci/caldav/views.py new file mode 100644 index 0000000..91ea44a --- /dev/null +++ b/davinci/caldav/views.py @@ -0,0 +1,3 @@ +from django.shortcuts import render + +# Create your views here. diff --git a/davinci/icalendar/__init__.py b/davinci/icalendar/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/davinci/icalendar/admin.py b/davinci/icalendar/admin.py new file mode 100644 index 0000000..49cad35 --- /dev/null +++ b/davinci/icalendar/admin.py @@ -0,0 +1,9 @@ +from django.contrib import admin + +from davinci.icalendar.forms import ICalSyncForm +from davinci.icalendar.models import ICalSync + +class ICalSyncAdmin(admin.ModelAdmin): + form = ICalSyncForm + +admin.site.register(ICalSync, ICalSyncAdmin) diff --git a/davinci/icalendar/apps.py b/davinci/icalendar/apps.py new file mode 100644 index 0000000..5d18c15 --- /dev/null +++ b/davinci/icalendar/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class IcalendarConfig(AppConfig): + name = 'DAVinci.icalendar' diff --git a/davinci/icalendar/forms.py b/davinci/icalendar/forms.py new file mode 100644 index 0000000..a335616 --- /dev/null +++ b/davinci/icalendar/forms.py @@ -0,0 +1,13 @@ +from django import forms + +from davinci.icalendar.models import ICalSync + +from durationwidget.widgets import TimeDurationWidget + +class ICalSyncForm(forms.ModelForm): + class Meta: + model = ICalSync + widgets = { + 'sync_interval': TimeDurationWidget(show_seconds=False) + } + fields = "__all__" diff --git a/davinci/icalendar/ical_utils.py b/davinci/icalendar/ical_utils.py new file mode 100644 index 0000000..25cc53c --- /dev/null +++ b/davinci/icalendar/ical_utils.py @@ -0,0 +1,13 @@ +from urllib.request import urlopen + +import ics + + +def get_ical_from_url(ical_url): + with urlopen(ical_url) as f: + return f.read() + + +def split_events(data): + c = ics.Calendar(data.decode('utf-8')) + return c.events diff --git a/davinci/icalendar/management/__init__.py b/davinci/icalendar/management/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/davinci/icalendar/management/commands/__init__.py b/davinci/icalendar/management/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/davinci/icalendar/management/commands/ical_sync.py b/davinci/icalendar/management/commands/ical_sync.py new file mode 100644 index 0000000..527bdd1 --- /dev/null +++ b/davinci/icalendar/management/commands/ical_sync.py @@ -0,0 +1,33 @@ +import logging + +from django.core.management.base import BaseCommand +from django.utils import timezone + +from davinci.icalendar.models import ICalSync + + +class Command(BaseCommand): + help = 'Sychronize iCal syncs that need to be synchronized.' + + def handle(self, *args, **options): + logger = logging.getLogger("davinci.icalendar.ical_sync") + logger.debug(f"Command `ical_sync` invoked") + self.stdout.write(f"Command `ical_sync` invoked") + + for sync in ICalSync.objects.filter(active=True): + sync: ICalSync + if sync.last_sync is None or timezone.now() > (sync.last_sync + sync.sync_interval): + logger.debug(f"Synchronizing {sync.name}...") + self.stdout.write(f"Synchronizing {sync.name}...") + + events = sync.get_events() + added, existing, deleted = sync.target.upload_events(events, purge=sync.purge) + + sync.last_sync = timezone.now() + sync.save() + msg = f"Sync for {sync.name} done! {added} events added, {existing} updated, and {deleted} purged." + logger.info(msg) + self.stdout.write(msg) + + self.stdout.write("iCal sync complete!") + logger.debug("iCal sync complete!") diff --git a/davinci/icalendar/migrations/0001_initial.py b/davinci/icalendar/migrations/0001_initial.py new file mode 100644 index 0000000..ab5cc34 --- /dev/null +++ b/davinci/icalendar/migrations/0001_initial.py @@ -0,0 +1,34 @@ +# Generated by Django 3.1.3 on 2020-11-11 15:52 + +import datetime +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('caldav', '0001_initial'), + ] + + operations = [ + migrations.CreateModel( + name='ICalSync', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=191, verbose_name='Sync Name')), + ('ical_url', models.URLField(verbose_name='iCal URL')), + ('purge', models.BooleanField(default=False, help_text='Do not use if you are importing multiple ICS files into the same calendar.', verbose_name='Purge calendar (remove old events)')), + ('sync_interval', models.DurationField(default=datetime.timedelta(seconds=3600), verbose_name='Sync interval')), + ('last_sync', models.DateTimeField(blank=True, null=True, verbose_name='Last synchronised')), + ('active', models.BooleanField(default=True, verbose_name='Sync active')), + ('target', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='caldav.caldavcalendar', verbose_name='Target calendar')), + ], + options={ + 'verbose_name': 'iCal Sync', + 'verbose_name_plural': 'iCal Syncs', + }, + ), + ] diff --git a/davinci/icalendar/migrations/__init__.py b/davinci/icalendar/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/davinci/icalendar/models.py b/davinci/icalendar/models.py new file mode 100644 index 0000000..18ae416 --- /dev/null +++ b/davinci/icalendar/models.py @@ -0,0 +1,29 @@ +from datetime import timedelta + +import humanize +from django.db import models + +from davinci.caldav.models import CalDAVCalendar +from davinci.icalendar import ical_utils + + +class ICalSync(models.Model): + name = models.CharField(max_length=191, verbose_name="Sync Name") + ical_url = models.URLField(verbose_name="iCal URL") + target = models.ForeignKey(to=CalDAVCalendar, on_delete=models.CASCADE, verbose_name="Target calendar") + purge = models.BooleanField(verbose_name="Purge calendar (remove old events)", default=False, + help_text="Do not use if you are importing multiple ICS files into the same calendar.") + sync_interval = models.DurationField(verbose_name="Sync interval", default=timedelta(hours=1)) + last_sync = models.DateTimeField(verbose_name="Last synchronised", blank=True, null=True) + active = models.BooleanField(verbose_name="Sync active", default=True) + + class Meta: + verbose_name = "iCal Sync" + verbose_name_plural = "iCal Syncs" + + def __str__(self): + return f'{self.name} to {self.target} every {humanize.precisedelta(self.sync_interval)}' + + def get_events(self): + data = ical_utils.get_ical_from_url(self.ical_url) + return ical_utils.split_events(data) diff --git a/davinci/icalendar/tests.py b/davinci/icalendar/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/davinci/icalendar/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/davinci/icalendar/views.py b/davinci/icalendar/views.py new file mode 100644 index 0000000..91ea44a --- /dev/null +++ b/davinci/icalendar/views.py @@ -0,0 +1,3 @@ +from django.shortcuts import render + +# Create your views here. diff --git a/davinci/local.py.default.py b/davinci/local.py.default.py new file mode 100644 index 0000000..ec4b9ea --- /dev/null +++ b/davinci/local.py.default.py @@ -0,0 +1,4 @@ +SECRET_KEY = "" +DEBUG = False +ALLOWED_HOSTS = [] +STATIC_ROOT = "static/" diff --git a/davinci/settings.py b/davinci/settings.py new file mode 100644 index 0000000..5ea7ca2 --- /dev/null +++ b/davinci/settings.py @@ -0,0 +1,131 @@ +""" +Django settings for davinci project. + +Generated by 'django-admin startproject' using Django 3.1.3. + +For more information on this file, see +https://docs.djangoproject.com/en/3.1/topics/settings/ + +For the full list of settings and their values, see +https://docs.djangoproject.com/en/3.1/ref/settings/ +""" +import sys +from pathlib import Path + +# Build paths inside the project like this: BASE_DIR / 'subdir'. +BASE_DIR = Path(__file__).resolve().parent.parent + + +# Quick-start development settings - unsuitable for production +# See https://docs.djangoproject.com/en/3.1/howto/deployment/checklist/ + +# SECURITY WARNING: keep the secret key used in production secret! +SECRET_KEY = '3&g*9)y2dz*i*s)3xa-u_y58&j&atvs6t+fvn!j-ke8-3m0i0x' + +# SECURITY WARNING: don't run with debug turned on in production! +DEBUG = False + +ALLOWED_HOSTS = [] + + +# Application definition + +INSTALLED_APPS = [ + 'django.contrib.admin', + 'django.contrib.auth', + 'django.contrib.contenttypes', + 'django.contrib.sessions', + 'django.contrib.messages', + 'django.contrib.staticfiles', + + 'formtools', + 'durationwidget', + + 'davinci.caldav', + 'davinci.icalendar', +] + +MIDDLEWARE = [ + 'django.middleware.security.SecurityMiddleware', + 'django.contrib.sessions.middleware.SessionMiddleware', + 'django.middleware.common.CommonMiddleware', + 'django.middleware.csrf.CsrfViewMiddleware', + 'django.contrib.auth.middleware.AuthenticationMiddleware', + 'django.contrib.messages.middleware.MessageMiddleware', + 'django.middleware.clickjacking.XFrameOptionsMiddleware', +] + +ROOT_URLCONF = 'davinci.urls' + +TEMPLATES = [ + { + 'BACKEND': 'django.template.backends.django.DjangoTemplates', + 'DIRS': [BASE_DIR / 'templates'] + , + 'APP_DIRS': True, + 'OPTIONS': { + 'context_processors': [ + 'django.template.context_processors.debug', + 'django.template.context_processors.request', + 'django.contrib.auth.context_processors.auth', + 'django.contrib.messages.context_processors.messages', + ], + }, + }, +] + +WSGI_APPLICATION = 'davinci.wsgi.application' + + +# Database +# https://docs.djangoproject.com/en/3.1/ref/settings/#databases + +DATABASES = { + 'default': { + 'ENGINE': 'django.db.backends.sqlite3', + 'NAME': BASE_DIR / 'db.sqlite3', + } +} + + +# Password validation +# https://docs.djangoproject.com/en/3.1/ref/settings/#auth-password-validators + +AUTH_PASSWORD_VALIDATORS = [ + { + 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', + }, +] + + +# Internationalization +# https://docs.djangoproject.com/en/3.1/topics/i18n/ + +LANGUAGE_CODE = 'en-us' + +TIME_ZONE = 'UTC' + +USE_I18N = True + +USE_L10N = True + +USE_TZ = True + + +# Static files (CSS, JavaScript, Images) +# https://docs.djangoproject.com/en/3.1/howto/static-files/ +STATIC_URL = '/static/' + +try: + from davinci.local import * +except ImportError: + print("DAVinci local settings not configured! Copy local.py.default to local.py and modify!", file=sys.stderr) diff --git a/davinci/urls.py b/davinci/urls.py new file mode 100644 index 0000000..290933d --- /dev/null +++ b/davinci/urls.py @@ -0,0 +1,23 @@ +"""davinci URL Configuration + +The `urlpatterns` list routes URLs to views. For more information please see: + https://docs.djangoproject.com/en/3.1/topics/http/urls/ +Examples: +Function views + 1. Add an import: from my_app import views + 2. Add a URL to urlpatterns: path('', views.home, name='home') +Class-based views + 1. Add an import: from other_app.views import Home + 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home') +Including another URLconf + 1. Import the include() function: from django.urls import include, path + 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) +""" +from django.contrib import admin +from django.urls import path +from django.views.generic import RedirectView + +urlpatterns = [ + path('admin/', admin.site.urls), + path('', RedirectView.as_view(pattern_name='admin:index')) +] diff --git a/davinci/wsgi.py b/davinci/wsgi.py new file mode 100644 index 0000000..4a83427 --- /dev/null +++ b/davinci/wsgi.py @@ -0,0 +1,16 @@ +""" +WSGI config for DAVinci project. + +It exposes the WSGI callable as a module-level variable named ``application``. + +For more information on this file, see +https://docs.djangoproject.com/en/3.1/howto/deployment/wsgi/ +""" + +import os + +from django.core.wsgi import get_wsgi_application + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'davinci.settings') + +application = get_wsgi_application() diff --git a/manage.py b/manage.py new file mode 100755 index 0000000..7cff7a6 --- /dev/null +++ b/manage.py @@ -0,0 +1,22 @@ +#!/usr/bin/env python +"""Django's command-line utility for administrative tasks.""" +import os +import sys + + +def main(): + """Run administrative tasks.""" + os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'davinci.settings') + try: + from django.core.management import execute_from_command_line + except ImportError as exc: + raise ImportError( + "Couldn't import Django. Are you sure it's installed and " + "available on your PYTHONPATH environment variable? Did you " + "forget to activate a virtual environment?" + ) from exc + execute_from_command_line(sys.argv) + + +if __name__ == '__main__': + main() diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..e0e4906 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,6 @@ +Django>=3.1.3,<3.2 +django-formtools>=2.2,<2.3 +django-durationwidget>=1.0.5,<1.1 +humanize>=3.1.0,<3.2 +ics>=0.7,<0.8 +caldav>=0.7.1,<0.8