diff --git a/chrono/agendas/management/commands/sync_desks_timeperiod_exceptions.py b/chrono/agendas/management/commands/sync_desks_timeperiod_exceptions.py index f0f05995..a9ec0ff7 100644 --- a/chrono/agendas/management/commands/sync_desks_timeperiod_exceptions.py +++ b/chrono/agendas/management/commands/sync_desks_timeperiod_exceptions.py @@ -18,16 +18,19 @@ from __future__ import print_function import sys -from chrono.agendas.models import Desk, ICSError from django.core.management.base import BaseCommand +from chrono.agendas.models import ICSError, TimePeriodExceptionSource + class Command(BaseCommand): help = 'Synchronize time period exceptions from desks remote ics' def handle(self, **options): - for desk in Desk.objects.exclude(timeperiod_exceptions_remote_url=''): + for source in TimePeriodExceptionSource.objects.filter(ics_url__isnull=False): try: - desk.create_timeperiod_exceptions_from_remote_ics(desk.timeperiod_exceptions_remote_url) + source.desk.import_timeperiod_exceptions_from_remote_ics(source.ics_url, source=source) except ICSError as e: - print(u'unable to create timeperiod exceptions for "%s": %s' % (desk, e), file=sys.stderr) + print( + u'unable to create timeperiod exceptions for "%s": %s' % (source.desk, e), file=sys.stderr + ) diff --git a/chrono/agendas/migrations/0008_auto_20160910_1319.py b/chrono/agendas/migrations/0008_auto_20160910_1319.py index 0aa24e7a..c4729af9 100644 --- a/chrono/agendas/migrations/0008_auto_20160910_1319.py +++ b/chrono/agendas/migrations/0008_auto_20160910_1319.py @@ -54,10 +54,10 @@ class Migration(migrations.Migration): model_name='agenda', name='kind', field=models.CharField( - default=b'events', + default='events', max_length=20, verbose_name='Kind', - choices=[(b'events', 'Events'), (b'meetings', 'Meetings')], + choices=[('events', 'Events'), ('meetings', 'Meetings')], ), ), migrations.AddField( diff --git a/chrono/agendas/migrations/0033_timeperiodexceptionsource.py b/chrono/agendas/migrations/0033_timeperiodexceptionsource.py new file mode 100644 index 00000000..e664fa5a --- /dev/null +++ b/chrono/agendas/migrations/0033_timeperiodexceptionsource.py @@ -0,0 +1,46 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('agendas', '0032_auto_20191127_0919'), + ] + + operations = [ + migrations.CreateModel( + name='TimePeriodExceptionSource', + fields=[ + ( + 'id', + models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'), + ), + ('ics_filename', models.CharField(max_length=256, null=True)), + ('ics_url', models.URLField(null=True, max_length=500)), + ('desk', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='agendas.Desk')), + ], + ), + migrations.AddField( + model_name='timeperiodexception', + name='source', + field=models.ForeignKey( + null=True, on_delete=django.db.models.deletion.CASCADE, to='agendas.TimePeriodExceptionSource' + ), + ), + migrations.AlterField( + model_name='desk', + name='timeperiod_exceptions_remote_url', + field=models.URLField( + blank=True, max_length=500, null=True, verbose_name='URL to fetch time period exceptions from' + ), + ), + migrations.AlterField( + model_name='timeperiodexception', + name='external_id', + field=models.CharField(blank=True, max_length=256, null=True, verbose_name='External ID'), + ), + ] diff --git a/chrono/agendas/migrations/0034_initial_source.py b/chrono/agendas/migrations/0034_initial_source.py new file mode 100644 index 00000000..9f862dd1 --- /dev/null +++ b/chrono/agendas/migrations/0034_initial_source.py @@ -0,0 +1,49 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import migrations + + +def create_source(apps, schema_editor): + Desk = apps.get_model('agendas', 'Desk') + TimePeriodExceptionSource = apps.get_model('agendas', 'TimePeriodExceptionSource') + for desk in Desk.objects.exclude(timeperiod_exceptions_remote_url=''): + # create a source for each remote url + source = TimePeriodExceptionSource.objects.create( + desk=desk, ics_url=desk.timeperiod_exceptions_remote_url + ) + # clear timeperiod_exceptions_remote_url + desk.timeperiod_exceptions_remote_url = None + desk.save() + # attach exceptions to the created source + desk.timeperiodexception_set.filter(external_id__isnull=False).exclude(external_id='').update( + source=source + ) + + +def init_remote_url(apps, schema_editor): + Desk = apps.get_model('agendas', 'Desk') + TimePeriodExceptionSource = apps.get_model('agendas', 'TimePeriodExceptionSource') + for source in TimePeriodExceptionSource.objects.filter(ics_url__isnull=False): + # set timeperiod_exceptions_remote_url + source.desk.timeperiod_exceptions_remote_url = source.ics_url + source.desk.save() + # unlink exceptions + source.timeperiodexception_set.update(source=None) + # delete the source + source.delete() + # reset remote_url + for desk in Desk.objects.filter(timeperiod_exceptions_remote_url__isnull=True): + desk.timeperiod_exceptions_remote_url = '' + desk.save() + + +class Migration(migrations.Migration): + + dependencies = [ + ('agendas', '0033_timeperiodexceptionsource'), + ] + + operations = [ + migrations.RunPython(create_source, init_remote_url), + ] diff --git a/chrono/agendas/migrations/0035_remove_desk_timeperiod_exceptions_remote_url.py b/chrono/agendas/migrations/0035_remove_desk_timeperiod_exceptions_remote_url.py new file mode 100644 index 00000000..ecf983fe --- /dev/null +++ b/chrono/agendas/migrations/0035_remove_desk_timeperiod_exceptions_remote_url.py @@ -0,0 +1,17 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.18 on 2019-12-09 14:24 +from __future__ import unicode_literals + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('agendas', '0034_initial_source'), + ] + + operations = [ + migrations.RemoveField(model_name='desk', name='timeperiod_exceptions_remote_url',), + migrations.RemoveField(model_name='timeperiodexception', name='external_id',), + ] diff --git a/chrono/agendas/models.py b/chrono/agendas/models.py index 3374a50e..d98af06d 100644 --- a/chrono/agendas/models.py +++ b/chrono/agendas/models.py @@ -469,9 +469,6 @@ class Desk(models.Model): agenda = models.ForeignKey(Agenda, on_delete=models.CASCADE) label = models.CharField(_('Label'), max_length=150) slug = models.SlugField(_('Identifier'), max_length=160) - timeperiod_exceptions_remote_url = models.URLField( - _('URL to fetch time period exceptions from'), blank=True, max_length=500 - ) def __str__(self): return self.label @@ -528,27 +525,31 @@ class Desk(models.Model): in_two_weeks = self.get_exceptions_within_two_weeks() return self.timeperiodexception_set.count() == len(in_two_weeks) - def create_timeperiod_exceptions_from_remote_ics(self, url): + def import_timeperiod_exceptions_from_remote_ics(self, ics_url, source=None): try: - response = requests.get(url, proxies=settings.REQUESTS_PROXIES) + response = requests.get(ics_url, proxies=settings.REQUESTS_PROXIES) response.raise_for_status() except requests.HTTPError as e: raise ICSError( _('Failed to retrieve remote calendar (%(url)s, HTTP error %(status_code)s).') - % {'url': url, 'status_code': e.response.status_code} + % {'url': ics_url, 'status_code': e.response.status_code} ) except requests.RequestException as e: raise ICSError( _('Failed to retrieve remote calendar (%(url)s, %(exception)s).') - % {'url': url, 'exception': e} + % {'url': ics_url, 'exception': e} ) - return self.create_timeperiod_exceptions_from_ics(response.text, keep_synced_by_uid=True) + if source is None: + source = TimePeriodExceptionSource(desk=self, ics_url=ics_url) + return self._import_timeperiod_exceptions_from_ics(source=source, data=response.text) - def remove_timeperiod_exceptions_from_remote_ics(self): - TimePeriodException.objects.filter(desk=self).exclude(external_id='').delete() + def import_timeperiod_exceptions_from_ics_file(self, ics_file, source=None): + if source is None: + source = TimePeriodExceptionSource(desk=self, ics_filename=ics_file.name) + return self._import_timeperiod_exceptions_from_ics(source=source, data=force_text(ics_file.read())) - def create_timeperiod_exceptions_from_ics(self, data, keep_synced_by_uid=False, recurring_days=600): + def _import_timeperiod_exceptions_from_ics(self, source, data, recurring_days=600): try: parsed = vobject.readOne(data) except vobject.base.ParseError: @@ -556,10 +557,15 @@ class Desk(models.Model): total_created = 0 - if not parsed.contents.get('vevent') and not keep_synced_by_uid: + if not parsed.contents.get('vevent'): raise ICSError(_('The file doesn\'t contain any events.')) with transaction.atomic(): + if source.pk is None: + source.save() + # delete old exceptions related to this source + source.timeperiodexception_set.all().delete() + # create new exceptions update_datetime = now() for vevent in parsed.contents.get('vevent', []): if 'summary' in vevent.contents: @@ -590,24 +596,19 @@ class Desk(models.Model): end_dt = make_aware(datetime.datetime.combine(start_dt, datetime.datetime.max.time())) duration = end_dt - start_dt - event = {} - event['start_datetime'] = start_dt - event['end_datetime'] = end_dt - event['label'] = summary - - kwargs = {} - kwargs['desk'] = self - kwargs['recurrence_id'] = 0 - if keep_synced_by_uid: - kwargs['external_id'] = vevent.contents['uid'][0].value - else: - kwargs['label'] = summary + event = { + 'start_datetime': start_dt, + 'end_datetime': end_dt, + 'label': summary, + 'desk': self, + 'source': source, + 'recurrence_id': 0, + } if not vevent.rruleset: # classical event - obj, created = TimePeriodException.objects.update_or_create(defaults=event, **kwargs) - if created: - total_created += 1 + TimePeriodException.objects.create(**event) + total_created += 1 elif vevent.rruleset.count(): # recurring event until recurring_days in the future from_dt = start_dt @@ -621,26 +622,12 @@ class Desk(models.Model): if not is_aware(start_dt): start_dt = make_aware(start_dt) end_dt = start_dt + duration - kwargs['recurrence_id'] = i + event['recurrence_id'] = i event['start_datetime'] = start_dt event['end_datetime'] = end_dt - if end_dt < update_datetime: - TimePeriodException.objects.filter(**kwargs).update(**event) - else: - obj, created = TimePeriodException.objects.update_or_create( - defaults=event, **kwargs - ) - if created: - total_created += 1 - # delete unseen occurrences - kwargs.pop('recurrence_id', None) - TimePeriodException.objects.filter(recurrence_id__gt=i, **kwargs).delete() - - if keep_synced_by_uid: - # delete all outdated exceptions from remote calendar - TimePeriodException.objects.filter(update_datetime__lt=update_datetime, desk=self).exclude( - external_id='' - ).delete() + if end_dt >= update_datetime: + TimePeriodException.objects.create(**event) + total_created += 1 return total_created @@ -661,10 +648,22 @@ class Desk(models.Model): return openslots.search(aware_date, aware_next_date) +@python_2_unicode_compatible +class TimePeriodExceptionSource(models.Model): + desk = models.ForeignKey(Desk, on_delete=models.CASCADE) + ics_filename = models.CharField(null=True, max_length=256) + ics_url = models.URLField(null=True, max_length=500) + + def __str__(self): + if self.ics_filename is not None: + return self.ics_filename + return self.ics_url + + @python_2_unicode_compatible class TimePeriodException(models.Model): desk = models.ForeignKey(Desk, on_delete=models.CASCADE) - external_id = models.CharField(_('External ID'), max_length=256, blank=True) + source = models.ForeignKey(TimePeriodExceptionSource, on_delete=models.CASCADE, null=True) label = models.CharField(_('Optional Label'), max_length=150, blank=True, null=True) start_datetime = models.DateTimeField(_('Exception start time')) end_datetime = models.DateTimeField(_('Exception end time')) @@ -739,7 +738,6 @@ class TimePeriodException(models.Model): 'label': self.label, 'start_datetime': export_datetime(self.start_datetime), 'end_datetime': export_datetime(self.end_datetime), - 'external_id': self.external_id, 'recurrence_id': self.recurrence_id, 'update_datetime': export_datetime(self.update_datetime), } diff --git a/chrono/manager/forms.py b/chrono/manager/forms.py index 2ec2217a..113bf2fa 100644 --- a/chrono/manager/forms.py +++ b/chrono/manager/forms.py @@ -142,7 +142,7 @@ class NewDeskForm(forms.ModelForm): widgets = { 'agenda': forms.HiddenInput(), } - exclude = ['slug', 'timeperiod_exceptions_remote_url'] + exclude = ['slug'] class DeskForm(forms.ModelForm): @@ -151,7 +151,7 @@ class DeskForm(forms.ModelForm): widgets = { 'agenda': forms.HiddenInput(), } - exclude = ['timeperiod_exceptions_remote_url'] + exclude = [] class TimePeriodExceptionForm(forms.ModelForm): @@ -262,10 +262,6 @@ class ImportEventsForm(forms.Form): class ExceptionsImportForm(forms.ModelForm): - class Meta: - model = Desk - fields = [] - ics_file = forms.FileField( label=_('ICS File'), required=False, @@ -277,6 +273,15 @@ class ExceptionsImportForm(forms.ModelForm): help_text=_('URL to remote calendar which will be synchronised hourly.'), ) + class Meta: + model = Desk + fields = [] + + def clean(self, *args, **kwargs): + cleaned_data = super().clean(*args, **kwargs) + if not cleaned_data.get('ics_file') and not cleaned_data.get('ics_url'): + raise forms.ValidationError(_('Please provide an ICS File or an URL.')) + class AgendasImportForm(forms.Form): agendas_json = forms.FileField(label=_('Agendas Export File')) diff --git a/chrono/manager/templates/chrono/manager_import_exceptions.html b/chrono/manager/templates/chrono/manager_import_exceptions.html index 39d5e524..bf778f9b 100644 --- a/chrono/manager/templates/chrono/manager_import_exceptions.html +++ b/chrono/manager/templates/chrono/manager_import_exceptions.html @@ -13,7 +13,37 @@ {% block content %}