api: export/import models and views (#86291)

This commit is contained in:
Lauréline Guérin 2024-01-30 17:16:45 +01:00 committed by Lauréline Guérin
parent d5d447b9df
commit 439248a7e2
13 changed files with 1282 additions and 0 deletions

View File

@ -45,6 +45,10 @@ class Agenda(models.Model):
on_delete=models.SET_NULL,
)
application_component_type = 'lingo_agendas'
application_label_singular = _('Agenda (payment)')
application_label_plural = _('Agendas (payment)')
class Meta:
ordering = ['label']
@ -60,6 +64,20 @@ class Agenda(models.Model):
def base_slug(self):
return slugify(self.label)
def get_dependencies(self):
yield self.check_type_group
yield self.regie
if not settings.KNOWN_SERVICES.get('chrono'):
return
chrono = list(settings.KNOWN_SERVICES['chrono'].values())[0]
chrono_url = chrono.get('url') or ''
urls = {
'export': f'{chrono_url}api/export-import/agendas/{self.slug}/',
'dependencies': f'{chrono_url}api/export-import/agendas/{self.slug}/dependencies/',
'redirect': f'{chrono_url}api/export-import/agendas/{self.slug}/redirect/',
}
yield {'type': 'agendas', 'id': self.slug, 'text': self.label, 'urls': urls}
def export_json(self):
return {
'slug': self.slug,
@ -111,6 +129,10 @@ class CheckTypeGroup(models.Model):
slug = models.SlugField(_('Identifier'), max_length=160, unique=True)
label = models.CharField(_('Label'), max_length=150)
application_component_type = 'check_type_groups'
application_label_singular = _('Check type group')
application_label_plural = _('Check type groups')
class Meta:
ordering = ['label']
@ -126,6 +148,9 @@ class CheckTypeGroup(models.Model):
def base_slug(self):
return slugify(self.label)
def get_dependencies(self):
return []
@classmethod
def import_json(cls, data):
check_types = data.pop('check_types', [])

View File

View File

@ -0,0 +1,317 @@
# lingo - payment and billing system
# Copyright (C) 2022-2024 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 django.utils.translation import gettext_lazy as _
from rest_framework import permissions
from rest_framework.generics import GenericAPIView
from rest_framework.response import Response
from lingo.agendas.chrono import refresh_agendas
from lingo.agendas.models import Agenda, CheckTypeGroup
from lingo.export_import.models import Application, ApplicationElement
from lingo.invoicing.models import Payer, Regie
from lingo.invoicing.utils import import_site as invoicing_import_site
from lingo.pricing.models import CriteriaCategory, Pricing
from lingo.pricing.utils import import_site as pricing_import_site
klasses = {
klass.application_component_type: klass
for klass in [Pricing, CriteriaCategory, Agenda, CheckTypeGroup, CriteriaCategory, Payer, Regie]
}
klasses['roles'] = Group
klasses_translation = {
'lingo_agendas': 'agendas', # agendas type is already used in chrono for Agenda
}
klasses_translation_reverse = {v: k for k, v in klasses_translation.items()}
def import_site(components):
# refresh agendas from chrono first
refresh_agendas()
# then import regies, payers
invoicing_import_site(components)
# and pricing components
pricing_import_site(components)
class Index(GenericAPIView):
permission_classes = (permissions.IsAuthenticatedOrReadOnly,)
def get(self, request, *args, **kwargs):
data = []
for klass in klasses.values():
if klass == Group:
data.append(
{
'id': 'roles',
'text': _('Roles'),
'singular': _('Role'),
'urls': {
'list': request.build_absolute_uri(
reverse(
'api-export-import-components-list',
kwargs={'component_type': 'roles'},
)
),
},
'minor': True,
}
)
continue
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 [Pricing]:
component_type['minor'] = True
data.append(component_type)
return Response({'data': data})
index = Index.as_view()
def get_component_bundle_entry(request, component):
if isinstance(component, Group):
return {
'id': component.role.slug if hasattr(component, 'role') else component.id,
'text': component.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': component.role.uuid if hasattr(component, 'role') else None,
}
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']]
order_by = 'slug'
if klass == Group:
order_by = 'name'
response = [get_component_bundle_entry(request, x) for x in klass.objects.order_by(order_by)]
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, dict):
return element
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 == Pricing:
return redirect(reverse('lingo-manager-pricing-detail', kwargs={'pk': component.pk}))
if klass == CriteriaCategory:
return redirect(reverse('lingo-manager-pricing-criteria-list'))
if klass == Agenda:
return redirect(reverse('lingo-manager-agenda-detail', kwargs={'pk': component.pk}))
if klass == CheckTypeGroup:
return redirect(reverse('lingo-manager-check-type-list'))
if klass == Payer:
return redirect(reverse('lingo-manager-invoicing-payer-detail', kwargs={'pk': component.pk}))
if klass == Regie:
return redirect(reverse('lingo-manager-invoicing-regie-detail', 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,7 @@
from django.db import migrations
class Migration(migrations.Migration):
dependencies = []
operations = []

View File

@ -0,0 +1,61 @@
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
('contenttypes', '0002_remove_content_type_name'),
('export_import', '0001_initial'),
]
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 @@
# lingo - payment and billing system
# Copyright (C) 2022-2024 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 @@
# lingo - payment and billing system
# Copyright (C) 2022-2024 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

@ -93,6 +93,10 @@ class Payer(models.Model):
)
user_fields_mapping = models.JSONField(blank=True, default=dict)
application_component_type = 'payers'
application_label_singular = _('Payer')
application_label_plural = _('Payers')
class Meta:
ordering = ['label']
@ -130,6 +134,9 @@ class Payer(models.Model):
def base_slug(self):
return slugify(self.label)
def get_dependencies(self):
return []
def export_json(self):
return {
'label': self.label,
@ -320,6 +327,10 @@ class Regie(models.Model):
cashier_name = models.CharField(_('Cashier name'), max_length=256, blank=True)
city_name = models.CharField(_('City name'), max_length=256, blank=True)
application_component_type = 'regies'
application_label_singular = _('Regie')
application_label_plural = _('Regies')
class Meta:
ordering = ['label']
@ -335,6 +346,10 @@ class Regie(models.Model):
def base_slug(self):
return slugify(self.label)
def get_dependencies(self):
yield self.cashier_role
yield self.payer
def export_json(self):
return {
'label': self.label,

View File

@ -102,6 +102,10 @@ class CriteriaCategory(models.Model):
label = models.CharField(_('Label'), max_length=150)
slug = models.SlugField(_('Identifier'), max_length=160, unique=True)
application_component_type = 'pricing_categories'
application_label_singular = _('Criteria category')
application_label_plural = _('Criteria categories')
class Meta:
ordering = ['label']
@ -117,6 +121,9 @@ class CriteriaCategory(models.Model):
def base_slug(self):
return slugify(self.label)
def get_dependencies(self):
return []
@classmethod
def import_json(cls, data):
criterias = data.pop('criterias', [])
@ -304,6 +311,10 @@ class Pricing(models.Model):
pricing_data = models.JSONField(null=True)
application_component_type = 'pricings'
application_label_singular = _('Pricing')
application_label_plural = _('Pricings')
def __str__(self):
return self.label
@ -316,6 +327,10 @@ class Pricing(models.Model):
def base_slug(self):
return slugify(self.label)
def get_dependencies(self):
yield from self.agendas.all()
yield from self.categories.all()
def export_json(self):
return {
'label': self.label,

View File

@ -61,6 +61,7 @@ INSTALLED_APPS = (
'lingo.api',
'lingo.basket',
'lingo.epayment',
'lingo.export_import',
'lingo.invoicing',
'lingo.manager',
'lingo.pricing',

View File

@ -23,6 +23,7 @@ from .api.urls import urlpatterns as lingo_api_urls
from .basket.urls import public_urlpatterns as lingo_basket_public_urls
from .epayment.urls import manager_urlpatterns as lingo_epayment_manager_urls
from .epayment.urls import public_urlpatterns as lingo_epayment_public_urls
from .export_import import urls as export_import_urls
from .invoicing.urls import urlpatterns as lingo_invoicing_urls
from .manager.urls import urlpatterns as lingo_manager_urls
from .pricing.urls import urlpatterns as lingo_pricing_urls
@ -37,6 +38,7 @@ urlpatterns = [
re_path(r'^manage/epayment/', decorated_includes(manager_required, include(lingo_epayment_manager_urls))),
path('basket/', include(lingo_basket_public_urls)),
path('api/', include(lingo_api_urls)),
path('api/', include(export_import_urls)),
path('login/', login, name='auth_login'),
path('logout/', logout, name='auth_logout'),
path('', include(lingo_epayment_public_urls)),

View File

@ -0,0 +1,661 @@
import datetime
import io
import json
import re
import tarfile
from unittest import mock
import pytest
from django.contrib.auth.models import Group
from django.contrib.contenttypes.models import ContentType
from lingo.agendas.models import Agenda, CheckType, CheckTypeGroup
from lingo.export_import.models import Application, ApplicationElement
from lingo.invoicing.models import Payer, Regie
from lingo.pricing.models import CriteriaCategory, Pricing
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': 'pricings',
'singular': 'Pricing',
'text': 'Pricings',
'urls': {'list': 'http://testserver/api/export-import/pricings/'},
},
{
'id': 'pricing_categories',
'minor': True,
'singular': 'Criteria category',
'text': 'Criteria categories',
'urls': {'list': 'http://testserver/api/export-import/pricing_categories/'},
},
{
'id': 'lingo_agendas',
'minor': True,
'singular': 'Agenda (payment)',
'text': 'Agendas (payment)',
'urls': {'list': 'http://testserver/api/export-import/lingo_agendas/'},
},
{
'id': 'check_type_groups',
'minor': True,
'singular': 'Check type group',
'text': 'Check type groups',
'urls': {'list': 'http://testserver/api/export-import/check_type_groups/'},
},
{
'id': 'payers',
'minor': True,
'singular': 'Payer',
'text': 'Payers',
'urls': {'list': 'http://testserver/api/export-import/payers/'},
},
{
'id': 'regies',
'minor': True,
'singular': 'Regie',
'text': 'Regies',
'urls': {'list': 'http://testserver/api/export-import/regies/'},
},
{
'id': 'roles',
'minor': True,
'singular': 'Role',
'text': 'Roles',
'urls': {'list': 'http://testserver/api/export-import/roles/'},
},
],
}
def test_list(app, user):
app.authorization = ('Basic', ('john.doe', 'password'))
Pricing.objects.create(
label='Foo Bar pricing',
date_start=datetime.date(year=2021, month=9, day=1),
date_end=datetime.date(year=2022, month=9, day=1),
)
CriteriaCategory.objects.create(label='Foo Bar Cat')
Agenda.objects.create(label='Foo Bar Agenda')
CheckTypeGroup.objects.create(label='Foo Bar Group')
Payer.objects.create(label='Foo Bar Payer')
Regie.objects.create(label='Foo Bar Regie')
group = Group.objects.create(name='group1')
resp = app.get('/api/export-import/pricings/')
assert resp.json == {
'data': [
{
'id': 'foo-bar-pricing',
'text': 'Foo Bar pricing',
'type': 'pricings',
'urls': {
'dependencies': 'http://testserver/api/export-import/pricings/foo-bar-pricing/dependencies/',
'export': 'http://testserver/api/export-import/pricings/foo-bar-pricing/',
'redirect': 'http://testserver/api/export-import/pricings/foo-bar-pricing/redirect/',
},
}
]
}
resp = app.get('/api/export-import/pricing_categories/')
assert resp.json == {
'data': [
{
'id': 'foo-bar-cat',
'text': 'Foo Bar Cat',
'type': 'pricing_categories',
'urls': {
'dependencies': 'http://testserver/api/export-import/pricing_categories/foo-bar-cat/dependencies/',
'export': 'http://testserver/api/export-import/pricing_categories/foo-bar-cat/',
'redirect': 'http://testserver/api/export-import/pricing_categories/foo-bar-cat/redirect/',
},
}
],
}
resp = app.get('/api/export-import/lingo_agendas/')
assert resp.json == {
'data': [
{
'id': 'foo-bar-agenda',
'text': 'Foo Bar Agenda',
'type': 'lingo_agendas',
'urls': {
'dependencies': 'http://testserver/api/export-import/lingo_agendas/foo-bar-agenda/dependencies/',
'export': 'http://testserver/api/export-import/lingo_agendas/foo-bar-agenda/',
'redirect': 'http://testserver/api/export-import/lingo_agendas/foo-bar-agenda/redirect/',
},
}
]
}
resp = app.get('/api/export-import/check_type_groups/')
assert resp.json == {
'data': [
{
'id': 'foo-bar-group',
'text': 'Foo Bar Group',
'type': 'check_type_groups',
'urls': {
'dependencies': 'http://testserver/api/export-import/check_type_groups/foo-bar-group/dependencies/',
'export': 'http://testserver/api/export-import/check_type_groups/foo-bar-group/',
'redirect': 'http://testserver/api/export-import/check_type_groups/foo-bar-group/redirect/',
},
}
]
}
resp = app.get('/api/export-import/payers/')
assert resp.json == {
'data': [
{
'id': 'foo-bar-payer',
'text': 'Foo Bar Payer',
'type': 'payers',
'urls': {
'dependencies': 'http://testserver/api/export-import/payers/foo-bar-payer/dependencies/',
'export': 'http://testserver/api/export-import/payers/foo-bar-payer/',
'redirect': 'http://testserver/api/export-import/payers/foo-bar-payer/redirect/',
},
}
]
}
resp = app.get('/api/export-import/regies/')
assert resp.json == {
'data': [
{
'id': 'foo-bar-regie',
'text': 'Foo Bar Regie',
'type': 'regies',
'urls': {
'dependencies': 'http://testserver/api/export-import/regies/foo-bar-regie/dependencies/',
'export': 'http://testserver/api/export-import/regies/foo-bar-regie/',
'redirect': 'http://testserver/api/export-import/regies/foo-bar-regie/redirect/',
},
}
]
}
resp = app.get('/api/export-import/roles/')
assert resp.json == {
'data': [{'id': group.pk, 'text': 'group1', 'type': 'roles', 'urls': {}, 'uuid': None}]
}
def test_export_pricing(app, user):
app.authorization = ('Basic', ('john.doe', 'password'))
Pricing.objects.create(
label='Foo Bar pricing',
date_start=datetime.date(year=2021, month=9, day=1),
date_end=datetime.date(year=2022, month=9, day=1),
)
resp = app.get('/api/export-import/pricings/foo-bar-pricing/')
assert resp.json['data']['label'] == 'Foo Bar pricing'
def test_export_minor_components(app, user):
CriteriaCategory.objects.create(label='Foo Bar Cat')
Agenda.objects.create(label='Foo Bar Agenda')
CheckTypeGroup.objects.create(label='Foo Bar Group')
Payer.objects.create(label='Foo Bar Payer')
Regie.objects.create(label='Foo Bar Regie')
resp = app.get('/api/export-import/pricing_categories/foo-bar-cat/')
assert resp.json['data']['label'] == 'Foo Bar Cat'
resp = app.get('/api/export-import/lingo_agendas/foo-bar-agenda/')
assert resp.json['data']['slug'] == 'foo-bar-agenda'
resp = app.get('/api/export-import/check_type_groups/foo-bar-group/')
assert resp.json['data']['label'] == 'Foo Bar Group'
resp = app.get('/api/export-import/payers/foo-bar-payer/')
assert resp.json['data']['label'] == 'Foo Bar Payer'
resp = app.get('/api/export-import/regies/foo-bar-regie/')
assert resp.json['data']['label'] == 'Foo Bar Regie'
def test_pricing_dependencies(app, user):
app.authorization = ('Basic', ('john.doe', 'password'))
pricing = Pricing.objects.create(
label='Foo Bar pricing',
date_start=datetime.date(year=2021, month=9, day=1),
date_end=datetime.date(year=2022, month=9, day=1),
)
category1 = CriteriaCategory.objects.create(label='Cat 1')
category2 = CriteriaCategory.objects.create(label='Cat 2')
agenda1 = Agenda.objects.create(label='Foo bar 1')
agenda2 = Agenda.objects.create(label='Foo bar 2')
pricing.categories.add(category1, through_defaults={'order': 1})
pricing.categories.add(category2, through_defaults={'order': 2})
pricing.agendas.add(agenda1, agenda2)
resp = app.get('/api/export-import/pricings/foo-bar-pricing/dependencies/')
assert resp.json == {
'data': [
{
'id': 'foo-bar-1',
'text': 'Foo bar 1',
'type': 'lingo_agendas',
'urls': {
'dependencies': 'http://testserver/api/export-import/lingo_agendas/foo-bar-1/dependencies/',
'export': 'http://testserver/api/export-import/lingo_agendas/foo-bar-1/',
'redirect': 'http://testserver/api/export-import/lingo_agendas/foo-bar-1/redirect/',
},
},
{
'id': 'foo-bar-2',
'text': 'Foo bar 2',
'type': 'lingo_agendas',
'urls': {
'dependencies': 'http://testserver/api/export-import/lingo_agendas/foo-bar-2/dependencies/',
'export': 'http://testserver/api/export-import/lingo_agendas/foo-bar-2/',
'redirect': 'http://testserver/api/export-import/lingo_agendas/foo-bar-2/redirect/',
},
},
{
'id': 'cat-1',
'text': 'Cat 1',
'type': 'pricing_categories',
'urls': {
'dependencies': 'http://testserver/api/export-import/pricing_categories/cat-1/dependencies/',
'export': 'http://testserver/api/export-import/pricing_categories/cat-1/',
'redirect': 'http://testserver/api/export-import/pricing_categories/cat-1/redirect/',
},
},
{
'id': 'cat-2',
'text': 'Cat 2',
'type': 'pricing_categories',
'urls': {
'dependencies': 'http://testserver/api/export-import/pricing_categories/cat-2/dependencies/',
'export': 'http://testserver/api/export-import/pricing_categories/cat-2/',
'redirect': 'http://testserver/api/export-import/pricing_categories/cat-2/redirect/',
},
},
],
'err': 0,
}
def test_agenda_dependencies(app, user):
app.authorization = ('Basic', ('john.doe', 'password'))
regie = Regie.objects.create(label='Foo Bar Regie')
group = CheckTypeGroup.objects.create(label='Foo Bar Group')
Agenda.objects.create(label='Foo Bar Agenda', check_type_group=group, regie=regie)
resp = app.get('/api/export-import/lingo_agendas/foo-bar-agenda/dependencies/')
assert resp.json == {
'data': [
{
'id': 'foo-bar-group',
'text': 'Foo Bar Group',
'type': 'check_type_groups',
'urls': {
'dependencies': 'http://testserver/api/export-import/check_type_groups/foo-bar-group/dependencies/',
'export': 'http://testserver/api/export-import/check_type_groups/foo-bar-group/',
'redirect': 'http://testserver/api/export-import/check_type_groups/foo-bar-group/redirect/',
},
},
{
'id': 'foo-bar-regie',
'text': 'Foo Bar Regie',
'type': 'regies',
'urls': {
'dependencies': 'http://testserver/api/export-import/regies/foo-bar-regie/dependencies/',
'export': 'http://testserver/api/export-import/regies/foo-bar-regie/',
'redirect': 'http://testserver/api/export-import/regies/foo-bar-regie/redirect/',
},
},
{
'id': 'foo-bar-agenda',
'text': 'Foo Bar Agenda',
'type': 'agendas',
'urls': {
'dependencies': 'http://chrono.example.org/api/export-import/agendas/foo-bar-agenda/dependencies/',
'export': 'http://chrono.example.org/api/export-import/agendas/foo-bar-agenda/',
'redirect': 'http://chrono.example.org/api/export-import/agendas/foo-bar-agenda/redirect/',
},
},
],
'err': 0,
}
def test_check_type_group_dependencies(app, user):
app.authorization = ('Basic', ('john.doe', 'password'))
group = CheckTypeGroup.objects.create(label='Foo Bar Group')
CheckType.objects.create(label='Foo reason', group=group, kind='presence')
CheckType.objects.create(label='Baz reason', group=group)
resp = app.get('/api/export-import/check_type_groups/foo-bar-group/dependencies/')
assert resp.json == {
'data': [],
'err': 0,
}
def test_regie_dependencies(app, user):
app.authorization = ('Basic', ('john.doe', 'password'))
group = Group.objects.create(name='group1')
payer = Payer.objects.create(label='Foo Bar Payer')
Regie.objects.create(label='Foo Bar Regie', cashier_role=group, payer=payer)
resp = app.get('/api/export-import/regies/foo-bar-regie/dependencies/')
assert resp.json == {
'data': [
{'id': group.pk, 'text': 'group1', 'type': 'roles', 'urls': {}, 'uuid': None},
{
'id': 'foo-bar-payer',
'text': 'Foo Bar Payer',
'type': 'payers',
'urls': {
'dependencies': 'http://testserver/api/export-import/payers/foo-bar-payer/dependencies/',
'export': 'http://testserver/api/export-import/payers/foo-bar-payer/',
'redirect': 'http://testserver/api/export-import/payers/foo-bar-payer/redirect/',
},
},
],
'err': 0,
}
def test_payer_dependencies(app, user):
app.authorization = ('Basic', ('john.doe', 'password'))
Payer.objects.create(label='Foo Bar Payer')
resp = app.get('/api/export-import/payers/foo-bar-payer/dependencies/')
assert resp.json == {
'data': [],
'err': 0,
}
def test_pricing_categories_dependencies(app, user):
app.authorization = ('Basic', ('john.doe', 'password'))
CriteriaCategory.objects.create(label='Foo Bar Cat')
resp = app.get('/api/export-import/pricing_categories/foo-bar-cat/dependencies/')
assert resp.json == {
'data': [],
'err': 0,
}
def test_redirect(app, user):
app.authorization = ('Basic', ('john.doe', 'password'))
pricing = Pricing.objects.create(
label='Foo Bar pricing',
date_start=datetime.date(year=2021, month=9, day=1),
date_end=datetime.date(year=2022, month=9, day=1),
)
category = CriteriaCategory.objects.create(label='Foo Bar Cat')
agenda = Agenda.objects.create(label='Foo Bar Agenda')
group = CheckTypeGroup.objects.create(label='Foo Bar Group')
payer = Payer.objects.create(label='Foo Bar Payer')
regie = Regie.objects.create(label='Foo Bar Regie')
redirect_url = f'/api/export-import/pricings/{pricing.slug}/redirect/'
resp = app.get(redirect_url, status=302)
assert resp.location == f'/manage/pricing/{pricing.pk}/'
redirect_url = f'/api/export-import/pricing_categories/{category.slug}/redirect/'
resp = app.get(redirect_url, status=302)
assert resp.location == '/manage/pricing/criterias/'
redirect_url = f'/api/export-import/lingo_agendas/{agenda.slug}/redirect/'
resp = app.get(redirect_url, status=302)
assert resp.location == f'/manage/pricing/agenda/{agenda.pk}/'
redirect_url = f'/api/export-import/check_type_groups/{group.slug}/redirect/'
resp = app.get(redirect_url, status=302)
assert resp.location == '/manage/pricing/check-types/'
redirect_url = f'/api/export-import/payers/{payer.slug}/redirect/'
resp = app.get(redirect_url, status=302)
assert resp.location == f'/manage/invoicing/payer/{payer.pk}/'
redirect_url = f'/api/export-import/regies/{regie.slug}/redirect/'
resp = app.get(redirect_url, status=302)
assert resp.location == f'/manage/invoicing/regie/{regie.pk}/'
def create_bundle(app, user, visible=True, version_number='42.0'):
app.authorization = ('Basic', ('john.doe', 'password'))
group, _ = CheckTypeGroup.objects.get_or_create(label='Foo Bar Group')
payer, _ = Payer.objects.get_or_create(label='Foo Bar Payer')
regie, _ = Regie.objects.get_or_create(label='Foo Bar Regie', payer=payer)
pricing, _ = Pricing.objects.get_or_create(
label='Foo Bar pricing',
date_start=datetime.date(year=2021, month=9, day=1),
date_end=datetime.date(year=2022, month=9, day=1),
)
category, _ = CriteriaCategory.objects.get_or_create(label='Foo Bar Cat')
agenda, _ = Agenda.objects.get_or_create(label='Foo Bar Agenda', check_type_group=group, regie=regie)
pricing.categories.add(category, through_defaults={'order': 1})
pricing.agendas.add(agenda)
components = [
(pricing, False),
(category, True),
(agenda, True),
(group, True),
(payer, True),
(regie, 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)
@mock.patch('lingo.export_import.api_views.refresh_agendas')
def test_bundle_import(mock_refresh, 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))
Pricing.objects.all().delete()
CriteriaCategory.objects.all().delete()
CheckTypeGroup.objects.all().delete()
Regie.objects.all().delete()
Payer.objects.all().delete()
Agenda.objects.all().delete()
agenda = Agenda.objects.create(label='Foo Bar Agenda') # created by agenda refresh
resp = app.put('/api/export-import/bundle-import/', bundles[0])
assert Pricing.objects.all().count() == 1
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() == 6
assert mock_refresh.call_args_list == [mock.call()]
payer = Payer.objects.latest('pk')
regie = Regie.objects.latest('pk')
assert regie.payer == payer
group = CheckTypeGroup.objects.latest('pk')
agenda.refresh_from_db()
assert agenda.regie == regie
assert agenda.check_type_group == group
category = CriteriaCategory.objects.latest('pk')
last_pricing = Pricing.objects.latest('pk')
assert list(last_pricing.agendas.all()) == [agenda]
assert list(last_pricing.categories.all()) == [category]
# 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_pricing = Pricing.objects.latest('pk')
ApplicationElement.objects.create(
application=application,
content_type=ContentType.objects.get_for_model(Pricing),
object_id=last_pricing.pk + 1,
)
# check update
resp = app.put('/api/export-import/bundle-import/', bundles[1])
assert Pricing.objects.all().count() == 1
assert resp.json['err'] == 0
assert Application.objects.count() == 1
application = Application.objects.latest('pk')
assert application.editable is False
assert ApplicationElement.objects.count() == 6
assert (
ApplicationElement.objects.filter(
application=application,
content_type=ContentType.objects.get_for_model(Pricing),
object_id=last_pricing.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 Pricing.objects.all().count() == 1
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() == 6
bundle = create_bundle(app, user, visible=True)
# create link to element not present in manifest: it should be unlinked
last_pricing = Pricing.objects.latest('pk')
ApplicationElement.objects.create(
application=application,
content_type=ContentType.objects.get_for_model(Pricing),
object_id=last_pricing.pk + 1,
)
# and remove regie to have unkown references in manifest
Regie.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() == 5 # pricing, categorie, agenda, group, payer
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',
)
pricing = Pricing.objects.latest('pk')
ApplicationElement.objects.create(
application=application,
content_object=pricing,
)
ApplicationElement.objects.create(
application=application,
content_type=ContentType.objects.get_for_model(Pricing),
object_id=pricing.pk + 1,
)
ApplicationElement.objects.create(
application=other_application,
content_object=pricing,
)
ApplicationElement.objects.create(
application=other_application,
content_type=ContentType.objects.get_for_model(Pricing),
object_id=pricing.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(Pricing),
object_id=pricing.pk,
).exists()
assert ApplicationElement.objects.filter(
application=other_application,
content_type=ContentType.objects.get_for_model(Pricing),
object_id=pricing.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': {}}