agendas: add global exceptions sources (#18904)

This commit is contained in:
Valentin Deniaud 2020-02-20 11:46:23 +01:00
parent 7b5da14331
commit bf394e0a07
12 changed files with 287 additions and 2 deletions

View File

@ -0,0 +1,27 @@
# chrono - agendas system
# Copyright (C) 2020 Entr'ouvert
#
# This program is free software: you can redistribute it and/or modify it
# under the terms of the GNU Affero General Public License as published
# by the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
from django.core.management.base import BaseCommand
from chrono.agendas.models import Desk
class Command(BaseCommand):
help = 'Synchronize time period exceptions from settings'
def handle(self, **options):
for desk in Desk.objects.all():
desk.import_timeperiod_exceptions_from_settings()

View File

@ -0,0 +1,33 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.11.18 on 2020-08-31 14:34
from __future__ import unicode_literals
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('agendas', '0056_auto_20200811_1611'),
]
operations = [
migrations.AddField(
model_name='timeperiodexceptionsource', name='enabled', field=models.BooleanField(default=True),
),
migrations.AddField(
model_name='timeperiodexceptionsource',
name='last_update',
field=models.DateTimeField(auto_now=True, null=True),
),
migrations.AddField(
model_name='timeperiodexceptionsource',
name='settings_label',
field=models.CharField(max_length=150, null=True),
),
migrations.AddField(
model_name='timeperiodexceptionsource',
name='settings_slug',
field=models.CharField(max_length=150, null=True),
),
]

View File

@ -39,6 +39,7 @@ from django.utils import functional
from django.utils.dates import WEEKDAYS
from django.utils.encoding import force_text
from django.utils.formats import date_format
from django.utils.module_loading import import_string
from django.utils.text import slugify
from django.utils.timezone import localtime, now, make_aware, make_naive, is_aware
from django.utils.translation import ugettext_lazy as _, ugettext
@ -1070,9 +1071,12 @@ class Desk(models.Model):
def save(self, *args, **kwargs):
assert self.agenda.kind != 'virtual', "a desk can't reference a virtual agenda"
first_created = not self.pk
if not self.slug:
self.slug = generate_slug(self, agenda=self.agenda)
super(Desk, self).save(*args, **kwargs)
if first_created:
self.import_timeperiod_exceptions_from_settings(enable=True)
@property
def base_slug(self):
@ -1294,6 +1298,24 @@ class Desk(models.Model):
return [OpeningHour(*time_range) for time_range in (openslots - exceptions)]
def import_timeperiod_exceptions_from_settings(self, enable=False):
start_update = now()
for slug, source_info in settings.EXCEPTIONS_SOURCES.items():
label = source_info['label']
try:
source = TimePeriodExceptionSource.objects.get(desk=self, settings_slug=slug)
except TimePeriodExceptionSource.DoesNotExist:
source = TimePeriodExceptionSource.objects.create(
desk=self, settings_slug=slug, enabled=False
)
source.settings_label = _(label)
source.save()
if enable or source.enabled: # if already enabled, update anyway
source.enable()
TimePeriodExceptionSource.objects.filter(
desk=self, settings_slug__isnull=False, last_update__lt=start_update
).delete() # source was not in settings anymore
class Resource(models.Model):
slug = models.SlugField(_('Identifier'), max_length=160, unique=True)
@ -1345,10 +1367,16 @@ class TimePeriodExceptionSource(models.Model):
ics_filename = models.CharField(null=True, max_length=256)
ics_file = models.FileField(upload_to=ics_directory_path, blank=True, null=True)
ics_url = models.URLField(null=True, max_length=500)
settings_slug = models.CharField(null=True, max_length=150)
settings_label = models.CharField(null=True, max_length=150)
last_update = models.DateTimeField(auto_now=True, null=True)
enabled = models.BooleanField(default=True)
def __str__(self):
if self.ics_filename is not None:
return self.ics_filename
if self.settings_label is not None:
return ugettext(self.settings_label)
return self.ics_url
def duplicate(self, desk_target=None):
@ -1366,6 +1394,34 @@ class TimePeriodExceptionSource(models.Model):
return new_source
def enable(self):
source_info = settings.EXCEPTIONS_SOURCES.get(self.settings_slug)
if not source_info:
return
source_class = import_string(source_info['class'])
calendar = source_class()
this_year = now().year
days = [day for year in range(this_year, this_year + 3) for day in calendar.holidays(year)]
with transaction.atomic():
self.timeperiodexception_set.all().delete()
for day, label in days:
start_datetime = make_aware(datetime.datetime.combine(day, datetime.datetime.min.time()))
end_datetime = start_datetime + datetime.timedelta(days=1)
TimePeriodException.objects.create(
desk=self.desk,
source=self,
label=_(label),
start_datetime=start_datetime,
end_datetime=end_datetime,
)
self.enabled = True
self.save()
def disable(self):
self.timeperiodexception_set.all().delete()
self.enabled = False
self.save()
class TimePeriodException(models.Model):
desk = models.ForeignKey(Desk, on_delete=models.CASCADE)

View File

@ -17,13 +17,17 @@
<ul class="objects-list single-links">
{% for object in exception_sources %}
<li>
<a title="{{ object }}" {% if not object.ics_filename %}href="{{ object }}"{% endif %}>{% if object.ics_filename %}{{ object|truncatechars:50 }}{% else %}{{ object|truncatechars:50 }}{% endif %}</a>
<a {% if not object.enabled %}class="disabled"{% endif %} title="{{ object }}" {% if object.ics_url %}href="{{ object }}"{% endif %}>{% if object.ics_filename %}{{ object|truncatechars:50 }}{% else %}{{ object|truncatechars:50 }}{% endif %}</a>
{% if object.ics_filename %}
<a rel="popup" class="link-action-icon refresh" href="{% url 'chrono-manager-time-period-exception-source-replace' object.pk %}">{% trans "replace" %}</a>
{% else %}
{% elif object.ics_url %}
<a class="link-action-icon refresh" href="{% url 'chrono-manager-time-period-exception-source-refresh' object.pk %}">{% trans "refresh" %}</a>
{% endif %}
{% if not object.settings_slug %}
<a rel="popup" class="delete" href="{% url 'chrono-manager-time-period-exception-source-delete' object.pk %}">{% trans "remove" %}</a>
{% else %}
<a class="link-action-text" href="{% url 'chrono-manager-time-period-exception-source-toggle' object.pk %}">({{ object.enabled|yesno:_("disable,enable") }})</a>
{% endif %}
</li>
{% endfor %}
</ul>

View File

@ -193,6 +193,11 @@ urlpatterns = [
views.time_period_exception_source_refresh,
name='chrono-manager-time-period-exception-source-refresh',
),
url(
r'^time-period-exceptions-source/(?P<pk>\d+)/toggle$',
views.time_period_exception_source_toggle,
name='chrono-manager-time-period-exception-source-toggle',
),
url(
r'^time-period-exceptions-source/(?P<pk>\d+)/replace$',
views.time_period_exception_source_replace,

View File

@ -1946,6 +1946,32 @@ class EventCancellationReportListView(ViewableAgendaMixin, ListView):
event_cancellation_report_list = EventCancellationReportListView.as_view()
class TimePeriodExceptionSourceToggleView(ManagedDeskSubobjectMixin, DetailView):
model = TimePeriodExceptionSource
def get_object(self, queryset=None):
source = super().get_object(queryset)
if source.settings_slug is None:
raise Http404('This source cannot be enabled nor disabled')
return source
def get(self, request, *args, **kwargs):
source = self.get_object()
if source.enabled:
source.disable()
message = _('Exception source %(source)s has been disabled on desk %(desk)s.')
else:
source.enable()
message = _('Exception source %(source)s has been enabled on desk %(desk)s.')
messages.info(self.request, message % {'source': source, 'desk': source.desk})
return HttpResponseRedirect(
reverse('chrono-manager-agenda-settings', kwargs={'pk': source.desk.agenda_id})
)
time_period_exception_source_toggle = TimePeriodExceptionSourceToggleView.as_view()
def menu_json(request):
label = _('Agendas')
json_str = json.dumps(

View File

@ -26,6 +26,8 @@ and to disable DEBUG mode in production.
import os
from django.conf.global_settings import STATICFILES_FINDERS
_ = lambda s: s
# Build paths inside the project like this: os.path.join(BASE_DIR, ...)
BASE_DIR = os.path.dirname(os.path.dirname(__file__))
@ -166,6 +168,10 @@ REQUESTS_PROXIES = None
# we use 28s by default: timeout just before web server, which is usually 30s
REQUESTS_TIMEOUT = 28
EXCEPTIONS_SOURCES = {
'holidays': {'class': 'workalendar.europe.France', 'label': _('Holidays')},
}
local_settings_file = os.environ.get(
'CHRONO_SETTINGS_FILE', os.path.join(os.path.dirname(__file__), 'local_settings.py')
)

1
debian/chrono.cron.d vendored Normal file
View File

@ -0,0 +1 @@
0 0 1 1 * chrono /usr/bin/chrono-manage -- tenant_command sync_desks_timeperiod_exceptions_from_settings --all-tenants

View File

@ -168,6 +168,7 @@ setup(
'vobject',
'python-dateutil',
'requests',
'workalendar',
],
zip_safe=False,
cmdclass={

View File

@ -25,3 +25,5 @@ KNOWN_SERVICES = {
}
},
}
EXCEPTIONS_SOURCES = {}

View File

@ -7,6 +7,7 @@ import requests
from django.contrib.auth.models import Group
from django.core.files.base import ContentFile
from django.core.management import call_command
from django.test import override_settings
from django.utils.timezone import localtime, make_aware, now
from chrono.agendas.models import (
@ -512,6 +513,75 @@ def test_sync_desks_timeperiod_exceptions_from_ics(mocked_get, capsys):
assert import_file_ics.call_args_list == []
@override_settings(
EXCEPTIONS_SOURCES={'holidays': {'class': 'workalendar.europe.France', 'label': 'Holidays'},}
)
def test_timeperiodexception_from_settings():
agenda = Agenda(label=u'Test 1 agenda')
agenda.save()
desk = Desk(label='Test 1 desk', agenda=agenda)
desk.save()
# first save automatically load exceptions
source = TimePeriodExceptionSource.objects.get(desk=desk)
assert source.settings_slug == 'holidays'
assert source.enabled
assert TimePeriodException.objects.filter(desk=desk, source=source).exists()
exception = TimePeriodException.objects.first()
from workalendar.europe import France
date, label = France().holidays()[0]
exception = TimePeriodException.objects.filter(label=label).first()
assert exception.end_datetime - exception.start_datetime == datetime.timedelta(days=1)
assert localtime(exception.start_datetime).date() == date
source.disable()
assert not source.enabled
assert not TimePeriodException.objects.filter(desk=desk, source=source).exists()
source.enable()
assert source.enabled
assert TimePeriodException.objects.filter(desk=desk, source=source).exists()
def test_timeperiodexception_from_settings_command():
setting = {
'EXCEPTIONS_SOURCES': {'holidays': {'class': 'workalendar.europe.France', 'label': 'Holidays'},}
}
agenda = Agenda(label=u'Test 1 agenda')
agenda.save()
desk1 = Desk(label='Test 1 desk', agenda=agenda)
desk1.save()
with override_settings(**setting):
desk2 = Desk(label='Test 2 desk', agenda=agenda)
desk2.save()
desk3 = Desk(label='Test 3 desk', agenda=agenda)
desk3.save()
source3 = TimePeriodExceptionSource.objects.get(desk=desk3)
source3.disable()
call_command('sync_desks_timeperiod_exceptions_from_settings')
assert not TimePeriodExceptionSource.objects.get(desk=desk1).enabled
source2 = TimePeriodExceptionSource.objects.get(desk=desk2)
assert source2.enabled
source3.refresh_from_db()
assert not source3.enabled
exceptions_count = source2.timeperiodexception_set.count()
# Alsace Moselle has more holidays
setting['EXCEPTIONS_SOURCES']['holidays']['class'] = 'workalendar.europe.FranceAlsaceMoselle'
with override_settings(**setting):
call_command('sync_desks_timeperiod_exceptions_from_settings')
source2.refresh_from_db()
assert exceptions_count < source2.timeperiodexception_set.count()
setting['EXCEPTIONS_SOURCES'] = {}
with override_settings(**setting):
call_command('sync_desks_timeperiod_exceptions_from_settings')
assert not TimePeriodExceptionSource.objects.exists()
def test_base_meeting_duration():
agenda = Agenda(label='Meeting', kind='meetings')
agenda.save()

View File

@ -11,10 +11,12 @@ import os
from django.contrib.auth.models import User, Group
from django.core.management import call_command
from django.db import connection
from django.test import override_settings
from django.test.utils import CaptureQueriesContext
from django.utils.encoding import force_text
from django.utils.timezone import make_aware, now, localtime
import datetime
import freezegun
import pytest
import requests
@ -2451,6 +2453,58 @@ END:VCALENDAR"""
assert exceptions[0].pk != new_exceptions[0].pk
@override_settings(
EXCEPTIONS_SOURCES={'holidays': {'class': 'workalendar.europe.France', 'label': 'Holidays'},}
)
def test_meetings_agenda_time_period_exception_source_from_settings(app, admin_user):
agenda = Agenda.objects.create(label='Foo bar', kind='meetings')
desk = Desk.objects.create(agenda=agenda, label='Desk A')
MeetingType(agenda=agenda, label='Blah').save()
TimePeriod.objects.create(
weekday=1, desk=desk, start_time=datetime.time(10, 0), end_time=datetime.time(12, 0)
)
assert TimePeriodException.objects.exists()
login(app)
resp = app.get('/manage/agendas/%d/' % agenda.pk).follow()
resp = resp.click('Settings')
resp = resp.click('upload')
assert 'Holidays' in resp.text
assert 'disabled' not in resp.text
assert 'refresh' not in resp.text
resp = resp.click('disable').follow()
assert not TimePeriodException.objects.exists()
resp = resp.click('upload')
assert 'Holidays' in resp.text
assert 'disabled' in resp.text
resp = resp.click('enable').follow()
assert TimePeriodException.objects.exists()
resp = resp.click('upload')
assert 'disabled' not in resp.text
def test_meetings_agenda_time_period_exception_source_try_disable_ics(app, admin_user):
agenda = Agenda.objects.create(label='Foo bar', kind='meetings')
desk = Desk.objects.create(agenda=agenda, label='Desk A')
MeetingType(agenda=agenda, label='Blah').save()
TimePeriod.objects.create(
weekday=1, desk=desk, start_time=datetime.time(10, 0), end_time=datetime.time(12, 0)
)
source = TimePeriodExceptionSource.objects.create(desk=desk, ics_url='https://example.com/test.ics')
login(app)
resp = app.get('/manage/agendas/%d/' % agenda.pk).follow()
resp = resp.click('Settings')
resp = resp.click('upload')
assert 'test.ics' in resp.text
assert app.get('/manage/time-period-exceptions-source/%s/toggle' % source.pk, status=404)
def test_agenda_day_view(app, admin_user, manager_user, api_user):
agenda = Agenda.objects.create(label='New Example', kind='meetings')
desk = Desk.objects.create(agenda=agenda, label='New Desk')