0001-agendas-add-global-exceptions-sources-18904.patch
chrono/agendas/management/commands/sync_desks_timeperiod_exceptions_from_settings.py | ||
---|---|---|
1 |
# chrono - agendas system |
|
2 |
# Copyright (C) 2020 Entr'ouvert |
|
3 |
# |
|
4 |
# This program is free software: you can redistribute it and/or modify it |
|
5 |
# under the terms of the GNU Affero General Public License as published |
|
6 |
# by the Free Software Foundation, either version 3 of the License, or |
|
7 |
# (at your option) any later version. |
|
8 |
# |
|
9 |
# This program is distributed in the hope that it will be useful, |
|
10 |
# but WITHOUT ANY WARRANTY; without even the implied warranty of |
|
11 |
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
|
12 |
# GNU Affero General Public License for more details. |
|
13 |
# |
|
14 |
# You should have received a copy of the GNU Affero General Public License |
|
15 |
# along with this program. If not, see <http://www.gnu.org/licenses/>. |
|
16 | ||
17 |
from django.core.management.base import BaseCommand |
|
18 | ||
19 |
from chrono.agendas.models import Desk |
|
20 | ||
21 | ||
22 |
class Command(BaseCommand): |
|
23 |
help = 'Synchronize time period exceptions from settings' |
|
24 | ||
25 |
def handle(self, **options): |
|
26 |
for desk in Desk.objects.all(): |
|
27 |
desk.import_timeperiod_exceptions_from_settings() |
chrono/agendas/migrations/0057_auto_20200831_1634.py | ||
---|---|---|
1 |
# -*- coding: utf-8 -*- |
|
2 |
# Generated by Django 1.11.18 on 2020-08-31 14:34 |
|
3 |
from __future__ import unicode_literals |
|
4 | ||
5 |
from django.db import migrations, models |
|
6 | ||
7 | ||
8 |
class Migration(migrations.Migration): |
|
9 | ||
10 |
dependencies = [ |
|
11 |
('agendas', '0056_auto_20200811_1611'), |
|
12 |
] |
|
13 | ||
14 |
operations = [ |
|
15 |
migrations.AddField( |
|
16 |
model_name='timeperiodexceptionsource', name='enabled', field=models.BooleanField(default=True), |
|
17 |
), |
|
18 |
migrations.AddField( |
|
19 |
model_name='timeperiodexceptionsource', |
|
20 |
name='last_update', |
|
21 |
field=models.DateTimeField(auto_now=True, null=True), |
|
22 |
), |
|
23 |
migrations.AddField( |
|
24 |
model_name='timeperiodexceptionsource', |
|
25 |
name='settings_label', |
|
26 |
field=models.CharField(max_length=150, null=True), |
|
27 |
), |
|
28 |
migrations.AddField( |
|
29 |
model_name='timeperiodexceptionsource', |
|
30 |
name='settings_slug', |
|
31 |
field=models.CharField(max_length=150, null=True), |
|
32 |
), |
|
33 |
] |
chrono/agendas/models.py | ||
---|---|---|
39 | 39 |
from django.utils.dates import WEEKDAYS |
40 | 40 |
from django.utils.encoding import force_text |
41 | 41 |
from django.utils.formats import date_format |
42 |
from django.utils.module_loading import import_string |
|
42 | 43 |
from django.utils.text import slugify |
43 | 44 |
from django.utils.timezone import localtime, now, make_aware, make_naive, is_aware |
44 | 45 |
from django.utils.translation import ugettext_lazy as _, ugettext |
... | ... | |
1070 | 1071 | |
1071 | 1072 |
def save(self, *args, **kwargs): |
1072 | 1073 |
assert self.agenda.kind != 'virtual', "a desk can't reference a virtual agenda" |
1074 |
first_created = not self.pk |
|
1073 | 1075 |
if not self.slug: |
1074 | 1076 |
self.slug = generate_slug(self, agenda=self.agenda) |
1075 | 1077 |
super(Desk, self).save(*args, **kwargs) |
1078 |
if first_created: |
|
1079 |
self.import_timeperiod_exceptions_from_settings(enable=True) |
|
1076 | 1080 | |
1077 | 1081 |
@property |
1078 | 1082 |
def base_slug(self): |
... | ... | |
1294 | 1298 | |
1295 | 1299 |
return [OpeningHour(*time_range) for time_range in (openslots - exceptions)] |
1296 | 1300 | |
1301 |
def import_timeperiod_exceptions_from_settings(self, enable=False): |
|
1302 |
start_update = now() |
|
1303 |
for slug, source_info in settings.EXCEPTIONS_SOURCES.items(): |
|
1304 |
label = source_info['label'] |
|
1305 |
try: |
|
1306 |
source = TimePeriodExceptionSource.objects.get(desk=self, settings_slug=slug) |
|
1307 |
except TimePeriodExceptionSource.DoesNotExist: |
|
1308 |
source = TimePeriodExceptionSource.objects.create( |
|
1309 |
desk=self, settings_slug=slug, enabled=False |
|
1310 |
) |
|
1311 |
source.settings_label = _(label) |
|
1312 |
source.save() |
|
1313 |
if enable or source.enabled: # if already enabled, update anyway |
|
1314 |
source.enable() |
|
1315 |
TimePeriodExceptionSource.objects.filter( |
|
1316 |
desk=self, settings_slug__isnull=False, last_update__lt=start_update |
|
1317 |
).delete() # source was not in settings anymore |
|
1318 | ||
1297 | 1319 | |
1298 | 1320 |
class Resource(models.Model): |
1299 | 1321 |
slug = models.SlugField(_('Identifier'), max_length=160, unique=True) |
... | ... | |
1345 | 1367 |
ics_filename = models.CharField(null=True, max_length=256) |
1346 | 1368 |
ics_file = models.FileField(upload_to=ics_directory_path, blank=True, null=True) |
1347 | 1369 |
ics_url = models.URLField(null=True, max_length=500) |
1370 |
settings_slug = models.CharField(null=True, max_length=150) |
|
1371 |
settings_label = models.CharField(null=True, max_length=150) |
|
1372 |
last_update = models.DateTimeField(auto_now=True, null=True) |
|
1373 |
enabled = models.BooleanField(default=True) |
|
1348 | 1374 | |
1349 | 1375 |
def __str__(self): |
1350 | 1376 |
if self.ics_filename is not None: |
1351 | 1377 |
return self.ics_filename |
1378 |
if self.settings_label is not None: |
|
1379 |
return ugettext(self.settings_label) |
|
1352 | 1380 |
return self.ics_url |
1353 | 1381 | |
1354 | 1382 |
def duplicate(self, desk_target=None): |
... | ... | |
1366 | 1394 | |
1367 | 1395 |
return new_source |
1368 | 1396 | |
1397 |
def enable(self): |
|
1398 |
source_info = settings.EXCEPTIONS_SOURCES.get(self.settings_slug) |
|
1399 |
if not source_info: |
|
1400 |
return |
|
1401 |
source_class = import_string(source_info['class']) |
|
1402 |
calendar = source_class() |
|
1403 |
this_year = now().year |
|
1404 |
days = [day for year in range(this_year, this_year + 3) for day in calendar.holidays(year)] |
|
1405 |
with transaction.atomic(): |
|
1406 |
self.timeperiodexception_set.all().delete() |
|
1407 |
for day, label in days: |
|
1408 |
start_datetime = make_aware(datetime.datetime.combine(day, datetime.datetime.min.time())) |
|
1409 |
end_datetime = start_datetime + datetime.timedelta(days=1) |
|
1410 |
TimePeriodException.objects.create( |
|
1411 |
desk=self.desk, |
|
1412 |
source=self, |
|
1413 |
label=_(label), |
|
1414 |
start_datetime=start_datetime, |
|
1415 |
end_datetime=end_datetime, |
|
1416 |
) |
|
1417 |
self.enabled = True |
|
1418 |
self.save() |
|
1419 | ||
1420 |
def disable(self): |
|
1421 |
self.timeperiodexception_set.all().delete() |
|
1422 |
self.enabled = False |
|
1423 |
self.save() |
|
1424 | ||
1369 | 1425 | |
1370 | 1426 |
class TimePeriodException(models.Model): |
1371 | 1427 |
desk = models.ForeignKey(Desk, on_delete=models.CASCADE) |
chrono/manager/templates/chrono/manager_import_exceptions.html | ||
---|---|---|
17 | 17 |
<ul class="objects-list single-links"> |
18 | 18 |
{% for object in exception_sources %} |
19 | 19 |
<li> |
20 |
<a title="{{ object }}" {% if not object.ics_filename %}href="{{ object }}"{% endif %}>{% if object.ics_filename %}{{ object|truncatechars:50 }}{% else %}{{ object|truncatechars:50 }}{% endif %}</a>
|
|
20 |
<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>
|
|
21 | 21 |
{% if object.ics_filename %} |
22 | 22 |
<a rel="popup" class="link-action-icon refresh" href="{% url 'chrono-manager-time-period-exception-source-replace' object.pk %}">{% trans "replace" %}</a> |
23 |
{% else %}
|
|
23 |
{% elif object.ics_url %}
|
|
24 | 24 |
<a class="link-action-icon refresh" href="{% url 'chrono-manager-time-period-exception-source-refresh' object.pk %}">{% trans "refresh" %}</a> |
25 | 25 |
{% endif %} |
26 |
{% if not object.settings_slug %} |
|
26 | 27 |
<a rel="popup" class="delete" href="{% url 'chrono-manager-time-period-exception-source-delete' object.pk %}">{% trans "remove" %}</a> |
28 |
{% else %} |
|
29 |
<a class="link-action-text" href="{% url 'chrono-manager-time-period-exception-source-toggle' object.pk %}">({{ object.enabled|yesno:_("disable,enable") }})</a> |
|
30 |
{% endif %} |
|
27 | 31 |
</li> |
28 | 32 |
{% endfor %} |
29 | 33 |
</ul> |
chrono/manager/urls.py | ||
---|---|---|
193 | 193 |
views.time_period_exception_source_refresh, |
194 | 194 |
name='chrono-manager-time-period-exception-source-refresh', |
195 | 195 |
), |
196 |
url( |
|
197 |
r'^time-period-exceptions-source/(?P<pk>\d+)/toggle$', |
|
198 |
views.time_period_exception_source_toggle, |
|
199 |
name='chrono-manager-time-period-exception-source-toggle', |
|
200 |
), |
|
196 | 201 |
url( |
197 | 202 |
r'^time-period-exceptions-source/(?P<pk>\d+)/replace$', |
198 | 203 |
views.time_period_exception_source_replace, |
chrono/manager/views.py | ||
---|---|---|
1946 | 1946 |
event_cancellation_report_list = EventCancellationReportListView.as_view() |
1947 | 1947 | |
1948 | 1948 | |
1949 |
class TimePeriodExceptionSourceToggleView(ManagedDeskSubobjectMixin, DetailView): |
|
1950 |
model = TimePeriodExceptionSource |
|
1951 | ||
1952 |
def get_object(self, queryset=None): |
|
1953 |
source = super().get_object(queryset) |
|
1954 |
if source.settings_slug is None: |
|
1955 |
raise Http404('This source cannot be enabled nor disabled') |
|
1956 |
return source |
|
1957 | ||
1958 |
def get(self, request, *args, **kwargs): |
|
1959 |
source = self.get_object() |
|
1960 |
if source.enabled: |
|
1961 |
source.disable() |
|
1962 |
message = _('Exception source %(source)s has been disabled on desk %(desk)s.') |
|
1963 |
else: |
|
1964 |
source.enable() |
|
1965 |
message = _('Exception source %(source)s has been enabled on desk %(desk)s.') |
|
1966 |
messages.info(self.request, message % {'source': source, 'desk': source.desk}) |
|
1967 |
return HttpResponseRedirect( |
|
1968 |
reverse('chrono-manager-agenda-settings', kwargs={'pk': source.desk.agenda_id}) |
|
1969 |
) |
|
1970 | ||
1971 | ||
1972 |
time_period_exception_source_toggle = TimePeriodExceptionSourceToggleView.as_view() |
|
1973 | ||
1974 | ||
1949 | 1975 |
def menu_json(request): |
1950 | 1976 |
label = _('Agendas') |
1951 | 1977 |
json_str = json.dumps( |
chrono/settings.py | ||
---|---|---|
26 | 26 |
import os |
27 | 27 |
from django.conf.global_settings import STATICFILES_FINDERS |
28 | 28 | |
29 |
_ = lambda s: s |
|
30 | ||
29 | 31 |
# Build paths inside the project like this: os.path.join(BASE_DIR, ...) |
30 | 32 |
BASE_DIR = os.path.dirname(os.path.dirname(__file__)) |
31 | 33 | |
... | ... | |
166 | 168 |
# we use 28s by default: timeout just before web server, which is usually 30s |
167 | 169 |
REQUESTS_TIMEOUT = 28 |
168 | 170 | |
171 |
EXCEPTIONS_SOURCES = { |
|
172 |
'holidays': {'class': 'workalendar.europe.France', 'label': _('Holidays')}, |
|
173 |
} |
|
174 | ||
169 | 175 |
local_settings_file = os.environ.get( |
170 | 176 |
'CHRONO_SETTINGS_FILE', os.path.join(os.path.dirname(__file__), 'local_settings.py') |
171 | 177 |
) |
debian/chrono.cron.d | ||
---|---|---|
1 |
0 0 1 1 * chrono /usr/bin/chrono-manage -- tenant_command sync_desks_timeperiod_exceptions_from_settings --all-tenants |
setup.py | ||
---|---|---|
168 | 168 |
'vobject', |
169 | 169 |
'python-dateutil', |
170 | 170 |
'requests', |
171 |
'workalendar', |
|
171 | 172 |
], |
172 | 173 |
zip_safe=False, |
173 | 174 |
cmdclass={ |
tests/settings.py | ||
---|---|---|
25 | 25 |
} |
26 | 26 |
}, |
27 | 27 |
} |
28 | ||
29 |
EXCEPTIONS_SOURCES = {} |
tests/test_agendas.py | ||
---|---|---|
7 | 7 |
from django.contrib.auth.models import Group |
8 | 8 |
from django.core.files.base import ContentFile |
9 | 9 |
from django.core.management import call_command |
10 |
from django.test import override_settings |
|
10 | 11 |
from django.utils.timezone import localtime, make_aware, now |
11 | 12 | |
12 | 13 |
from chrono.agendas.models import ( |
... | ... | |
512 | 513 |
assert import_file_ics.call_args_list == [] |
513 | 514 | |
514 | 515 | |
516 |
@override_settings( |
|
517 |
EXCEPTIONS_SOURCES={'holidays': {'class': 'workalendar.europe.France', 'label': 'Holidays'},} |
|
518 |
) |
|
519 |
def test_timeperiodexception_from_settings(): |
|
520 |
agenda = Agenda(label=u'Test 1 agenda') |
|
521 |
agenda.save() |
|
522 |
desk = Desk(label='Test 1 desk', agenda=agenda) |
|
523 |
desk.save() |
|
524 | ||
525 |
# first save automatically load exceptions |
|
526 |
source = TimePeriodExceptionSource.objects.get(desk=desk) |
|
527 |
assert source.settings_slug == 'holidays' |
|
528 |
assert source.enabled |
|
529 |
assert TimePeriodException.objects.filter(desk=desk, source=source).exists() |
|
530 | ||
531 |
exception = TimePeriodException.objects.first() |
|
532 |
from workalendar.europe import France |
|
533 | ||
534 |
date, label = France().holidays()[0] |
|
535 |
exception = TimePeriodException.objects.filter(label=label).first() |
|
536 |
assert exception.end_datetime - exception.start_datetime == datetime.timedelta(days=1) |
|
537 |
assert localtime(exception.start_datetime).date() == date |
|
538 | ||
539 |
source.disable() |
|
540 |
assert not source.enabled |
|
541 |
assert not TimePeriodException.objects.filter(desk=desk, source=source).exists() |
|
542 | ||
543 |
source.enable() |
|
544 |
assert source.enabled |
|
545 |
assert TimePeriodException.objects.filter(desk=desk, source=source).exists() |
|
546 | ||
547 | ||
548 |
def test_timeperiodexception_from_settings_command(): |
|
549 |
setting = { |
|
550 |
'EXCEPTIONS_SOURCES': {'holidays': {'class': 'workalendar.europe.France', 'label': 'Holidays'},} |
|
551 |
} |
|
552 |
agenda = Agenda(label=u'Test 1 agenda') |
|
553 |
agenda.save() |
|
554 |
desk1 = Desk(label='Test 1 desk', agenda=agenda) |
|
555 |
desk1.save() |
|
556 |
with override_settings(**setting): |
|
557 |
desk2 = Desk(label='Test 2 desk', agenda=agenda) |
|
558 |
desk2.save() |
|
559 |
desk3 = Desk(label='Test 3 desk', agenda=agenda) |
|
560 |
desk3.save() |
|
561 |
source3 = TimePeriodExceptionSource.objects.get(desk=desk3) |
|
562 |
source3.disable() |
|
563 | ||
564 |
call_command('sync_desks_timeperiod_exceptions_from_settings') |
|
565 |
assert not TimePeriodExceptionSource.objects.get(desk=desk1).enabled |
|
566 |
source2 = TimePeriodExceptionSource.objects.get(desk=desk2) |
|
567 |
assert source2.enabled |
|
568 |
source3.refresh_from_db() |
|
569 |
assert not source3.enabled |
|
570 | ||
571 |
exceptions_count = source2.timeperiodexception_set.count() |
|
572 |
# Alsace Moselle has more holidays |
|
573 |
setting['EXCEPTIONS_SOURCES']['holidays']['class'] = 'workalendar.europe.FranceAlsaceMoselle' |
|
574 |
with override_settings(**setting): |
|
575 |
call_command('sync_desks_timeperiod_exceptions_from_settings') |
|
576 |
source2.refresh_from_db() |
|
577 |
assert exceptions_count < source2.timeperiodexception_set.count() |
|
578 | ||
579 |
setting['EXCEPTIONS_SOURCES'] = {} |
|
580 |
with override_settings(**setting): |
|
581 |
call_command('sync_desks_timeperiod_exceptions_from_settings') |
|
582 |
assert not TimePeriodExceptionSource.objects.exists() |
|
583 | ||
584 | ||
515 | 585 |
def test_base_meeting_duration(): |
516 | 586 |
agenda = Agenda(label='Meeting', kind='meetings') |
517 | 587 |
agenda.save() |
tests/test_manager.py | ||
---|---|---|
11 | 11 |
from django.contrib.auth.models import User, Group |
12 | 12 |
from django.core.management import call_command |
13 | 13 |
from django.db import connection |
14 |
from django.test import override_settings |
|
14 | 15 |
from django.test.utils import CaptureQueriesContext |
15 | 16 |
from django.utils.encoding import force_text |
16 | 17 |
from django.utils.timezone import make_aware, now, localtime |
17 | 18 | |
19 |
import datetime |
|
18 | 20 |
import freezegun |
19 | 21 |
import pytest |
20 | 22 |
import requests |
... | ... | |
2451 | 2453 |
assert exceptions[0].pk != new_exceptions[0].pk |
2452 | 2454 | |
2453 | 2455 | |
2456 |
@override_settings( |
|
2457 |
EXCEPTIONS_SOURCES={'holidays': {'class': 'workalendar.europe.France', 'label': 'Holidays'},} |
|
2458 |
) |
|
2459 |
def test_meetings_agenda_time_period_exception_source_from_settings(app, admin_user): |
|
2460 |
agenda = Agenda.objects.create(label='Foo bar', kind='meetings') |
|
2461 |
desk = Desk.objects.create(agenda=agenda, label='Desk A') |
|
2462 |
MeetingType(agenda=agenda, label='Blah').save() |
|
2463 |
TimePeriod.objects.create( |
|
2464 |
weekday=1, desk=desk, start_time=datetime.time(10, 0), end_time=datetime.time(12, 0) |
|
2465 |
) |
|
2466 |
assert TimePeriodException.objects.exists() |
|
2467 | ||
2468 |
login(app) |
|
2469 |
resp = app.get('/manage/agendas/%d/' % agenda.pk).follow() |
|
2470 |
resp = resp.click('Settings') |
|
2471 |
resp = resp.click('upload') |
|
2472 |
assert 'Holidays' in resp.text |
|
2473 |
assert 'disabled' not in resp.text |
|
2474 |
assert 'refresh' not in resp.text |
|
2475 | ||
2476 |
resp = resp.click('disable').follow() |
|
2477 |
assert not TimePeriodException.objects.exists() |
|
2478 | ||
2479 |
resp = resp.click('upload') |
|
2480 |
assert 'Holidays' in resp.text |
|
2481 |
assert 'disabled' in resp.text |
|
2482 | ||
2483 |
resp = resp.click('enable').follow() |
|
2484 |
assert TimePeriodException.objects.exists() |
|
2485 | ||
2486 |
resp = resp.click('upload') |
|
2487 |
assert 'disabled' not in resp.text |
|
2488 | ||
2489 | ||
2490 |
def test_meetings_agenda_time_period_exception_source_try_disable_ics(app, admin_user): |
|
2491 |
agenda = Agenda.objects.create(label='Foo bar', kind='meetings') |
|
2492 |
desk = Desk.objects.create(agenda=agenda, label='Desk A') |
|
2493 |
MeetingType(agenda=agenda, label='Blah').save() |
|
2494 |
TimePeriod.objects.create( |
|
2495 |
weekday=1, desk=desk, start_time=datetime.time(10, 0), end_time=datetime.time(12, 0) |
|
2496 |
) |
|
2497 |
source = TimePeriodExceptionSource.objects.create(desk=desk, ics_url='https://example.com/test.ics') |
|
2498 | ||
2499 |
login(app) |
|
2500 |
resp = app.get('/manage/agendas/%d/' % agenda.pk).follow() |
|
2501 |
resp = resp.click('Settings') |
|
2502 |
resp = resp.click('upload') |
|
2503 |
assert 'test.ics' in resp.text |
|
2504 | ||
2505 |
assert app.get('/manage/time-period-exceptions-source/%s/toggle' % source.pk, status=404) |
|
2506 | ||
2507 | ||
2454 | 2508 |
def test_agenda_day_view(app, admin_user, manager_user, api_user): |
2455 | 2509 |
agenda = Agenda.objects.create(label='New Example', kind='meetings') |
2456 | 2510 |
desk = Desk.objects.create(agenda=agenda, label='New Desk') |
2457 |
- |