Compare commits

..

10 Commits

Author SHA1 Message Date
Valentin Deniaud f6792b339f manager: allow separate arrival/departure check for partial bookings (#80047)
gitea/chrono/pipeline/head This commit looks good Details
2023-10-31 10:04:50 +01:00
Emmanuel Cazenave 36d1ea9ec0 setup: compute pep440 compliant dirty version number (#81731)
gitea/chrono/pipeline/head This commit looks good Details
2023-10-30 17:40:37 +01:00
Lauréline Guérin 368c239218 manager: fix wording in partial booking day view (#82840)
gitea/chrono/pipeline/head This commit looks good Details
2023-10-30 16:10:06 +01:00
Lauréline Guérin 81aa0d95fc manager: partial bookings, no delete option if no check object (#82840) 2023-10-30 16:10:06 +01:00
Lauréline Guérin 61a6bc35bb
misc: fix failing tests at midnight (#82920)
gitea/chrono/pipeline/head This commit looks good Details
2023-10-28 00:43:33 +02:00
Lauréline Guérin b15e4a3c7c
api: complete agendas dependencies (#82713)
gitea/chrono/pipeline/head This commit looks good Details
2023-10-27 15:51:50 +02:00
Lauréline Guérin f5e3f625d2
api: make import/export endpoints generic for other kinds of objects (#82713) 2023-10-27 15:51:50 +02:00
Lauréline Guérin a940ee3961 api: export/import, add uuid for role in dependencies view (#82764)
gitea/chrono/pipeline/head This commit looks good Details
2023-10-27 15:49:46 +02:00
Lauréline Guérin 9defbefe1e
misc: fix failing tests at midnight (#82753)
gitea/chrono/pipeline/head This commit looks good Details
2023-10-27 10:40:29 +02:00
Emmanuel Cazenave cba5520541 api: add module with applification API (#82198)
gitea/chrono/pipeline/head This commit looks good Details
2023-10-17 10:29:46 +02:00
17 changed files with 1156 additions and 25 deletions

View File

@ -308,6 +308,10 @@ class Agenda(models.Model):
validators=[MaxValueValidator(59)],
)
application_component_type = 'agendas'
application_label_singular = _('Agenda')
application_label_plural = _('Agendas')
class Meta:
ordering = ['label']
@ -438,6 +442,19 @@ class Agenda(models.Model):
raise ValueError()
return gcd
def get_dependencies(self):
yield self.view_role
yield self.edit_role
yield self.category
if self.kind == 'virtual':
yield from self.real_agendas.all()
if self.kind == 'meetings':
yield from self.resources.all()
for desk in self.desk_set.all():
yield from desk.get_dependencies()
if self.kind == 'events':
yield self.events_type
def export_json(self):
agenda = {
'label': self.label,
@ -2711,6 +2728,10 @@ class EventsType(models.Model):
label = models.CharField(_('Label'), max_length=150)
custom_fields = models.JSONField(blank=True, default=list)
application_component_type = 'events_types'
application_label_singular = _('Events type')
application_label_plural = _('Events types')
def __str__(self):
return self.label
@ -2742,6 +2763,9 @@ class EventsType(models.Model):
custom_fields.append(values)
return custom_fields
def get_dependencies(self):
return []
@classmethod
def import_json(cls, data, overwrite=False):
data = clean_import_data(cls, data)
@ -3092,7 +3116,12 @@ class BookingCheck(models.Model):
if len(booking_checks) > 1:
raise ValueError('too many booking checks') # should not happen
return bool(start_time < booking_checks[0].end_time and end_time > booking_checks[0].start_time)
booking_check = booking_checks[0]
if not start_time or not end_time:
return bool(booking_check.start_time < (start_time or end_time) < booking_check.end_time)
return bool(start_time < booking_check.end_time and end_time > booking_check.start_time)
OpeningHour = collections.namedtuple('OpeningHour', ['begin', 'end'])
@ -3120,6 +3149,9 @@ class Desk(models.Model):
def base_slug(self):
return slugify(self.label)
def get_dependencies(self):
yield from self.unavailability_calendars.all()
@classmethod
def import_json(cls, data):
timeperiods = data.pop('timeperiods', [])
@ -3264,6 +3296,10 @@ class Resource(models.Model):
label = models.CharField(_('Label'), max_length=150)
description = models.TextField(_('Description'), blank=True, help_text=_('Optional description.'))
application_component_type = 'resources'
application_label_singular = _('Resource')
application_label_plural = _('Resources')
def __str__(self):
return self.label
@ -3285,6 +3321,9 @@ class Resource(models.Model):
group_ids = [x.id for x in user.groups.all()]
return self.agenda_set.filter(edit_role_id__in=group_ids).exists()
def get_dependencies(self):
return []
@classmethod
def import_json(cls, data, overwrite=False):
data = clean_import_data(cls, data)
@ -3304,6 +3343,10 @@ class Category(models.Model):
slug = models.SlugField(_('Identifier'), max_length=160, unique=True)
label = models.CharField(_('Label'), max_length=150)
application_component_type = 'agendas_categories'
application_label_singular = _('Category (agendas)')
application_label_plural = _('Categories (agendas)')
def __str__(self):
return self.label
@ -3319,6 +3362,9 @@ class Category(models.Model):
def base_slug(self):
return slugify(self.label)
def get_dependencies(self):
return []
@classmethod
def import_json(cls, data, overwrite=False):
data = clean_import_data(cls, data)
@ -3632,6 +3678,10 @@ class UnavailabilityCalendar(models.Model):
on_delete=models.SET_NULL,
)
application_component_type = 'unavailability_calendars'
application_label_singular = _('Unavailability calendar')
application_label_plural = _('Unavailability calendars')
class Meta:
ordering = ['label']
@ -3662,6 +3712,10 @@ class UnavailabilityCalendar(models.Model):
def get_absolute_url(self):
return reverse('chrono-manager-unavailability-calendar-view', kwargs={'pk': self.id})
def get_dependencies(self):
yield self.view_role
yield self.edit_role
def export_json(self):
unavailability_calendar = {
'label': self.label,

View File

View File

@ -0,0 +1,269 @@
# chrono - content management system
# Copyright (C) 2016-2023 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/>.
import io
import json
import tarfile
from django.contrib.auth.models import Group
from django.http import Http404
from django.shortcuts import get_object_or_404, redirect
from django.urls import reverse
from rest_framework import permissions
from rest_framework.generics import GenericAPIView
from rest_framework.response import Response
from chrono.agendas.models import Agenda, Category, EventsType, Resource, UnavailabilityCalendar
from chrono.apps.export_import.models import Application, ApplicationElement
from chrono.manager.utils import import_site
klasses = {
klass.application_component_type: klass
for klass in [Agenda, Category, EventsType, Resource, UnavailabilityCalendar]
}
klasses_translation = {
'agendas_categories': 'categories', # categories type is already used in wcs for FormDef Category
}
klasses_translation_reverse = {v: k for k, v in klasses_translation.items()}
class Index(GenericAPIView):
permission_classes = (permissions.IsAuthenticatedOrReadOnly,)
def get(self, request, *args, **kwargs):
data = []
for klass in klasses.values():
component_type = {
'id': klass.application_component_type,
'text': klass.application_label_plural,
'singular': klass.application_label_singular,
'urls': {
'list': request.build_absolute_uri(
reverse(
'api-export-import-components-list',
kwargs={'component_type': klass.application_component_type},
)
),
},
}
if klass not in [Agenda]:
component_type['minor'] = True
data.append(component_type)
return Response({'data': data})
index = Index.as_view()
def get_component_bundle_entry(request, component):
return {
'id': str(component.slug),
'text': component.label,
'type': component.application_component_type,
'urls': {
'export': request.build_absolute_uri(
reverse(
'api-export-import-component-export',
kwargs={
'component_type': component.application_component_type,
'slug': str(component.slug),
},
)
),
'dependencies': request.build_absolute_uri(
reverse(
'api-export-import-component-dependencies',
kwargs={
'component_type': component.application_component_type,
'slug': str(component.slug),
},
)
),
'redirect': request.build_absolute_uri(
reverse(
'api-export-import-component-redirect',
kwargs={
'component_type': component.application_component_type,
'slug': str(component.slug),
},
)
),
},
}
class ListComponents(GenericAPIView):
permission_classes = (permissions.IsAuthenticatedOrReadOnly,)
def get(self, request, *args, **kwargs):
klass = klasses[kwargs['component_type']]
response = [get_component_bundle_entry(request, x) for x in klass.objects.order_by('slug')]
return Response({'data': response})
list_components = ListComponents.as_view()
class ExportComponent(GenericAPIView):
permission_classes = (permissions.IsAuthenticatedOrReadOnly,)
def get(self, request, slug, *args, **kwargs):
klass = klasses[kwargs['component_type']]
serialisation = klass.objects.get(slug=slug).export_json()
return Response({'data': serialisation})
export_component = ExportComponent.as_view()
class ComponentDependencies(GenericAPIView):
permission_classes = (permissions.IsAuthenticatedOrReadOnly,)
def get(self, request, slug, *args, **kwargs):
klass = klasses[kwargs['component_type']]
component = klass.objects.get(slug=slug)
def dependency_dict(element):
if isinstance(element, Group):
return {
'id': element.role.slug if hasattr(element, 'role') else element.id,
'text': element.name,
'type': 'roles',
'urls': {},
# include uuid in object reference, this is not used for applification API but is useful
# for authentic creating its role summary page.
'uuid': element.role.uuid if hasattr(element, 'role') else None,
}
return get_component_bundle_entry(request, element)
dependencies = [dependency_dict(x) for x in component.get_dependencies() if x]
return Response({'err': 0, 'data': dependencies})
component_dependencies = ComponentDependencies.as_view()
def component_redirect(request, component_type, slug):
klass = klasses[component_type]
component = get_object_or_404(klass, slug=slug)
if klass == Agenda:
return redirect(reverse('chrono-manager-agenda-view', kwargs={'pk': component.pk}))
raise Http404
class BundleCheck(GenericAPIView):
permission_classes = (permissions.IsAuthenticatedOrReadOnly,)
def put(self, request, *args, **kwargs):
return Response({'err': 0, 'data': {}})
bundle_check = BundleCheck.as_view()
class BundleImport(GenericAPIView):
permission_classes = (permissions.IsAuthenticatedOrReadOnly,)
install = True
def put(self, request, *args, **kwargs):
tar_io = io.BytesIO(request.read())
components = {}
with tarfile.open(fileobj=tar_io) as tar:
manifest = json.loads(tar.extractfile('manifest.json').read().decode())
self.application = Application.update_or_create_from_manifest(
manifest,
tar,
editable=not self.install,
)
for element in manifest.get('elements'):
component_type = element['type']
if component_type not in klasses or element['type'] == 'roles':
continue
component_type = klasses_translation.get(component_type, component_type)
if component_type not in components:
components[component_type] = []
component_content = (
tar.extractfile('%s/%s' % (element['type'], element['slug'])).read().decode()
)
components[component_type].append(json.loads(component_content).get('data'))
# init cache of application elements, from manifest
self.application_elements = set()
# import agendas
self.do_something(components)
# create application elements
self.link_objects(components)
# remove obsolete application elements
self.unlink_obsolete_objects()
return Response({'err': 0})
def do_something(self, components):
if components:
import_site(components)
def link_objects(self, components):
for component_type, component_list in components.items():
component_type = klasses_translation_reverse.get(component_type, component_type)
klass = klasses[component_type]
for component in component_list:
try:
existing_component = klass.objects.get(slug=component['slug'])
except klass.DoesNotExist:
pass
else:
element = ApplicationElement.update_or_create_for_object(
self.application, existing_component
)
self.application_elements.add(element.content_object)
def unlink_obsolete_objects(self):
known_elements = ApplicationElement.objects.filter(application=self.application)
for element in known_elements:
if element.content_object not in self.application_elements:
element.delete()
bundle_import = BundleImport.as_view()
class BundleDeclare(BundleImport):
install = False
def do_something(self, components):
# no installation on declare
pass
bundle_declare = BundleDeclare.as_view()
class BundleUnlink(GenericAPIView):
permission_classes = (permissions.IsAuthenticatedOrReadOnly,)
def post(self, request, *args, **kwargs):
if request.POST.get('application'):
try:
application = Application.objects.get(slug=request.POST['application'])
except Application.DoesNotExist:
pass
else:
application.delete()
return Response({'err': 0})
bundle_unlink = BundleUnlink.as_view()

View File

@ -0,0 +1,62 @@
# Generated by Django 3.2.18 on 2023-10-13 09:19
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
('contenttypes', '0002_remove_content_type_name'),
]
operations = [
migrations.CreateModel(
name='Application',
fields=[
(
'id',
models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
),
('name', models.CharField(max_length=100)),
('slug', models.SlugField(max_length=100, unique=True)),
('icon', models.FileField(blank=True, null=True, upload_to='applications/icons/')),
('description', models.TextField(blank=True)),
('documentation_url', models.URLField(blank=True)),
('version_number', models.CharField(max_length=100)),
('version_notes', models.TextField(blank=True)),
('editable', models.BooleanField(default=True)),
('visible', models.BooleanField(default=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
],
),
migrations.CreateModel(
name='ApplicationElement',
fields=[
(
'id',
models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
),
('object_id', models.PositiveIntegerField()),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
(
'application',
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, to='export_import.application'
),
),
(
'content_type',
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, to='contenttypes.contenttype'
),
),
],
options={
'unique_together': {('application', 'content_type', 'object_id')},
},
),
]

View File

@ -0,0 +1,131 @@
# chrono - content management system
# Copyright (C) 2016-2023 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/>.
import collections
from django.contrib.contenttypes.fields import GenericForeignKey
from django.contrib.contenttypes.models import ContentType
from django.db import models
class Application(models.Model):
name = models.CharField(max_length=100)
slug = models.SlugField(max_length=100, unique=True)
icon = models.FileField(
upload_to='applications/icons/',
blank=True,
null=True,
)
description = models.TextField(blank=True)
documentation_url = models.URLField(blank=True)
version_number = models.CharField(max_length=100)
version_notes = models.TextField(blank=True)
editable = models.BooleanField(default=True)
visible = models.BooleanField(default=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
def __str__(self):
return str(self.name)
@classmethod
def update_or_create_from_manifest(cls, manifest, tar, editable=False):
application, dummy = cls.objects.get_or_create(
slug=manifest.get('slug'), defaults={'editable': editable}
)
application.name = manifest.get('application')
application.description = manifest.get('description')
application.documentation_url = manifest.get('documentation_url')
application.version_number = manifest.get('version_number') or 'unknown'
application.version_notes = manifest.get('version_notes')
if not editable:
application.editable = editable
application.visible = manifest.get('visible', True)
application.save()
icon = manifest.get('icon')
if icon:
application.icon.save(icon, tar.extractfile(icon), save=True)
else:
application.icon.delete()
return application
@classmethod
def select_for_object_class(cls, object_class):
content_type = ContentType.objects.get_for_model(object_class)
elements = ApplicationElement.objects.filter(content_type=content_type)
return cls.objects.filter(pk__in=elements.values('application'), visible=True).order_by('name')
@classmethod
def populate_objects(cls, object_class, objects):
content_type = ContentType.objects.get_for_model(object_class)
elements = ApplicationElement.objects.filter(content_type=content_type)
elements_by_objects = collections.defaultdict(list)
for element in elements:
elements_by_objects[element.content_object].append(element)
applications_by_ids = {
a.pk: a for a in cls.objects.filter(pk__in=elements.values('application'), visible=True)
}
for obj in objects:
applications = []
elements = elements_by_objects.get(obj) or []
for element in elements:
application = applications_by_ids.get(element.application_id)
if application:
applications.append(application)
obj._applications = sorted(applications, key=lambda a: a.name)
@classmethod
def load_for_object(cls, obj):
content_type = ContentType.objects.get_for_model(obj.__class__)
elements = ApplicationElement.objects.filter(content_type=content_type, object_id=obj.pk)
applications_by_ids = {
a.pk: a for a in cls.objects.filter(pk__in=elements.values('application'), visible=True)
}
applications = []
for element in elements:
application = applications_by_ids.get(element.application_id)
if application:
applications.append(application)
obj._applications = sorted(applications, key=lambda a: a.name)
def get_objects_for_object_class(self, object_class):
content_type = ContentType.objects.get_for_model(object_class)
elements = ApplicationElement.objects.filter(content_type=content_type, application=self)
return object_class.objects.filter(pk__in=elements.values('object_id'))
class ApplicationElement(models.Model):
application = models.ForeignKey(Application, on_delete=models.CASCADE)
content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE)
object_id = models.PositiveIntegerField()
content_object = GenericForeignKey('content_type', 'object_id')
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
unique_together = ['application', 'content_type', 'object_id']
@classmethod
def update_or_create_for_object(cls, application, obj):
content_type = ContentType.objects.get_for_model(obj.__class__)
element, created = cls.objects.get_or_create(
application=application,
content_type=content_type,
object_id=obj.pk,
)
if not created:
element.save()
return element

View File

@ -0,0 +1,47 @@
# chrono - content management system
# Copyright (C) 2016-2023 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.urls import path
from . import api_views
urlpatterns = [
path('export-import/', api_views.index, name='api-export-import'),
path('export-import/bundle-check/', api_views.bundle_check),
path('export-import/bundle-declare/', api_views.bundle_declare),
path('export-import/bundle-import/', api_views.bundle_import),
path('export-import/unlink/', api_views.bundle_unlink),
path(
'export-import/<slug:component_type>/',
api_views.list_components,
name='api-export-import-components-list',
),
path(
'export-import/<slug:component_type>/<slug:slug>/',
api_views.export_component,
name='api-export-import-component-export',
),
path(
'export-import/<slug:component_type>/<slug:slug>/dependencies/',
api_views.component_dependencies,
name='api-export-import-component-dependencies',
),
path(
'export-import/<slug:component_type>/<slug:slug>/redirect/',
api_views.component_redirect,
name='api-export-import-component-redirect',
),
]

View File

@ -586,13 +586,7 @@ class BookingCheckPresenceForm(forms.Form):
class PartialBookingCheckForm(forms.ModelForm):
presence = forms.NullBooleanField(
label=_('Status'),
widget=forms.RadioSelect(
choices=(
(None, _('Not checked')),
(True, _('Present')),
(False, _('Absent')),
)
),
widget=forms.RadioSelect,
required=False,
)
presence_check_type = forms.ChoiceField(label=_('Type'), required=False)
@ -617,6 +611,13 @@ class PartialBookingCheckForm(forms.ModelForm):
absence_check_types = [(ct.slug, ct.label) for ct in self.check_types if ct.kind == 'absence']
presence_check_types = [(ct.slug, ct.label) for ct in self.check_types if ct.kind == 'presence']
presence_choices = []
if self.instance.pk:
presence_choices.append((None, _('Not checked')))
else:
self.initial['presence'] = True
presence_choices.append((True, _('Present')))
if presence_check_types:
self.fields['presence_check_type'].choices = [(None, '---------')] + presence_check_types
self.fields['presence_check_type'].initial = self.instance.type_slug
@ -632,20 +633,23 @@ class PartialBookingCheckForm(forms.ModelForm):
if not self.instance.booking.start_time:
self.fields['start_time'].widget = widgets.TimeWidget(step=60)
self.fields['end_time'].widget = widgets.TimeWidget(step=60)
self.fields['presence'].widget.choices = ((None, _('Not checked')), (True, _('Present')))
self.fields.pop('absence_check_type', None)
else:
presence_choices.append((False, _('Absent')))
self.fields['presence'].widget.choices = presence_choices
def clean(self):
if (
self.cleaned_data.get('start_time')
and self.cleaned_data.get('end_time')
and self.cleaned_data['end_time'] <= self.cleaned_data['start_time']
):
start_time = self.cleaned_data.get('start_time')
end_time = self.cleaned_data.get('end_time')
if not start_time and not end_time:
raise ValidationError(_('Both arrival and departure cannot not be empty.'))
if start_time and end_time and end_time <= start_time:
raise ValidationError(_('Arrival must be before departure.'))
if self.instance.overlaps_existing_check(
self.cleaned_data['start_time'], self.cleaned_data['end_time']
):
if self.instance.overlaps_existing_check(start_time, end_time):
raise ValidationError(_('Booking check hours overlap existing check.'))
if self.cleaned_data['presence'] is not None:

View File

@ -85,7 +85,7 @@
{% for check in user.booking_checks %}
<a
class="registrant--bar clearfix check {{ check.css_class }}"
title="{% trans "Checked period:" %}"
title="{% trans "Checked period" %}"
style="left: {{ check.css_left }}%; width: {{ check.css_width }}%;"
{% if allow_check %}
rel="popup"

View File

@ -106,6 +106,10 @@ def import_site(data, if_empty=False, clean=False, overwrite=False):
existing_roles_names = set(existing_roles.values_list('name', flat=True))
raise AgendaImportError('Missing roles: "%s"' % ', '.join(role_names - existing_roles_names))
# sort agendas to import virtual agendas first
if data.get('agendas'):
data['agendas'] = sorted(data['agendas'], key=lambda a: a['kind'] == 'virtual')
with transaction.atomic():
for cls, key in (
(Category, 'categories'),

View File

@ -58,6 +58,7 @@ INSTALLED_APPS = (
'chrono.api',
'chrono.manager',
'chrono.apps.ants_hub',
'chrono.apps.export_import',
'rest_framework',
'django_filters',
)

View File

@ -20,6 +20,8 @@ from django.contrib.auth.decorators import login_required
from django.contrib.staticfiles.urls import staticfiles_urlpatterns
from django.urls import include, path, re_path
from chrono.apps.export_import import urls as export_import_urls
from .api.urls import urlpatterns as chrono_api_urls
from .manager.urls import urlpatterns as chrono_manager_urls
from .urls_utils import decorated_includes
@ -29,6 +31,7 @@ urlpatterns = [
path('', homepage, name='home'),
re_path(r'^manage/', decorated_includes(login_required, include(chrono_manager_urls))),
path('api/', include(chrono_api_urls)),
path('api/', include(export_import_urls)),
path('logout/', LogoutView.as_view(), name='auth_logout'),
path('login/', LoginView.as_view(), name='auth_login'),
]

View File

@ -48,7 +48,7 @@ def get_version():
real_number, commit_count, commit_hash = result.split('-', 2)
version = '%s.post%s+%s' % (real_number, commit_count, commit_hash)
else:
version = result
version = result.replace('.dirty', '+dirty')
return version
else:
return '0.0.post%s' % len(subprocess.check_output(['git', 'rev-list', 'HEAD']).splitlines())

View File

@ -871,14 +871,14 @@ def test_meetings_and_virtual_datetimes_date_filter(app):
virtual_agenda.save()
# exclude weekday6 through date_end, 4 slots each day * 5 days
params = {'date_end': localtime(now() + datetime.timedelta(days=6)).date().isoformat()}
params = {'date_end': (localtime(now()).date() + datetime.timedelta(days=6)).isoformat()}
resp = app.get(foo_api_url, params=params)
assert len(resp.json['data']) == 20
resp = app.get(virtual_api_url, params=params)
assert len(resp.json['data']) == 20
params = {
'date_end': localtime(now() + datetime.timedelta(days=6))
'date_end': (localtime(now()) + datetime.timedelta(days=6))
.replace(hour=11, minute=0, second=0, microsecond=0)
.isoformat()
}

View File

@ -0,0 +1,506 @@
import io
import json
import re
import tarfile
import pytest
from django.contrib.auth.models import Group
from django.contrib.contenttypes.models import ContentType
from chrono.agendas.models import Agenda, Category, Desk, EventsType, Resource, UnavailabilityCalendar
from chrono.apps.export_import.models import Application, ApplicationElement
pytestmark = pytest.mark.django_db
def test_object_types(app, user):
app.authorization = ('Basic', ('john.doe', 'password'))
resp = app.get('/api/export-import/')
assert resp.json == {
'data': [
{
'id': 'agendas',
'text': 'Agendas',
'singular': 'Agenda',
'urls': {'list': 'http://testserver/api/export-import/agendas/'},
},
{
'id': 'agendas_categories',
'minor': True,
'singular': 'Category (agendas)',
'text': 'Categories (agendas)',
'urls': {'list': 'http://testserver/api/export-import/agendas_categories/'},
},
{
'id': 'events_types',
'minor': True,
'singular': 'Events type',
'text': 'Events types',
'urls': {'list': 'http://testserver/api/export-import/events_types/'},
},
{
'id': 'resources',
'minor': True,
'singular': 'Resource',
'text': 'Resources',
'urls': {'list': 'http://testserver/api/export-import/resources/'},
},
{
'id': 'unavailability_calendars',
'minor': True,
'singular': 'Unavailability calendar',
'text': 'Unavailability calendars',
'urls': {'list': 'http://testserver/api/export-import/unavailability_calendars/'},
},
]
}
def test_list_agendas(app, user):
app.authorization = ('Basic', ('john.doe', 'password'))
Agenda.objects.create(label='Rdv', slug='rdv', kind='meetings')
Agenda.objects.create(label='Event', slug='event', kind='events')
resp = app.get('/api/export-import/agendas/')
assert resp.json == {
'data': [
{
'id': 'event',
'text': 'Event',
'type': 'agendas',
'urls': {
'export': 'http://testserver/api/export-import/agendas/event/',
'dependencies': 'http://testserver/api/export-import/agendas/event/dependencies/',
'redirect': 'http://testserver/api/export-import/agendas/event/redirect/',
},
},
{
'id': 'rdv',
'text': 'Rdv',
'type': 'agendas',
'urls': {
'export': 'http://testserver/api/export-import/agendas/rdv/',
'dependencies': 'http://testserver/api/export-import/agendas/rdv/dependencies/',
'redirect': 'http://testserver/api/export-import/agendas/rdv/redirect/',
},
},
]
}
def test_export_agenda(app, user):
app.authorization = ('Basic', ('john.doe', 'password'))
group1 = Group.objects.create(name='group1')
group2 = Group.objects.create(name='group2')
Agenda.objects.create(label='Rdv', slug='rdv', kind='meetings', edit_role=group1, view_role=group2)
resp = app.get('/api/export-import/agendas/rdv/')
assert resp.json['data']['label'] == 'Rdv'
assert resp.json['data']['permissions'] == {'view': 'group2', 'edit': 'group1'}
def test_export_minor_components(app, user):
app.authorization = ('Basic', ('john.doe', 'password'))
Category.objects.create(slug='cat', label='Category')
Resource.objects.create(slug='foo', label='Foo')
EventsType.objects.create(slug='foo', label='Foo')
UnavailabilityCalendar.objects.create(slug='foo', label='Foo')
resp = app.get('/api/export-import/agendas_categories/cat/')
assert resp.json['data']['label'] == 'Category'
resp = app.get('/api/export-import/resources/foo/')
assert resp.json['data']['label'] == 'Foo'
resp = app.get('/api/export-import/events_types/foo/')
assert resp.json['data']['label'] == 'Foo'
resp = app.get('/api/export-import/unavailability_calendars/foo/')
assert resp.json['data']['label'] == 'Foo'
def test_agenda_dependencies_category(app, user):
app.authorization = ('Basic', ('john.doe', 'password'))
category = Category.objects.create(slug='cat', label='Category')
Agenda.objects.create(label='Rdv', slug='rdv', kind='meetings', category=category)
resp = app.get('/api/export-import/agendas/rdv/dependencies/')
assert resp.json == {
'data': [
{
'id': 'cat',
'text': 'Category',
'type': 'agendas_categories',
'urls': {
'dependencies': 'http://testserver/api/export-import/agendas_categories/cat/dependencies/',
'export': 'http://testserver/api/export-import/agendas_categories/cat/',
'redirect': 'http://testserver/api/export-import/agendas_categories/cat/redirect/',
},
}
],
'err': 0,
}
def test_agenda_dependencies_resources(app, user):
app.authorization = ('Basic', ('john.doe', 'password'))
meetings_agenda = Agenda.objects.create(label='Rdv', slug='rdv', kind='meetings')
meetings_agenda.resources.add(Resource.objects.create(slug='foo', label='Foo'))
resp = app.get('/api/export-import/agendas/rdv/dependencies/')
assert resp.json == {
'data': [
{
'id': 'foo',
'text': 'Foo',
'type': 'resources',
'urls': {
'dependencies': 'http://testserver/api/export-import/resources/foo/dependencies/',
'export': 'http://testserver/api/export-import/resources/foo/',
'redirect': 'http://testserver/api/export-import/resources/foo/redirect/',
},
}
],
'err': 0,
}
def test_agenda_dependencies_unavailability_calendars(app, user):
app.authorization = ('Basic', ('john.doe', 'password'))
meetings_agenda = Agenda.objects.create(label='Rdv', slug='rdv', kind='meetings')
desk = Desk.objects.create(slug='foo', label='Foo', agenda=meetings_agenda)
desk.unavailability_calendars.add(UnavailabilityCalendar.objects.create(slug='foo', label='Foo'))
resp = app.get('/api/export-import/agendas/rdv/dependencies/')
assert resp.json == {
'data': [
{
'id': 'foo',
'text': 'Foo',
'type': 'unavailability_calendars',
'urls': {
'dependencies': 'http://testserver/api/export-import/unavailability_calendars/foo/dependencies/',
'export': 'http://testserver/api/export-import/unavailability_calendars/foo/',
'redirect': 'http://testserver/api/export-import/unavailability_calendars/foo/redirect/',
},
}
],
'err': 0,
}
def test_agenda_dependencies_groups(app, user):
app.authorization = ('Basic', ('john.doe', 'password'))
group1 = Group.objects.create(name='group1')
group2 = Group.objects.create(name='group2')
Agenda.objects.create(label='Rdv', slug='rdv', kind='meetings', edit_role=group1, view_role=group2)
resp = app.get('/api/export-import/agendas/rdv/dependencies/')
# note: with hobo.agent.common installed, 'groups' will contain group slugs,
# not group id
assert resp.json == {
'data': [
{'id': group2.id, 'text': group2.name, 'type': 'roles', 'urls': {}, 'uuid': None},
{'id': group1.id, 'text': group1.name, 'type': 'roles', 'urls': {}, 'uuid': None},
],
'err': 0,
}
def test_agenda_dependencies_virtual_agendas(app, user):
app.authorization = ('Basic', ('john.doe', 'password'))
rdv1 = Agenda.objects.create(label='Rdv1', slug='rdv1', kind='meetings')
rdv2 = Agenda.objects.create(label='Rdv2', slug='rdv2', kind='meetings')
virt = Agenda.objects.create(label='Virt', slug='virt', kind='virtual')
virt.real_agendas.add(rdv1)
virt.real_agendas.add(rdv2)
resp = app.get('/api/export-import/agendas/virt/dependencies/')
assert resp.json == {
'data': [
{
'id': 'rdv1',
'text': 'Rdv1',
'type': 'agendas',
'urls': {
'dependencies': 'http://testserver/api/export-import/agendas/rdv1/dependencies/',
'export': 'http://testserver/api/export-import/agendas/rdv1/',
'redirect': 'http://testserver/api/export-import/agendas/rdv1/redirect/',
},
},
{
'id': 'rdv2',
'text': 'Rdv2',
'type': 'agendas',
'urls': {
'dependencies': 'http://testserver/api/export-import/agendas/rdv2/dependencies/',
'export': 'http://testserver/api/export-import/agendas/rdv2/',
'redirect': 'http://testserver/api/export-import/agendas/rdv2/redirect/',
},
},
],
'err': 0,
}
def test_agenda_dependencies_events_type(app, user):
app.authorization = ('Basic', ('john.doe', 'password'))
events_type = EventsType.objects.create(slug='foo', label='Foo')
Agenda.objects.create(label='Evt', slug='evt', kind='events', events_type=events_type)
resp = app.get('/api/export-import/agendas/evt/dependencies/')
assert resp.json == {
'data': [
{
'id': 'foo',
'text': 'Foo',
'type': 'events_types',
'urls': {
'dependencies': 'http://testserver/api/export-import/events_types/foo/dependencies/',
'export': 'http://testserver/api/export-import/events_types/foo/',
'redirect': 'http://testserver/api/export-import/events_types/foo/redirect/',
},
}
],
'err': 0,
}
def test_agenda_redirect(app, user):
app.authorization = ('Basic', ('john.doe', 'password'))
agenda = Agenda.objects.create(label='Rdv', slug='rdv', kind='meetings')
redirect_url = '/api/export-import/agendas/rdv/redirect/'
resp = app.get(redirect_url, status=302)
assert resp.location == f'/manage/agendas/{agenda.pk}/'
def create_bundle(app, user, visible=True, version_number='42.0'):
app.authorization = ('Basic', ('john.doe', 'password'))
group, _ = Group.objects.get_or_create(name='plop')
category, _ = Category.objects.get_or_create(slug='foo', label='Foo')
meetings_agenda, _ = Agenda.objects.get_or_create(
slug='rdv', label='Rdv', kind='meetings', edit_role=group, category=category
)
resource, _ = Resource.objects.get_or_create(slug='foo', label='Foo')
meetings_agenda.resources.add(resource)
desk, _ = Desk.objects.get_or_create(slug='foo', label='Foo', agenda=meetings_agenda)
unavailability_calendar, _ = UnavailabilityCalendar.objects.get_or_create(slug='foo', label='Foo')
desk.unavailability_calendars.add(unavailability_calendar)
events_type, _ = EventsType.objects.get_or_create(slug='foo', label='Foo')
events_agenda, _ = Agenda.objects.get_or_create(
label='Evt', slug='evt', kind='events', events_type=events_type
)
Desk.objects.get_or_create(agenda=events_agenda, slug='_exceptions_holder')
sub_agenda, _ = Agenda.objects.get_or_create(slug='sub', label='Sub', kind='meetings')
virtual_agenda, _ = Agenda.objects.get_or_create(label='Virt', slug='virt', kind='virtual')
virtual_agenda.real_agendas.add(sub_agenda)
components = [
(meetings_agenda, False),
(category, True),
(resource, True),
(unavailability_calendar, True),
(events_agenda, False),
(events_type, True),
(virtual_agenda, False),
(sub_agenda, True),
]
tar_io = io.BytesIO()
with tarfile.open(mode='w', fileobj=tar_io) as tar:
manifest_json = {
'application': 'Test',
'slug': 'test',
'icon': 'foo.png',
'description': 'Foo Bar',
'documentation_url': 'http://foo.bar',
'visible': visible,
'version_number': version_number,
'version_notes': 'foo bar blah',
'elements': [],
}
for component, auto_dependency in components:
manifest_json['elements'].append(
{
'type': component.application_component_type,
'slug': component.slug,
'name': component.label,
'auto-dependency': auto_dependency,
}
)
manifest_fd = io.BytesIO(json.dumps(manifest_json, indent=2).encode())
tarinfo = tarfile.TarInfo('manifest.json')
tarinfo.size = len(manifest_fd.getvalue())
tar.addfile(tarinfo, fileobj=manifest_fd)
icon_fd = io.BytesIO(
b'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABAQAAAAA3bvkkAAAACklEQVQI12NoAAAAggCB3UNq9AAAAABJRU5ErkJggg=='
)
tarinfo = tarfile.TarInfo('foo.png')
tarinfo.size = len(icon_fd.getvalue())
tar.addfile(tarinfo, fileobj=icon_fd)
for component, _ in components:
component_export = app.get(
'/api/export-import/%s/%s/' % (component.application_component_type, component.slug)
).content
tarinfo = tarfile.TarInfo('%s/%s' % (component.application_component_type, component.slug))
tarinfo.size = len(component_export)
tar.addfile(tarinfo, fileobj=io.BytesIO(component_export))
bundle = tar_io.getvalue()
return bundle
@pytest.fixture
def bundle(app, user):
return create_bundle(app, user)
def test_bundle_import(app, user):
app.authorization = ('Basic', ('john.doe', 'password'))
bundles = []
for version_number in ['42.0', '42.1']:
bundles.append(create_bundle(app, user, version_number=version_number))
Agenda.objects.all().delete()
Category.objects.all().delete()
Resource.objects.all().delete()
EventsType.objects.all().delete()
UnavailabilityCalendar.objects.all().delete()
resp = app.put('/api/export-import/bundle-import/', bundles[0])
assert Agenda.objects.all().count() == 4
assert resp.json['err'] == 0
assert Application.objects.count() == 1
application = Application.objects.latest('pk')
assert application.slug == 'test'
assert application.name == 'Test'
assert application.description == 'Foo Bar'
assert application.documentation_url == 'http://foo.bar'
assert application.version_number == '42.0'
assert application.version_notes == 'foo bar blah'
assert re.match(r'applications/icons/foo(_\w+)?.png', application.icon.name)
assert application.editable is False
assert application.visible is True
assert ApplicationElement.objects.count() == 8
# check editable flag is kept on install
application.editable = True
application.save()
# create link to element not present in manifest: it should be unlinked
last_agenda = Agenda.objects.latest('pk')
ApplicationElement.objects.create(
application=application,
content_type=ContentType.objects.get_for_model(Agenda),
object_id=last_agenda.pk + 1,
)
# check update
resp = app.put('/api/export-import/bundle-import/', bundles[1])
assert Agenda.objects.all().count() == 4
assert resp.json['err'] == 0
assert Application.objects.count() == 1
application = Application.objects.latest('pk')
assert application.editable is False
assert ApplicationElement.objects.count() == 8
assert (
ApplicationElement.objects.filter(
application=application,
content_type=ContentType.objects.get_for_model(Agenda),
object_id=last_agenda.pk + 1,
).exists()
is False
)
def test_bundle_declare(app, user):
app.authorization = ('Basic', ('john.doe', 'password'))
bundle = create_bundle(app, user, visible=False)
resp = app.put('/api/export-import/bundle-declare/', bundle)
assert Agenda.objects.all().count() == 4
assert resp.json['err'] == 0
assert Application.objects.count() == 1
application = Application.objects.latest('pk')
assert application.slug == 'test'
assert application.name == 'Test'
assert application.description == 'Foo Bar'
assert application.documentation_url == 'http://foo.bar'
assert application.version_number == '42.0'
assert application.version_notes == 'foo bar blah'
assert re.match(r'applications/icons/foo(_\w+)?.png', application.icon.name)
assert application.editable is True
assert application.visible is False
assert ApplicationElement.objects.count() == 8
bundle = create_bundle(app, user, visible=True)
# create link to element not present in manifest: it should be unlinked
last_page = Agenda.objects.latest('pk')
ApplicationElement.objects.create(
application=application,
content_type=ContentType.objects.get_for_model(Agenda),
object_id=last_page.pk + 1,
)
# and remove agendas to have unkown references in manifest
Agenda.objects.all().delete()
resp = app.put('/api/export-import/bundle-declare/', bundle)
assert Application.objects.count() == 1
application = Application.objects.latest('pk')
assert application.visible is True
assert ApplicationElement.objects.count() == 4 # category, events_type, unavailability_calendar, resource
def test_bundle_unlink(app, user, bundle):
app.authorization = ('Basic', ('john.doe', 'password'))
application = Application.objects.create(
name='Test',
slug='test',
version_number='42.0',
)
other_application = Application.objects.create(
name='Other Test',
slug='other-test',
version_number='42.0',
)
agenda = Agenda.objects.latest('pk')
ApplicationElement.objects.create(
application=application,
content_object=agenda,
)
ApplicationElement.objects.create(
application=application,
content_type=ContentType.objects.get_for_model(Agenda),
object_id=agenda.pk + 1,
)
ApplicationElement.objects.create(
application=other_application,
content_object=agenda,
)
ApplicationElement.objects.create(
application=other_application,
content_type=ContentType.objects.get_for_model(Agenda),
object_id=agenda.pk + 1,
)
assert Application.objects.count() == 2
assert ApplicationElement.objects.count() == 4
app.post('/api/export-import/unlink/', {'application': 'test'})
assert Application.objects.count() == 1
assert ApplicationElement.objects.count() == 2
assert ApplicationElement.objects.filter(
application=other_application,
content_type=ContentType.objects.get_for_model(Agenda),
object_id=agenda.pk,
).exists()
assert ApplicationElement.objects.filter(
application=other_application,
content_type=ContentType.objects.get_for_model(Agenda),
object_id=agenda.pk + 1,
).exists()
# again
app.post('/api/export-import/unlink/', {'application': 'test'})
assert Application.objects.count() == 1
assert ApplicationElement.objects.count() == 2
def test_bundle_check(app, user):
app.authorization = ('Basic', ('john.doe', 'password'))
assert app.put('/api/export-import/bundle-check/').json == {'err': 0, 'data': {}}

View File

@ -2925,10 +2925,10 @@ def test_event_check_subscription(check_types, app, admin_user):
)
# event not in past
subscription.date_end = now() + datetime.timedelta(days=2)
subscription.date_end = datetime.date.today() + datetime.timedelta(days=2)
subscription.save()
assert agenda.enable_check_for_future_events is False
event.start_datetime = now() + datetime.timedelta(days=1)
event.start_datetime = localtime(now()) + datetime.timedelta(days=1)
event.save()
app.post(
'/manage/agendas/%s/subscriptions/%s/absence/%s' % (agenda.pk, subscription.pk, event.pk),

View File

@ -281,9 +281,15 @@ def test_manager_partial_bookings_day_view_multiple_bookings(app, admin_user, fr
resp = resp.click('Booked period', index=0)
resp.form['start_time'] = '09:30'
resp.form['end_time'] = '12:00'
assert resp.form['presence'].options == [('True', True, None), ('False', False, None)]
resp.form['presence'] = 'True'
resp = resp.form.submit().follow()
resp = resp.click('Checked period')
# possible to cancel check is '' option is selected
assert resp.form['presence'].options == [('', False, None), ('True', True, None), ('False', False, None)]
resp = app.get('/manage/agendas/%s/day/%d/%d/%d/' % (agenda.pk, today.year, today.month, today.day))
assert len(resp.pyquery('.partial-booking--registrant')) == 1
assert len(resp.pyquery('.registrant--bar')) == 4
assert len(resp.pyquery('.registrant--bar.check')) == 1
@ -295,6 +301,7 @@ def test_manager_partial_bookings_day_view_multiple_bookings(app, admin_user, fr
resp = resp.click('Booked period')
resp.form['start_time'] = '15:00'
resp.form['end_time'] = '17:00'
assert resp.form['presence'].options == [('True', True, None), ('False', False, None)]
resp.form['presence'] = 'True'
resp = resp.form.submit().follow()
@ -507,6 +514,11 @@ def test_manager_partial_bookings_multiple_checks(app, admin_user):
assert 'Add second booking check' not in resp.text
resp.form['start_time'] = '11:30'
resp.form['end_time'] = ''
resp = resp.form.submit()
assert 'Booking check hours overlap existing check.' in resp.text
resp.form['end_time'] = '17:30'
resp = resp.form.submit()
assert 'Booking check hours overlap existing check.' in resp.text
@ -521,6 +533,45 @@ def test_manager_partial_bookings_multiple_checks(app, admin_user):
assert resp.pyquery('.registrant--bar.check')[0].findall('time')[1].text == '12:00'
def test_manager_partial_bookings_incomplete_check(app, admin_user):
agenda = Agenda.objects.create(label='Foo bar', kind='events', partial_bookings=True)
start_datetime = make_aware(datetime.datetime(2023, 5, 2, 8, 0))
event = Event.objects.create(
label='Event', start_datetime=start_datetime, end_time=datetime.time(18, 00), places=10, agenda=agenda
)
booking = Booking.objects.create(
user_external_id='xxx',
user_first_name='Jane',
user_last_name='Doe',
start_time=datetime.time(11, 00),
end_time=datetime.time(13, 30),
event=event,
)
app = login(app)
today = start_datetime.date()
resp = app.get('/manage/agendas/%s/day/%d/%d/%d/' % (agenda.pk, today.year, today.month, today.day))
resp = resp.click('Booked period')
resp = resp.form.submit()
assert 'Both arrival and departure cannot not be empty.' in resp.text
resp.form['start_time'] = '11:01'
resp.form['presence'] = 'True'
resp = resp.form.submit().follow()
booking.refresh_from_db()
assert booking.user_check.start_time == datetime.time(11, 1)
assert booking.user_check.end_time is None
assert booking.user_check.computed_start_time == datetime.time(11, 0)
assert booking.user_check.computed_end_time is None
assert len(resp.pyquery('.registrant--bar')) == 2
assert len(resp.pyquery('.registrant--bar.booking')) == 1
assert len(resp.pyquery('.registrant--bar.check.present')) == 1
assert resp.pyquery('.registrant--bar.check time').text() == '11:01'
@mock.patch('chrono.manager.forms.get_agenda_check_types')
def test_manager_partial_bookings_check_subscription(check_types, app, admin_user):
check_types.return_value = []
@ -556,8 +607,7 @@ def test_manager_partial_bookings_check_subscription(check_types, app, admin_use
assert 'Fill with booking start time' not in resp.text
assert 'absence_check_type' not in resp.form.fields
assert resp.form['presence'].options == [
('', True, None),
('True', False, None),
('True', True, None),
] # no 'False' option
assert not Booking.objects.exists()