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 | |
... | ... | |
515 | 516 |
unique_together = ['agenda', 'slug'] |
516 | 517 | |
517 | 518 |
def save(self, *args, **kwargs): |
519 |
first_created = not self.pk |
|
518 | 520 |
if not self.slug: |
519 | 521 |
self.slug = generate_slug(self, agenda=self.agenda) |
520 | 522 |
super(Desk, self).save(*args, **kwargs) |
523 |
if first_created: |
|
524 |
self.import_timeperiod_exceptions_from_settings(enable=True) |
|
521 | 525 | |
522 | 526 |
@classmethod |
523 | 527 |
def import_json(cls, data): |
... | ... | |
691 | 695 | |
692 | 696 |
return openslots.search(aware_date, aware_next_date) |
693 | 697 | |
698 |
def import_timeperiod_exceptions_from_settings(self, enable=False): |
|
699 |
start_update = now() |
|
700 |
for slug, source_info in settings.EXCEPTIONS_SOURCES.items(): |
|
701 |
label = source_info['label'] |
|
702 |
source, created = TimePeriodExceptionSource.objects.update_or_create( |
|
703 |
desk=self, settings_slug=slug, defaults={'settings_label': _(label)} |
|
704 |
) |
|
705 |
if enable or source.enabled: # if already enabled, update anyway |
|
706 |
source.enable() |
|
707 |
TimePeriodExceptionSource.objects.filter( |
|
708 |
desk=self, settings_slug__isnull=False, last_update__lt=start_update |
|
709 |
).delete() # source was not in settings anymore |
|
710 | ||
694 | 711 | |
695 | 712 |
def ics_directory_path(instance, filename): |
696 | 713 |
return 'ics/{0}/{1}'.format(str(uuid.uuid4()), filename) |
... | ... | |
701 | 718 |
ics_filename = models.CharField(null=True, max_length=256) |
702 | 719 |
ics_file = models.FileField(upload_to=ics_directory_path, blank=True, null=True) |
703 | 720 |
ics_url = models.URLField(null=True, max_length=500) |
721 |
settings_slug = models.CharField(null=True, max_length=150) |
|
722 |
settings_label = models.CharField(null=True, max_length=150) |
|
723 |
last_update = models.DateTimeField(auto_now=True, null=True) |
|
724 |
enabled = models.BooleanField(default=True) |
|
704 | 725 | |
705 | 726 |
def __str__(self): |
706 | 727 |
if self.ics_filename is not None: |
707 | 728 |
return self.ics_filename |
729 |
if self.settings_label is not None: |
|
730 |
return ugettext(self.settings_label) |
|
708 | 731 |
return self.ics_url |
709 | 732 | |
733 |
def enable(self): |
|
734 |
source_info = settings.EXCEPTIONS_SOURCES.get(self.settings_slug) |
|
735 |
if not source_info: |
|
736 |
return |
|
737 |
self.timeperiodexception_set.all().delete() |
|
738 |
source_class = import_string(source_info['class']) |
|
739 |
calendar = source_class() |
|
740 |
this_year = now().year |
|
741 |
days = [day for year in range(this_year, this_year + 3) for day in calendar.holidays(year)] |
|
742 |
with transaction.atomic(): |
|
743 |
for day, label in days: |
|
744 |
start_datetime = make_aware(datetime.datetime.combine(day, datetime.datetime.min.time())) |
|
745 |
end_datetime = start_datetime + datetime.timedelta(days=1) |
|
746 |
TimePeriodException.objects.create( |
|
747 |
desk=self.desk, |
|
748 |
source=self, |
|
749 |
label=_(label), |
|
750 |
start_datetime=start_datetime, |
|
751 |
end_datetime=end_datetime, |
|
752 |
) |
|
753 |
self.enabled = True |
|
754 |
self.save() |
|
755 | ||
756 |
def disable(self): |
|
757 |
self.timeperiodexception_set.all().delete() |
|
758 |
self.enabled = False |
|
759 |
self.save() |
|
760 | ||
761 |
def delete(self, *args, **kwargs): |
|
762 |
if self.settings_slug is not None: |
|
763 |
self.disable() |
|
764 |
return 0, {} |
|
765 |
return super().delete(*args, **kwargs) |
|
766 | ||
710 | 767 | |
711 | 768 |
class TimePeriodException(models.Model): |
712 | 769 |
desk = models.ForeignKey(Desk, on_delete=models.CASCADE) |
chrono/manager/static/css/style.scss | ||
---|---|---|
274 | 274 |
} |
275 | 275 |
} |
276 | 276 | |
277 |
ul.objects-list.single-links li a.link-action-icon.show { |
|
278 |
&::before { |
|
279 |
content: "\f06e"; /* eye */ |
|
280 |
} |
|
281 |
} |
|
282 | ||
277 | 283 |
div.ui-dialog form p span.datetime input { |
278 | 284 |
width: auto; |
279 | 285 |
} |
chrono/manager/templates/chrono/manager_confirm_source_delete.html | ||
---|---|---|
9 | 9 |
<form method="post"> |
10 | 10 |
{% csrf_token %} |
11 | 11 |
<p> |
12 |
{% if not object.settings_slug %} |
|
12 | 13 |
{% blocktrans %}Are you sure you want to delete this exception source?{% endblocktrans %} |
14 |
{% else %} |
|
15 |
{% blocktrans %}Are you sure you want to disable this exception source?{% endblocktrans %} |
|
16 |
{% endif %} |
|
13 | 17 |
</p> |
14 | 18 |
<div class="buttons"> |
15 | 19 |
<button class="delete-button">{% trans 'Delete' %}</button> |
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 object.enabled %} |
|
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-icon show" href="{% url 'chrono-manager-time-period-exception-source-enable' object.pk %}">{% trans "enable" %}</a> |
|
30 |
{% endif %} |
|
27 | 31 |
</li> |
28 | 32 |
{% endfor %} |
29 | 33 |
</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+)/enable$', |
|
128 |
views.time_period_exception_source_enable, |
|
129 |
name='chrono-manager-time-period-exception-source-enable', |
|
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 | ||
---|---|---|
1049 | 1049 |
time_period_exception_source_refresh = TimePeriodExceptionSourceRefreshView.as_view() |
1050 | 1050 | |
1051 | 1051 | |
1052 |
class TimePeriodExceptionSourceEnableView(ManagedDeskSubobjectMixin, DetailView): |
|
1053 |
model = TimePeriodExceptionSource |
|
1054 | ||
1055 |
def get(self, request, *args, **kwargs): |
|
1056 |
source = self.get_object() |
|
1057 |
source.enable() |
|
1058 |
messages.info( |
|
1059 |
self.request, _('Exception source %s has been enabled on desk %s.' % (source, source.desk)) |
|
1060 |
) |
|
1061 |
return HttpResponseRedirect( |
|
1062 |
reverse('chrono-manager-agenda-settings', kwargs={'pk': source.desk.agenda_id}) |
|
1063 |
) |
|
1064 | ||
1065 | ||
1066 |
time_period_exception_source_enable = TimePeriodExceptionSourceEnableView.as_view() |
|
1067 | ||
1068 | ||
1052 | 1069 |
def menu_json(request): |
1053 | 1070 |
label = _('Agendas') |
1054 | 1071 |
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 |
# deleting only disable source |
|
461 |
source.delete() |
|
462 |
source.refresh_from_db() |
|
463 |
assert not source.enabled |
|
464 |
assert not TimePeriodException.objects.filter(desk=desk, source=source).exists() |
|
465 | ||
466 | ||
467 |
def test_timeperiodexception_from_settings_command(): |
|
468 |
setting = { |
|
469 |
'EXCEPTIONS_SOURCES': { |
|
470 |
'holidays': {'class': 'workalendar.europe.France', 'label': 'Holidays'}, |
|
471 |
} |
|
472 |
} |
|
473 |
agenda = Agenda(label=u'Test 1 agenda') |
|
474 |
agenda.save() |
|
475 |
desk1 = Desk(label='Test 1 desk', agenda=agenda) |
|
476 |
desk1.save() |
|
477 |
with override_settings(**setting): |
|
478 |
desk2 = Desk(label='Test 2 desk', agenda=agenda) |
|
479 |
desk2.save() |
|
480 |
desk3 = Desk(label='Test 3 desk', agenda=agenda) |
|
481 |
desk3.save() |
|
482 |
source3 = TimePeriodExceptionSource.objects.get(desk=desk3) |
|
483 |
source3.disable() |
|
484 | ||
485 |
call_command('sync_desks_timeperiod_exceptions_from_settings') |
|
486 |
assert not TimePeriodExceptionSource.objects.filter(desk=desk1).exists() |
|
487 |
source2 = TimePeriodExceptionSource.objects.get(desk=desk2) |
|
488 |
assert source2.enabled |
|
489 |
source3.refresh_from_db() |
|
490 |
assert not source3.enabled |
|
491 | ||
492 |
exceptions_count = source2.timeperiodexception_set.count() |
|
493 |
# Alsace Moselle has more holidays |
|
494 |
setting['EXCEPTIONS_SOURCES']['holidays']['class'] = 'workalendar.europe.FranceAlsaceMoselle' |
|
495 |
with override_settings(**setting): |
|
496 |
call_command('sync_desks_timeperiod_exceptions_from_settings') |
|
497 |
source2.refresh_from_db() |
|
498 |
assert exceptions_count < source2.timeperiodexception_set.count() |
|
499 | ||
500 |
setting['EXCEPTIONS_SOURCES'] = {} |
|
501 |
with override_settings(**setting): |
|
502 |
call_command('sync_desks_timeperiod_exceptions_from_settings') |
|
503 |
assert not TimePeriodExceptionSource.objects.exists() |
|
504 | ||
505 | ||
429 | 506 |
def test_base_meeting_duration(): |
430 | 507 |
agenda = Agenda(label='Meeting', kind='meetings') |
431 | 508 |
agenda.save() |
tests/test_manager.py | ||
---|---|---|
8 | 8 |
from django.contrib.auth.models import User, Group |
9 | 9 |
from django.utils.encoding import force_text |
10 | 10 |
from django.utils.timezone import make_aware, now, localtime |
11 |
from django.test import override_settings |
|
11 | 12 |
import datetime |
12 | 13 |
import freezegun |
13 | 14 |
import mock |
... | ... | |
1624 | 1625 |
assert exceptions[0].pk != new_exceptions[0].pk |
1625 | 1626 | |
1626 | 1627 | |
1628 |
@override_settings(EXCEPTIONS_SOURCES={ |
|
1629 |
'holidays': {'class': 'workalendar.europe.France', 'label': 'Holidays'}, |
|
1630 |
}) |
|
1631 |
def test_meetings_agenda_time_period_exception_source_from_settings(app, admin_user): |
|
1632 |
agenda = Agenda.objects.create(label='Foo bar', kind='meetings') |
|
1633 |
desk = Desk.objects.create(agenda=agenda, label='Desk A') |
|
1634 |
MeetingType(agenda=agenda, label='Blah').save() |
|
1635 |
TimePeriod.objects.create( |
|
1636 |
weekday=1, desk=desk, start_time=datetime.time(10, 0), end_time=datetime.time(12, 0) |
|
1637 |
) |
|
1638 |
assert TimePeriodException.objects.exists() |
|
1639 | ||
1640 |
login(app) |
|
1641 |
resp = app.get('/manage/agendas/%d/' % agenda.pk).follow() |
|
1642 |
resp = resp.click('Settings') |
|
1643 |
resp = resp.click('upload') # TODO this view name is now incorrect, change it |
|
1644 |
assert 'Holidays' in resp.text |
|
1645 |
assert 'disabled' not in resp.text |
|
1646 |
assert 'refresh' not in resp.text |
|
1647 | ||
1648 |
resp = resp.click('remove') |
|
1649 |
resp = resp.form.submit().follow() |
|
1650 |
assert not TimePeriodException.objects.exists() |
|
1651 | ||
1652 |
resp = resp.click('upload') |
|
1653 |
assert 'Holidays' in resp.text |
|
1654 |
assert 'disabled' in resp.text |
|
1655 |
assert 'remove' not in resp.text |
|
1656 | ||
1657 |
resp = resp.click('enable').follow() |
|
1658 |
assert TimePeriodException.objects.exists() |
|
1659 | ||
1660 |
resp = resp.click('upload') |
|
1661 |
assert 'disabled' not in resp.text |
|
1662 | ||
1627 | 1663 |
def test_agenda_day_view(app, admin_user, manager_user, api_user): |
1628 | 1664 |
agenda = Agenda.objects.create(label='New Example', kind='meetings') |
1629 | 1665 |
desk = Desk.objects.create(agenda=agenda, label='New Desk') |
1630 |
- |