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/0041_auto_20200330_1803.py | ||
---|---|---|
1 |
# -*- coding: utf-8 -*- |
|
2 |
# Generated by Django 1.11.18 on 2020-03-30 16:03 |
|
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', '0040_timeperiod_agenda'), |
|
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 | ||
---|---|---|
31 | 31 |
from django.utils.dates import WEEKDAYS |
32 | 32 |
from django.utils.encoding import force_text |
33 | 33 |
from django.utils.formats import date_format |
34 |
from django.utils.module_loading import import_string |
|
34 | 35 |
from django.utils.text import slugify |
35 | 36 |
from django.utils.timezone import localtime, now, make_aware, make_naive, is_aware |
36 |
from django.utils.translation import ugettext_lazy as _ |
|
37 |
from django.utils.translation import ugettext, ugettext_lazy as _
|
|
37 | 38 | |
38 | 39 |
from jsonfield import JSONField |
39 | 40 | |
... | ... | |
735 | 736 | |
736 | 737 |
def save(self, *args, **kwargs): |
737 | 738 |
assert self.agenda.kind != 'virtual', "a desk can't reference a virtual agenda" |
739 |
first_created = not self.pk |
|
738 | 740 |
if not self.slug: |
739 | 741 |
self.slug = generate_slug(self, agenda=self.agenda) |
740 | 742 |
super(Desk, self).save(*args, **kwargs) |
743 |
if first_created: |
|
744 |
self.import_timeperiod_exceptions_from_settings(enable=True) |
|
741 | 745 | |
742 | 746 |
@classmethod |
743 | 747 |
def import_json(cls, data): |
... | ... | |
911 | 915 | |
912 | 916 |
return openslots.search(aware_date, aware_next_date) |
913 | 917 | |
918 |
def import_timeperiod_exceptions_from_settings(self, enable=False): |
|
919 |
start_update = now() |
|
920 |
for slug, source_info in settings.EXCEPTIONS_SOURCES.items(): |
|
921 |
label = source_info['label'] |
|
922 |
try: |
|
923 |
source = TimePeriodExceptionSource.objects.get(desk=self, settings_slug=slug) |
|
924 |
except TimePeriodExceptionSource.DoesNotExist: |
|
925 |
source = TimePeriodExceptionSource.objects.create( |
|
926 |
desk=self, settings_slug=slug, enabled=False |
|
927 |
) |
|
928 |
source.settings_label = _(label) |
|
929 |
source.save() |
|
930 |
if enable or source.enabled: # if already enabled, update anyway |
|
931 |
source.enable() |
|
932 |
TimePeriodExceptionSource.objects.filter( |
|
933 |
desk=self, settings_slug__isnull=False, last_update__lt=start_update |
|
934 |
).delete() # source was not in settings anymore |
|
935 | ||
914 | 936 | |
915 | 937 |
def ics_directory_path(instance, filename): |
916 | 938 |
return 'ics/{0}/{1}'.format(str(uuid.uuid4()), filename) |
... | ... | |
921 | 943 |
ics_filename = models.CharField(null=True, max_length=256) |
922 | 944 |
ics_file = models.FileField(upload_to=ics_directory_path, blank=True, null=True) |
923 | 945 |
ics_url = models.URLField(null=True, max_length=500) |
946 |
settings_slug = models.CharField(null=True, max_length=150) |
|
947 |
settings_label = models.CharField(null=True, max_length=150) |
|
948 |
last_update = models.DateTimeField(auto_now=True, null=True) |
|
949 |
enabled = models.BooleanField(default=True) |
|
924 | 950 | |
925 | 951 |
def __str__(self): |
926 | 952 |
if self.ics_filename is not None: |
927 | 953 |
return self.ics_filename |
954 |
if self.settings_label is not None: |
|
955 |
return ugettext(self.settings_label) |
|
928 | 956 |
return self.ics_url |
929 | 957 | |
958 |
def enable(self): |
|
959 |
source_info = settings.EXCEPTIONS_SOURCES.get(self.settings_slug) |
|
960 |
if not source_info: |
|
961 |
return |
|
962 |
source_class = import_string(source_info['class']) |
|
963 |
calendar = source_class() |
|
964 |
this_year = now().year |
|
965 |
days = [day for year in range(this_year, this_year + 3) for day in calendar.holidays(year)] |
|
966 |
with transaction.atomic(): |
|
967 |
self.timeperiodexception_set.all().delete() |
|
968 |
for day, label in days: |
|
969 |
start_datetime = make_aware(datetime.datetime.combine(day, datetime.datetime.min.time())) |
|
970 |
end_datetime = start_datetime + datetime.timedelta(days=1) |
|
971 |
TimePeriodException.objects.create( |
|
972 |
desk=self.desk, |
|
973 |
source=self, |
|
974 |
label=_(label), |
|
975 |
start_datetime=start_datetime, |
|
976 |
end_datetime=end_datetime, |
|
977 |
) |
|
978 |
self.enabled = True |
|
979 |
self.save() |
|
980 | ||
981 |
def disable(self): |
|
982 |
self.timeperiodexception_set.all().delete() |
|
983 |
self.enabled = False |
|
984 |
self.save() |
|
985 | ||
930 | 986 | |
931 | 987 |
class TimePeriodException(models.Model): |
932 | 988 |
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 | ||
---|---|---|
138 | 138 |
views.time_period_exception_source_refresh, |
139 | 139 |
name='chrono-manager-time-period-exception-source-refresh', |
140 | 140 |
), |
141 |
url( |
|
142 |
r'^time-period-exceptions-source/(?P<pk>\d+)/toggle$', |
|
143 |
views.time_period_exception_source_toggle, |
|
144 |
name='chrono-manager-time-period-exception-source-toggle', |
|
145 |
), |
|
141 | 146 |
url( |
142 | 147 |
r'^time-period-exceptions-source/(?P<pk>\d+)/replace$', |
143 | 148 |
views.time_period_exception_source_replace, |
chrono/manager/views.py | ||
---|---|---|
1227 | 1227 |
time_period_exception_source_refresh = TimePeriodExceptionSourceRefreshView.as_view() |
1228 | 1228 | |
1229 | 1229 | |
1230 |
class TimePeriodExceptionSourceToggleView(ManagedDeskSubobjectMixin, DetailView): |
|
1231 |
model = TimePeriodExceptionSource |
|
1232 | ||
1233 |
def get_object(self, queryset=None): |
|
1234 |
source = super().get_object(queryset) |
|
1235 |
if source.settings_slug is None: |
|
1236 |
raise Http404('This source cannot be enabled nor disabled') |
|
1237 |
return source |
|
1238 | ||
1239 |
def get(self, request, *args, **kwargs): |
|
1240 |
source = self.get_object() |
|
1241 |
if source.enabled: |
|
1242 |
source.disable() |
|
1243 |
message = _('Exception source %(source)s has been disabled on desk %(desk)s.') |
|
1244 |
else: |
|
1245 |
source.enable() |
|
1246 |
message = _('Exception source %(source)s has been enabled on desk %(desk)s.') |
|
1247 |
messages.info(self.request, message % {'source': source, 'desk': source.desk}) |
|
1248 |
return HttpResponseRedirect( |
|
1249 |
reverse('chrono-manager-agenda-settings', kwargs={'pk': source.desk.agenda_id}) |
|
1250 |
) |
|
1251 | ||
1252 | ||
1253 |
time_period_exception_source_toggle = TimePeriodExceptionSourceToggleView.as_view() |
|
1254 | ||
1255 | ||
1230 | 1256 |
def menu_json(request): |
1231 | 1257 |
label = _('Agendas') |
1232 | 1258 |
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 | |
... | ... | |
161 | 163 |
# (see http://docs.python-requests.org/en/master/user/advanced/#proxies) |
162 | 164 |
REQUESTS_PROXIES = None |
163 | 165 | |
166 |
EXCEPTIONS_SOURCES = { |
|
167 |
'holidays': {'class': 'workalendar.europe.France', 'label': _('Holidays')}, |
|
168 |
} |
|
169 | ||
164 | 170 |
local_settings_file = os.environ.get( |
165 | 171 |
'CHRONO_SETTINGS_FILE', os.path.join(os.path.dirname(__file__), 'local_settings.py') |
166 | 172 |
) |
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 | ||
---|---|---|
13 | 13 |
'TEST': {'NAME': 'chrono-test-%s' % os.environ.get("BRANCH_NAME", "").replace('/', '-')[:63],}, |
14 | 14 |
} |
15 | 15 |
} |
16 | ||
17 |
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 ( |
... | ... | |
438 | 439 |
assert import_file_ics.call_args_list == [] |
439 | 440 | |
440 | 441 | |
442 |
@override_settings( |
|
443 |
EXCEPTIONS_SOURCES={'holidays': {'class': 'workalendar.europe.France', 'label': 'Holidays'},} |
|
444 |
) |
|
445 |
def test_timeperiodexception_from_settings(): |
|
446 |
agenda = Agenda(label=u'Test 1 agenda') |
|
447 |
agenda.save() |
|
448 |
desk = Desk(label='Test 1 desk', agenda=agenda) |
|
449 |
desk.save() |
|
450 | ||
451 |
# first save automatically load exceptions |
|
452 |
source = TimePeriodExceptionSource.objects.get(desk=desk) |
|
453 |
assert source.settings_slug == 'holidays' |
|
454 |
assert source.enabled |
|
455 |
assert TimePeriodException.objects.filter(desk=desk, source=source).exists() |
|
456 | ||
457 |
exception = TimePeriodException.objects.first() |
|
458 |
from workalendar.europe import France |
|
459 | ||
460 |
date, label = France().holidays()[0] |
|
461 |
exception = TimePeriodException.objects.filter(label=label).first() |
|
462 |
assert exception.end_datetime - exception.start_datetime == datetime.timedelta(days=1) |
|
463 |
assert localtime(exception.start_datetime).date() == date |
|
464 | ||
465 |
source.disable() |
|
466 |
assert not source.enabled |
|
467 |
assert not TimePeriodException.objects.filter(desk=desk, source=source).exists() |
|
468 | ||
469 |
source.enable() |
|
470 |
assert source.enabled |
|
471 |
assert TimePeriodException.objects.filter(desk=desk, source=source).exists() |
|
472 | ||
473 | ||
474 |
def test_timeperiodexception_from_settings_command(): |
|
475 |
setting = { |
|
476 |
'EXCEPTIONS_SOURCES': {'holidays': {'class': 'workalendar.europe.France', 'label': 'Holidays'},} |
|
477 |
} |
|
478 |
agenda = Agenda(label=u'Test 1 agenda') |
|
479 |
agenda.save() |
|
480 |
desk1 = Desk(label='Test 1 desk', agenda=agenda) |
|
481 |
desk1.save() |
|
482 |
with override_settings(**setting): |
|
483 |
desk2 = Desk(label='Test 2 desk', agenda=agenda) |
|
484 |
desk2.save() |
|
485 |
desk3 = Desk(label='Test 3 desk', agenda=agenda) |
|
486 |
desk3.save() |
|
487 |
source3 = TimePeriodExceptionSource.objects.get(desk=desk3) |
|
488 |
source3.disable() |
|
489 | ||
490 |
call_command('sync_desks_timeperiod_exceptions_from_settings') |
|
491 |
assert not TimePeriodExceptionSource.objects.get(desk=desk1).enabled |
|
492 |
source2 = TimePeriodExceptionSource.objects.get(desk=desk2) |
|
493 |
assert source2.enabled |
|
494 |
source3.refresh_from_db() |
|
495 |
assert not source3.enabled |
|
496 | ||
497 |
exceptions_count = source2.timeperiodexception_set.count() |
|
498 |
# Alsace Moselle has more holidays |
|
499 |
setting['EXCEPTIONS_SOURCES']['holidays']['class'] = 'workalendar.europe.FranceAlsaceMoselle' |
|
500 |
with override_settings(**setting): |
|
501 |
call_command('sync_desks_timeperiod_exceptions_from_settings') |
|
502 |
source2.refresh_from_db() |
|
503 |
assert exceptions_count < source2.timeperiodexception_set.count() |
|
504 | ||
505 |
setting['EXCEPTIONS_SOURCES'] = {} |
|
506 |
with override_settings(**setting): |
|
507 |
call_command('sync_desks_timeperiod_exceptions_from_settings') |
|
508 |
assert not TimePeriodExceptionSource.objects.exists() |
|
509 | ||
510 | ||
441 | 511 |
def test_base_meeting_duration(): |
442 | 512 |
agenda = Agenda(label='Meeting', kind='meetings') |
443 | 513 |
agenda.save() |
tests/test_manager.py | ||
---|---|---|
9 | 9 |
from django.contrib.auth.models import User, Group |
10 | 10 |
from django.utils.encoding import force_text |
11 | 11 |
from django.utils.timezone import make_aware, now, localtime |
12 |
from django.test import override_settings |
|
12 | 13 |
import datetime |
13 | 14 |
import freezegun |
14 | 15 |
import mock |
... | ... | |
1665 | 1666 |
assert exceptions[0].pk != new_exceptions[0].pk |
1666 | 1667 | |
1667 | 1668 | |
1669 |
@override_settings( |
|
1670 |
EXCEPTIONS_SOURCES={'holidays': {'class': 'workalendar.europe.France', 'label': 'Holidays'},} |
|
1671 |
) |
|
1672 |
def test_meetings_agenda_time_period_exception_source_from_settings(app, admin_user): |
|
1673 |
agenda = Agenda.objects.create(label='Foo bar', kind='meetings') |
|
1674 |
desk = Desk.objects.create(agenda=agenda, label='Desk A') |
|
1675 |
MeetingType(agenda=agenda, label='Blah').save() |
|
1676 |
TimePeriod.objects.create( |
|
1677 |
weekday=1, desk=desk, start_time=datetime.time(10, 0), end_time=datetime.time(12, 0) |
|
1678 |
) |
|
1679 |
assert TimePeriodException.objects.exists() |
|
1680 | ||
1681 |
login(app) |
|
1682 |
resp = app.get('/manage/agendas/%d/' % agenda.pk).follow() |
|
1683 |
resp = resp.click('Settings') |
|
1684 |
resp = resp.click('upload') |
|
1685 |
assert 'Holidays' in resp.text |
|
1686 |
assert 'disabled' not in resp.text |
|
1687 |
assert 'refresh' not in resp.text |
|
1688 | ||
1689 |
resp = resp.click('disable').follow() |
|
1690 |
assert not TimePeriodException.objects.exists() |
|
1691 | ||
1692 |
resp = resp.click('upload') |
|
1693 |
assert 'Holidays' in resp.text |
|
1694 |
assert 'disabled' in resp.text |
|
1695 | ||
1696 |
resp = resp.click('enable').follow() |
|
1697 |
assert TimePeriodException.objects.exists() |
|
1698 | ||
1699 |
resp = resp.click('upload') |
|
1700 |
assert 'disabled' not in resp.text |
|
1701 | ||
1702 | ||
1703 |
def test_meetings_agenda_time_period_exception_source_try_disable_ics(app, admin_user): |
|
1704 |
agenda = Agenda.objects.create(label='Foo bar', kind='meetings') |
|
1705 |
desk = Desk.objects.create(agenda=agenda, label='Desk A') |
|
1706 |
MeetingType(agenda=agenda, label='Blah').save() |
|
1707 |
TimePeriod.objects.create( |
|
1708 |
weekday=1, desk=desk, start_time=datetime.time(10, 0), end_time=datetime.time(12, 0) |
|
1709 |
) |
|
1710 |
source = TimePeriodExceptionSource.objects.create(desk=desk, ics_url='https://example.com/test.ics') |
|
1711 | ||
1712 |
login(app) |
|
1713 |
resp = app.get('/manage/agendas/%d/' % agenda.pk).follow() |
|
1714 |
resp = resp.click('Settings') |
|
1715 |
resp = resp.click('upload') |
|
1716 |
assert 'test.ics' in resp.text |
|
1717 | ||
1718 |
assert app.get('/manage/time-period-exceptions-source/%s/toggle' % source.pk, status=404) |
|
1719 | ||
1720 | ||
1668 | 1721 |
def test_agenda_day_view(app, admin_user, manager_user, api_user): |
1669 | 1722 |
agenda = Agenda.objects.create(label='New Example', kind='meetings') |
1670 | 1723 |
desk = Desk.objects.create(agenda=agenda, label='New Desk') |
1671 |
- |