agendas: add global exceptions sources (#18904)
This commit is contained in:
parent
7b5da14331
commit
bf394e0a07
|
@ -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()
|
|
@ -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),
|
||||
),
|
||||
]
|
|
@ -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)
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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')
|
||||
)
|
||||
|
|
|
@ -0,0 +1 @@
|
|||
0 0 1 1 * chrono /usr/bin/chrono-manage -- tenant_command sync_desks_timeperiod_exceptions_from_settings --all-tenants
|
1
setup.py
1
setup.py
|
@ -168,6 +168,7 @@ setup(
|
|||
'vobject',
|
||||
'python-dateutil',
|
||||
'requests',
|
||||
'workalendar',
|
||||
],
|
||||
zip_safe=False,
|
||||
cmdclass={
|
||||
|
|
|
@ -25,3 +25,5 @@ KNOWN_SERVICES = {
|
|||
}
|
||||
},
|
||||
}
|
||||
|
||||
EXCEPTIONS_SOURCES = {}
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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')
|
||||
|
|
Loading…
Reference in New Issue