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 TimePeriodExceptionSource |
|
20 | ||
21 | ||
22 |
class Command(BaseCommand): |
|
23 |
help = 'Synchronize time period exceptions from settings' |
|
24 | ||
25 |
def handle(self, **options): |
|
26 |
for source in TimePeriodExceptionSource.objects.filter(settings_slug__isnull=False): |
|
27 |
source.desk.import_timeperiod_exceptions_from_settings() |
chrono/agendas/migrations/0038_auto_20200220_1518.py | ||
---|---|---|
1 |
# -*- coding: utf-8 -*- |
|
2 |
# Generated by Django 1.11.18 on 2020-02-20 14:18 |
|
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', '0037_timeperiodexceptionsource_ics_file'), |
|
12 |
] |
|
13 | ||
14 |
operations = [ |
|
15 |
migrations.AddField( |
|
16 |
model_name='timeperiodexceptionsource', |
|
17 |
name='enabled', |
|
18 |
field=models.BooleanField(default=True), |
|
19 |
), |
|
20 |
migrations.AddField( |
|
21 |
model_name='timeperiodexceptionsource', |
|
22 |
name='last_update', |
|
23 |
field=models.DateTimeField(auto_now=True, null=True), |
|
24 |
), |
|
25 |
migrations.AddField( |
|
26 |
model_name='timeperiodexceptionsource', |
|
27 |
name='settings_label', |
|
28 |
field=models.CharField(max_length=150, null=True), |
|
29 |
), |
|
30 |
migrations.AddField( |
|
31 |
model_name='timeperiodexceptionsource', |
|
32 |
name='settings_slug', |
|
33 |
field=models.CharField(max_length=150, null=True), |
|
34 |
), |
|
35 |
] |
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 | |
... | ... | |
521 | 522 |
unique_together = ['agenda', 'slug'] |
522 | 523 | |
523 | 524 |
def save(self, *args, **kwargs): |
525 |
first_created = not self.pk |
|
524 | 526 |
if not self.slug: |
525 | 527 |
self.slug = generate_slug(self, agenda=self.agenda) |
526 | 528 |
super(Desk, self).save(*args, **kwargs) |
529 |
if first_created: |
|
530 |
self.import_timeperiod_exceptions_from_settings(enable=True) |
|
527 | 531 | |
528 | 532 |
@classmethod |
529 | 533 |
def import_json(cls, data): |
... | ... | |
697 | 701 | |
698 | 702 |
return openslots.search(aware_date, aware_next_date) |
699 | 703 | |
704 |
def import_timeperiod_exceptions_from_settings(self, enable=False): |
|
705 |
start_update = now() |
|
706 |
for slug, source_info in settings.EXCEPTIONS_SOURCES.items(): |
|
707 |
label = source_info['label'] |
|
708 |
source, created = TimePeriodExceptionSource.objects.update_or_create( |
|
709 |
desk=self, settings_slug=slug, defaults={'settings_label': _(label)} |
|
710 |
) |
|
711 |
if enable or source.enabled: # if already enabled, update anyway |
|
712 |
source.enable() |
|
713 |
TimePeriodExceptionSource.objects.filter( |
|
714 |
desk=self, settings_slug__isnull=False, last_update__lt=start_update |
|
715 |
).delete() # source was not in settings anymore |
|
716 | ||
700 | 717 | |
701 | 718 |
def ics_directory_path(instance, filename): |
702 | 719 |
return 'ics/{0}/{1}'.format(str(uuid.uuid4()), filename) |
... | ... | |
707 | 724 |
ics_filename = models.CharField(null=True, max_length=256) |
708 | 725 |
ics_file = models.FileField(upload_to=ics_directory_path, blank=True, null=True) |
709 | 726 |
ics_url = models.URLField(null=True, max_length=500) |
727 |
settings_slug = models.CharField(null=True, max_length=150) |
|
728 |
settings_label = models.CharField(null=True, max_length=150) |
|
729 |
last_update = models.DateTimeField(auto_now=True, null=True) |
|
730 |
enabled = models.BooleanField(default=True) |
|
710 | 731 | |
711 | 732 |
def __str__(self): |
712 | 733 |
if self.ics_filename is not None: |
713 | 734 |
return self.ics_filename |
735 |
if self.settings_label is not None: |
|
736 |
return ugettext(self.settings_label) |
|
714 | 737 |
return self.ics_url |
715 | 738 | |
739 |
def enable(self): |
|
740 |
source_info = settings.EXCEPTIONS_SOURCES.get(self.settings_slug) |
|
741 |
if not source_info: |
|
742 |
return |
|
743 |
self.timeperiodexception_set.all().delete() |
|
744 |
source_class = import_string(source_info['class']) |
|
745 |
calendar = source_class() |
|
746 |
this_year = now().year |
|
747 |
days = [day for year in range(this_year, this_year + 3) for day in calendar.holidays(year)] |
|
748 |
with transaction.atomic(): |
|
749 |
for day, label in days: |
|
750 |
start_datetime = make_aware(datetime.datetime.combine(day, datetime.datetime.min.time())) |
|
751 |
end_datetime = start_datetime + datetime.timedelta(days=1) |
|
752 |
TimePeriodException.objects.create( |
|
753 |
desk=self.desk, |
|
754 |
source=self, |
|
755 |
label=_(label), |
|
756 |
start_datetime=start_datetime, |
|
757 |
end_datetime=end_datetime, |
|
758 |
) |
|
759 |
self.enabled = True |
|
760 |
self.save() |
|
761 | ||
762 |
def disable(self): |
|
763 |
self.timeperiodexception_set.all().delete() |
|
764 |
self.enabled = False |
|
765 |
self.save() |
|
766 | ||
716 | 767 | |
717 | 768 |
class TimePeriodException(models.Model): |
718 | 769 |
desk = models.ForeignKey(Desk, on_delete=models.CASCADE) |
chrono/manager/static/css/style.scss | ||
---|---|---|
278 | 278 |
} |
279 | 279 |
} |
280 | 280 | |
281 |
ul.objects-list.single-links li a.link-action-icon.enable { |
|
282 |
&::before { |
|
283 |
content: "\f204"; /* toggle-off */ |
|
284 |
} |
|
285 |
} |
|
286 | ||
287 |
ul.objects-list.single-links li a.link-action-icon.disable { |
|
288 |
&::before { |
|
289 |
content: "\f205"; /* toggle-on */ |
|
290 |
} |
|
291 |
} |
|
292 | ||
281 | 293 |
div.ui-dialog form p span.datetime input { |
282 | 294 |
width: auto; |
283 | 295 |
} |
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 |
{% with status=object.enabled|yesno:"disable,enable" %} |
|
30 |
<a class="link-action-icon {{ status }}" href="{% url 'chrono-manager-time-period-exception-source-toggle' object.pk %}">{% blocktrans %}{{ status }}{% endblocktrans %}</a> |
|
31 |
{% endwith %} |
|
32 |
{% endif %} |
|
27 | 33 |
</li> |
28 | 34 |
{% endfor %} |
29 | 35 |
</ul> |
chrono/manager/urls.py | ||
---|---|---|
123 | 123 |
views.time_period_exception_source_refresh, |
124 | 124 |
name='chrono-manager-time-period-exception-source-refresh', |
125 | 125 |
), |
126 |
url( |
|
127 |
r'^time-period-exceptions-source/(?P<pk>\d+)/toggle$', |
|
128 |
views.time_period_exception_source_toggle, |
|
129 |
name='chrono-manager-time-period-exception-source-toggle', |
|
130 |
), |
|
126 | 131 |
url( |
127 | 132 |
r'^time-period-exceptions-source/(?P<pk>\d+)/replace$', |
128 | 133 |
views.time_period_exception_source_replace, |
chrono/manager/views.py | ||
---|---|---|
1080 | 1080 |
time_period_exception_source_refresh = TimePeriodExceptionSourceRefreshView.as_view() |
1081 | 1081 | |
1082 | 1082 | |
1083 |
class TimePeriodExceptionSourceToggleView(ManagedDeskSubobjectMixin, DetailView): |
|
1084 |
model = TimePeriodExceptionSource |
|
1085 | ||
1086 |
def get_object(self, queryset=None): |
|
1087 |
source = super().get_object(queryset) |
|
1088 |
if source.settings_slug is None: |
|
1089 |
raise Http404('This source cannot be enabled nor disabled') |
|
1090 |
return source |
|
1091 | ||
1092 |
def get(self, request, *args, **kwargs): |
|
1093 |
source = self.get_object() |
|
1094 |
if source.enabled: |
|
1095 |
source.disable() |
|
1096 |
message = _('Exception source %(source)s has been disabled on desk %(desk)s.') |
|
1097 |
else: |
|
1098 |
source.enable() |
|
1099 |
message = _('Exception source %(source)s has been enabled on desk %(desk)s.') |
|
1100 |
messages.info(self.request, message % {'source': source, 'desk': source.desk}) |
|
1101 |
return HttpResponseRedirect( |
|
1102 |
reverse('chrono-manager-agenda-settings', kwargs={'pk': source.desk.agenda_id}) |
|
1103 |
) |
|
1104 | ||
1105 | ||
1106 |
time_period_exception_source_toggle = TimePeriodExceptionSourceToggleView.as_view() |
|
1107 | ||
1108 | ||
1083 | 1109 |
def menu_json(request): |
1084 | 1110 |
label = _('Agendas') |
1085 | 1111 |
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 |
) |
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 | ||
---|---|---|
4 | 4 |
REST_FRAMEWORK = { |
5 | 5 |
'DEFAULT_AUTHENTICATION_CLASSES': ['rest_framework.authentication.BasicAuthentication'], |
6 | 6 |
} |
7 | ||
8 |
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 ( |
... | ... | |
426 | 427 |
assert import_file_ics.call_args_list == [] |
427 | 428 | |
428 | 429 | |
430 |
@override_settings(EXCEPTIONS_SOURCES={ |
|
431 |
'holidays': {'class': 'workalendar.europe.France', 'label': 'Holidays'}, |
|
432 |
}) |
|
433 |
def test_timeperiodexception_from_settings(): |
|
434 |
agenda = Agenda(label=u'Test 1 agenda') |
|
435 |
agenda.save() |
|
436 |
desk = Desk(label='Test 1 desk', agenda=agenda) |
|
437 |
desk.save() |
|
438 | ||
439 |
# first save automatically load exceptions |
|
440 |
source = TimePeriodExceptionSource.objects.get(desk=desk) |
|
441 |
assert source.settings_slug == 'holidays' |
|
442 |
assert source.enabled |
|
443 |
assert TimePeriodException.objects.filter(desk=desk, source=source).exists() |
|
444 | ||
445 |
exception = TimePeriodException.objects.first() |
|
446 |
from workalendar.europe import France |
|
447 |
date, label = France().holidays()[0] |
|
448 |
exception = TimePeriodException.objects.filter(label=label).first() |
|
449 |
assert exception.end_datetime - exception.start_datetime == datetime.timedelta(days=1) |
|
450 |
assert localtime(exception.start_datetime).date() == date |
|
451 | ||
452 |
source.disable() |
|
453 |
assert not source.enabled |
|
454 |
assert not TimePeriodException.objects.filter(desk=desk, source=source).exists() |
|
455 | ||
456 |
source.enable() |
|
457 |
assert source.enabled |
|
458 |
assert TimePeriodException.objects.filter(desk=desk, source=source).exists() |
|
459 | ||
460 | ||
461 |
def test_timeperiodexception_from_settings_command(): |
|
462 |
setting = { |
|
463 |
'EXCEPTIONS_SOURCES': { |
|
464 |
'holidays': {'class': 'workalendar.europe.France', 'label': 'Holidays'}, |
|
465 |
} |
|
466 |
} |
|
467 |
agenda = Agenda(label=u'Test 1 agenda') |
|
468 |
agenda.save() |
|
469 |
desk1 = Desk(label='Test 1 desk', agenda=agenda) |
|
470 |
desk1.save() |
|
471 |
with override_settings(**setting): |
|
472 |
desk2 = Desk(label='Test 2 desk', agenda=agenda) |
|
473 |
desk2.save() |
|
474 |
desk3 = Desk(label='Test 3 desk', agenda=agenda) |
|
475 |
desk3.save() |
|
476 |
source3 = TimePeriodExceptionSource.objects.get(desk=desk3) |
|
477 |
source3.disable() |
|
478 | ||
479 |
call_command('sync_desks_timeperiod_exceptions_from_settings') |
|
480 |
assert not TimePeriodExceptionSource.objects.filter(desk=desk1).exists() |
|
481 |
source2 = TimePeriodExceptionSource.objects.get(desk=desk2) |
|
482 |
assert source2.enabled |
|
483 |
source3.refresh_from_db() |
|
484 |
assert not source3.enabled |
|
485 | ||
486 |
exceptions_count = source2.timeperiodexception_set.count() |
|
487 |
# Alsace Moselle has more holidays |
|
488 |
setting['EXCEPTIONS_SOURCES']['holidays']['class'] = 'workalendar.europe.FranceAlsaceMoselle' |
|
489 |
with override_settings(**setting): |
|
490 |
call_command('sync_desks_timeperiod_exceptions_from_settings') |
|
491 |
source2.refresh_from_db() |
|
492 |
assert exceptions_count < source2.timeperiodexception_set.count() |
|
493 | ||
494 |
setting['EXCEPTIONS_SOURCES'] = {} |
|
495 |
with override_settings(**setting): |
|
496 |
call_command('sync_desks_timeperiod_exceptions_from_settings') |
|
497 |
assert not TimePeriodExceptionSource.objects.exists() |
|
498 | ||
499 | ||
429 | 500 |
def test_base_meeting_duration(): |
430 | 501 |
agenda = Agenda(label='Meeting', kind='meetings') |
431 | 502 |
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 |
... | ... | |
1638 | 1639 |
assert exceptions[0].pk != new_exceptions[0].pk |
1639 | 1640 | |
1640 | 1641 | |
1642 |
@override_settings(EXCEPTIONS_SOURCES={ |
|
1643 |
'holidays': {'class': 'workalendar.europe.France', 'label': 'Holidays'}, |
|
1644 |
}) |
|
1645 |
def test_meetings_agenda_time_period_exception_source_from_settings(app, admin_user): |
|
1646 |
agenda = Agenda.objects.create(label='Foo bar', kind='meetings') |
|
1647 |
desk = Desk.objects.create(agenda=agenda, label='Desk A') |
|
1648 |
MeetingType(agenda=agenda, label='Blah').save() |
|
1649 |
TimePeriod.objects.create( |
|
1650 |
weekday=1, desk=desk, start_time=datetime.time(10, 0), end_time=datetime.time(12, 0) |
|
1651 |
) |
|
1652 |
assert TimePeriodException.objects.exists() |
|
1653 | ||
1654 |
login(app) |
|
1655 |
resp = app.get('/manage/agendas/%d/' % agenda.pk).follow() |
|
1656 |
resp = resp.click('Settings') |
|
1657 |
resp = resp.click('upload') |
|
1658 |
assert 'Holidays' in resp.text |
|
1659 |
assert 'disabled' not in resp.text |
|
1660 |
assert 'refresh' not in resp.text |
|
1661 | ||
1662 |
resp = resp.click('disable').follow() |
|
1663 |
assert not TimePeriodException.objects.exists() |
|
1664 | ||
1665 |
resp = resp.click('upload') |
|
1666 |
assert 'Holidays' in resp.text |
|
1667 |
assert 'disabled' in resp.text |
|
1668 | ||
1669 |
resp = resp.click('enable').follow() |
|
1670 |
assert TimePeriodException.objects.exists() |
|
1671 | ||
1672 |
resp = resp.click('upload') |
|
1673 |
assert 'disabled' not in resp.text |
|
1674 | ||
1675 |
def test_meetings_agenda_time_period_exception_source_try_disable_ics(app, admin_user): |
|
1676 |
agenda = Agenda.objects.create(label='Foo bar', kind='meetings') |
|
1677 |
desk = Desk.objects.create(agenda=agenda, label='Desk A') |
|
1678 |
MeetingType(agenda=agenda, label='Blah').save() |
|
1679 |
TimePeriod.objects.create( |
|
1680 |
weekday=1, desk=desk, start_time=datetime.time(10, 0), end_time=datetime.time(12, 0) |
|
1681 |
) |
|
1682 |
source = TimePeriodExceptionSource.objects.create(desk=desk, ics_url='https://example.com/test.ics') |
|
1683 | ||
1684 |
login(app) |
|
1685 |
resp = app.get('/manage/agendas/%d/' % agenda.pk).follow() |
|
1686 |
resp = resp.click('Settings') |
|
1687 |
resp = resp.click('upload') |
|
1688 |
assert 'test.ics' in resp.text |
|
1689 | ||
1690 |
assert app.get('/manage/time-period-exceptions-source/%s/toggle' % source.pk, status=404) |
|
1691 | ||
1641 | 1692 |
def test_agenda_day_view(app, admin_user, manager_user, api_user): |
1642 | 1693 |
agenda = Agenda.objects.create(label='New Example', kind='meetings') |
1643 | 1694 |
desk = Desk.objects.create(agenda=agenda, label='New Desk') |
1644 |
- |