pricing: configuration of criterias and categories (#64746)

This commit is contained in:
Lauréline Guérin 2022-05-03 10:19:21 +02:00
parent 81138cf951
commit a5c5f63c83
No known key found for this signature in database
GPG Key ID: 1FAB9B9B4F93D473
11 changed files with 604 additions and 0 deletions

View File

@ -12,6 +12,9 @@
<li><a rel="popup" href="{% url 'chrono-manager-agendas-export' %}" data-autoclose-dialog="true">{% trans 'Export' %}</a></li>
<li><a href="{% url 'chrono-manager-events-type-list' %}">{% trans 'Events types' %}</a></li>
<li><a href="{% url 'chrono-manager-check-type-list' %}">{% trans 'Check types' %}</a></li>
{% if pricing_enabled %}
<li><a href="{% url 'chrono-manager-pricing-criteria-list' %}">{% trans 'Pricing' context 'pricing' %}</a></li>
{% endif %}
{% endif %}
{% if has_access_to_unavailability_calendars %}
<li><a href="{% url 'chrono-manager-unavailability-calendar-list' %}">{% trans 'Unavailability calendars' %}</a></li>

View File

@ -26,6 +26,7 @@ from operator import attrgetter
import requests
from dateutil.relativedelta import MO, relativedelta
from django.conf import settings
from django.contrib import messages
from django.core.exceptions import PermissionDenied
from django.db import IntegrityError, transaction
@ -163,6 +164,7 @@ class HomepageView(ListView):
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context['has_access_to_unavailability_calendars'] = self.has_access_to_unavailability_calendars()
context['pricing_enabled'] = settings.CHRONO_ENABLE_PRICING
return context
def get(self, request, *args, **kwargs):

51
chrono/pricing/forms.py Normal file
View File

@ -0,0 +1,51 @@
# chrono - agendas system
# Copyright (C) 2022 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 import forms
from django.forms import ValidationError
from django.template import Template, TemplateSyntaxError
from django.utils.translation import ugettext_lazy as _
from chrono.pricing.models import Criteria
class NewCriteriaForm(forms.ModelForm):
class Meta:
model = Criteria
fields = ['label', 'condition']
def clean_condition(self):
condition = self.cleaned_data['condition']
try:
Template('{%% if %s %%}OK{%% endif %%}' % condition)
except TemplateSyntaxError:
raise ValidationError(_('Invalid syntax.'))
return condition
class CriteriaForm(NewCriteriaForm):
class Meta:
model = Criteria
fields = ['label', 'slug', 'condition']
def clean_slug(self):
slug = self.cleaned_data['slug']
if self.instance.category.criterias.filter(slug=slug).exclude(pk=self.instance.pk).exists():
raise ValidationError(_('Another criteria exists with the same identifier.'))
return slug

View File

@ -0,0 +1,31 @@
{% extends "chrono/pricing/manager_criteria_list.html" %}
{% load i18n %}
{% block breadcrumb %}
{{ block.super }}
{% if object.pk %}
<a href="{% url 'chrono-manager-pricing-criteria-category-edit' object.pk %}">{{ object }}</a>
{% else %}
<a href="{% url 'chrono-manager-pricing-criteria-category-add' %}">{% trans "New criteria category" %}</a>
{% endif %}
{% endblock %}
{% block appbar %}
{% if object.pk %}
<h2>{% trans "Edit criteria category" %}</h2>
{% else %}
<h2>{% trans "New criteria category" %}</h2>
{% endif %}
{% endblock %}
{% block content %}
<form method="post" enctype="multipart/form-data">
{% csrf_token %}
{{ form.as_p }}
<div class="buttons">
<button class="submit-button">{% trans "Save" %}</button>
<a class="cancel" href="{% url 'chrono-manager-pricing-criteria-list' %}">{% trans 'Cancel' %}</a>
</div>
</form>
{% endblock %}

View File

@ -0,0 +1,31 @@
{% extends "chrono/pricing/manager_criteria_list.html" %}
{% load i18n %}
{% block breadcrumb %}
{{ block.super }}
{% if form.instance.pk %}
<a href="{% url 'chrono-manager-pricing-criteria-edit' form.instance.category_id form.instance.pk %}">{{ form.instance }}</a>
{% else %}
<a href="{% url 'chrono-manager-pricing-criteria-add' form.instance.category_id %}">{% trans "New criteria" %}</a>
{% endif %}
{% endblock %}
{% block appbar %}
{% if form.instance.pk %}
<h2>{{ form.instance.category }} - {% trans "Edit criteria" %}</h2>
{% else %}
<h2>{{ form.instance.category }} - {% trans "New criteria" %}</h2>
{% endif %}
{% endblock %}
{% block content %}
<form method="post" enctype="multipart/form-data">
{% csrf_token %}
{{ form.as_p }}
<div class="buttons">
<button class="submit-button">{% trans "Save" %}</button>
<a class="cancel" href="{% url 'chrono-manager-pricing-criteria-list' %}">{% trans 'Cancel' %}</a>
</div>
</form>
{% endblock %}

View File

@ -0,0 +1,50 @@
{% extends "chrono/manager_base.html" %}
{% load i18n %}
{% block breadcrumb %}
{{ block.super }}
<a href="{% url 'chrono-manager-pricing-criteria-list' %}">{% trans "Criterias" %}</a>
{% endblock %}
{% block appbar %}
<h2>{% trans 'Criterias' %}</h2>
<span class="actions">
<a rel="popup" href="{% url 'chrono-manager-pricing-criteria-category-add' %}">{% trans 'New criteria category' %}</a>
</span>
{% endblock %}
{% block content %}
<div class="pk-information">
<p>{% trans "Define here pricing criterias used in events agendas." %}</p>
</div>
{% for object in object_list %}
<div class="section criteria-category">
<h3>
<a rel="popup" href="{% url 'chrono-manager-pricing-criteria-category-edit' object.pk %}">{{ object }} [{{ object.slug }}]</a>
<span>
<a class="button" href="{# url 'chrono-manager-pricing-criteria-category-export' object.pk #}">{% trans "Export"%}</a>
<a class="button" rel="popup" href="{% url 'chrono-manager-pricing-criteria-category-delete' object.pk %}">{% trans "Delete"%}</a>
</span>
</h3>
<div>
<ul class="objects-list single-links">
{% for criteria in object.criterias.all %}
<li>
<a rel="popup" href="{% url 'chrono-manager-pricing-criteria-edit' object.pk criteria.pk %}">{{ criteria }}</a>
<a class="delete" rel="popup" href="{% url 'chrono-manager-pricing-criteria-delete' object.pk criteria.pk %}">{% trans "delete"%}</a>
</li>
{% endfor %}
<li><a class="add" rel="popup" href="{% url 'chrono-manager-pricing-criteria-add' object.pk %}">{% trans "Add a criteria" %}</a></li>
</ul>
</div>
</div>
{% empty %}
<div class="big-msg-info">
{% blocktrans %}
This site doesn't have any pricing criteria category yet. Click on the "New category" button in the top
right of the page to add a first one.
{% endblocktrans %}
</div>
{% endfor %}
{% endblock %}

53
chrono/pricing/urls.py Normal file
View File

@ -0,0 +1,53 @@
# chrono - agendas system
# Copyright (C) 2022 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.conf.urls import url
from . import views
urlpatterns = [
url(r'^criterias/$', views.criteria_list, name='chrono-manager-pricing-criteria-list'),
url(
r'^criteria/category/add/$',
views.criteria_category_add,
name='chrono-manager-pricing-criteria-category-add',
),
url(
r'^criteria/category/(?P<pk>\d+)/edit/$',
views.criteria_category_edit,
name='chrono-manager-pricing-criteria-category-edit',
),
url(
r'^criteria/category/(?P<pk>\d+)/delete/$',
views.criteria_category_delete,
name='chrono-manager-pricing-criteria-category-delete',
),
url(
r'^criteria/category/(?P<category_pk>\d+)/add/$',
views.criteria_add,
name='chrono-manager-pricing-criteria-add',
),
url(
r'^criteria/category/(?P<category_pk>\d+)/(?P<pk>\d+)/edit/$',
views.criteria_edit,
name='chrono-manager-pricing-criteria-edit',
),
url(
r'^criteria/category/(?P<category_pk>\d+)/(?P<pk>\d+)/delete/$',
views.criteria_delete,
name='chrono-manager-pricing-criteria-delete',
),
]

155
chrono/pricing/views.py Normal file
View File

@ -0,0 +1,155 @@
# chrono - agendas system
# Copyright (C) 2022 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.core.exceptions import PermissionDenied
from django.urls import reverse
from django.views.generic import CreateView, DeleteView, ListView, UpdateView
from chrono.pricing.forms import CriteriaForm, NewCriteriaForm
from chrono.pricing.models import Criteria, CriteriaCategory
class CriteriaListView(ListView):
template_name = 'chrono/pricing/manager_criteria_list.html'
model = CriteriaCategory
def dispatch(self, request, *args, **kwargs):
if not request.user.is_staff:
raise PermissionDenied()
return super().dispatch(request, *args, **kwargs)
def get_queryset(self):
return CriteriaCategory.objects.prefetch_related('criterias')
criteria_list = CriteriaListView.as_view()
class CriteriaCategoryAddView(CreateView):
template_name = 'chrono/pricing/manager_criteria_category_form.html'
model = CriteriaCategory
fields = ['label']
def dispatch(self, request, *args, **kwargs):
if not request.user.is_staff:
raise PermissionDenied()
return super().dispatch(request, *args, **kwargs)
def get_success_url(self):
return reverse('chrono-manager-pricing-criteria-list')
criteria_category_add = CriteriaCategoryAddView.as_view()
class CriteriaCategoryEditView(UpdateView):
template_name = 'chrono/pricing/manager_criteria_category_form.html'
model = CriteriaCategory
fields = ['label', 'slug']
def dispatch(self, request, *args, **kwargs):
if not request.user.is_staff:
raise PermissionDenied()
return super().dispatch(request, *args, **kwargs)
def get_success_url(self):
return reverse('chrono-manager-pricing-criteria-list')
criteria_category_edit = CriteriaCategoryEditView.as_view()
class CriteriaCategoryDeleteView(DeleteView):
template_name = 'chrono/manager_confirm_delete.html'
model = CriteriaCategory
def dispatch(self, request, *args, **kwargs):
if not request.user.is_staff:
raise PermissionDenied()
return super().dispatch(request, *args, **kwargs)
def get_success_url(self):
return reverse('chrono-manager-pricing-criteria-list')
criteria_category_delete = CriteriaCategoryDeleteView.as_view()
class CriteriaAddView(CreateView):
template_name = 'chrono/pricing/manager_criteria_form.html'
model = Criteria
form_class = NewCriteriaForm
def dispatch(self, request, *args, **kwargs):
self.category_pk = kwargs.pop('category_pk')
if not request.user.is_staff:
raise PermissionDenied()
return super().dispatch(request, *args, **kwargs)
def get_form_kwargs(self):
kwargs = super().get_form_kwargs()
if not kwargs.get('instance'):
kwargs['instance'] = self.model()
kwargs['instance'].category_id = self.category_pk
return kwargs
def get_success_url(self):
return reverse('chrono-manager-pricing-criteria-list')
criteria_add = CriteriaAddView.as_view()
class CriteriaEditView(UpdateView):
template_name = 'chrono/pricing/manager_criteria_form.html'
model = Criteria
form_class = CriteriaForm
def dispatch(self, request, *args, **kwargs):
self.category_pk = kwargs.pop('category_pk')
if not request.user.is_staff:
raise PermissionDenied()
return super().dispatch(request, *args, **kwargs)
def get_queryset(self):
return Criteria.objects.filter(category=self.category_pk)
def get_success_url(self):
return reverse('chrono-manager-pricing-criteria-list')
criteria_edit = CriteriaEditView.as_view()
class CriteriaDeleteView(DeleteView):
template_name = 'chrono/manager_confirm_delete.html'
model = Criteria
def dispatch(self, request, *args, **kwargs):
self.category_pk = kwargs.pop('category_pk')
if not request.user.is_staff:
raise PermissionDenied()
return super().dispatch(request, *args, **kwargs)
def get_queryset(self):
return Criteria.objects.filter(category=self.category_pk)
def get_success_url(self):
return reverse('chrono-manager-pricing-criteria-list')
criteria_delete = CriteriaDeleteView.as_view()

View File

@ -22,12 +22,14 @@ from django.contrib.staticfiles.urls import staticfiles_urlpatterns
from .api.urls import urlpatterns as chrono_api_urls
from .manager.urls import urlpatterns as chrono_manager_urls
from .pricing.urls import urlpatterns as chrono_pricing_urls
from .urls_utils import decorated_includes
from .views import LoginView, LogoutView, homepage
urlpatterns = [
url(r'^$', homepage, name='home'),
url(r'^manage/', decorated_includes(login_required, include(chrono_manager_urls))),
url(r'^manage/pricing/', decorated_includes(login_required, include(chrono_pricing_urls))),
url(r'^api/', include(chrono_api_urls)),
url(r'^logout/$', LogoutView.as_view(), name='auth_logout'),
url(r'^login/$', LoginView.as_view(), name='auth_login'),

36
tests/pricing/conftest.py Normal file
View File

@ -0,0 +1,36 @@
import pytest
from django.contrib.auth.models import Group, User
@pytest.fixture
def simple_user():
try:
user = User.objects.get(username='user')
except User.DoesNotExist:
user = User.objects.create_user('user', password='user')
return user
@pytest.fixture
def managers_group():
group, _ = Group.objects.get_or_create(name='Managers')
return group
@pytest.fixture
def manager_user(managers_group):
try:
user = User.objects.get(username='manager')
except User.DoesNotExist:
user = User.objects.create_user('manager', password='manager')
user.groups.set([managers_group])
return user
@pytest.fixture
def admin_user():
try:
user = User.objects.get(username='admin')
except User.DoesNotExist:
user = User.objects.create_superuser('admin', email=None, password='admin')
return user

View File

@ -0,0 +1,190 @@
import pytest
from chrono.agendas.models import Agenda
from chrono.pricing.models import Criteria, CriteriaCategory
from tests.utils import login
pytestmark = pytest.mark.django_db
@pytest.fixture
def agenda_with_restrictions(manager_user):
agenda = Agenda(label='Foo Bar')
agenda.view_role = manager_user.groups.all()[0]
agenda.save()
return agenda
def test_list_criterias_as_manager(app, manager_user, agenda_with_restrictions):
app = login(app, username='manager', password='manager')
app.get('/manage/pricing/criterias/', status=403)
resp = app.get('/manage/')
assert 'Pricing' not in resp.text
def test_add_category(settings, app, admin_user):
app = login(app)
resp = app.get('/manage/')
resp = resp.click('Pricing')
resp = resp.click('New criteria category')
resp.form['label'] = 'QF'
resp = resp.form.submit()
category = CriteriaCategory.objects.latest('pk')
assert resp.location.endswith('/manage/pricing/criterias/')
assert category.label == 'QF'
assert category.slug == 'qf'
settings.CHRONO_ENABLE_PRICING = False
resp = app.get('/manage/')
assert 'Pricing' not in resp.text
def test_add_category_as_manager(app, manager_user):
app = login(app, username='manager', password='manager')
app.get('/manage/pricing/criteria/category/add/', status=403)
def test_edit_category(app, admin_user):
category = CriteriaCategory.objects.create(label='QF')
category2 = CriteriaCategory.objects.create(label='Domicile')
app = login(app)
resp = app.get('/manage/pricing/criterias/')
resp = resp.click(href='/manage/pricing/criteria/category/%s/edit/' % category.pk)
resp.form['label'] = 'QF Foo'
resp.form['slug'] = category2.slug
resp = resp.form.submit()
assert resp.context['form'].errors['slug'] == ['Criteria category with this Identifier already exists.']
resp.form['slug'] = 'baz2'
resp = resp.form.submit()
assert resp.location.endswith('/manage/pricing/criterias/')
category.refresh_from_db()
assert category.label == 'QF Foo'
assert category.slug == 'baz2'
def test_edit_category_as_manager(app, manager_user):
category = CriteriaCategory.objects.create(label='Foo bar')
app = login(app, username='manager', password='manager')
app.get('/manage/pricing/criteria/category/%s/edit/' % category.pk, status=403)
def test_delete_category(app, admin_user):
category = CriteriaCategory.objects.create(label='QF')
Criteria.objects.create(label='QF 1', category=category)
app = login(app)
resp = app.get('/manage/pricing/criterias/')
resp = resp.click(href='/manage/pricing/criteria/category/%s/delete/' % category.pk)
resp = resp.form.submit()
assert resp.location.endswith('/manage/pricing/criterias/')
assert CriteriaCategory.objects.exists() is False
assert Criteria.objects.exists() is False
def test_delete_category_as_manager(app, manager_user):
category = CriteriaCategory.objects.create(label='Foo bar')
app = login(app, username='manager', password='manager')
app.get('/manage/pricing/criteria/category/%s/delete/' % category.pk, status=403)
def test_add_criteria(app, admin_user):
category = CriteriaCategory.objects.create(label='QF')
app = login(app)
resp = app.get('/manage/')
resp = resp.click('Pricing')
resp = resp.click('Add a criteria')
resp.form['label'] = 'QF < 1'
resp.form['condition'] = 'qf < 1 #'
assert 'slug' not in resp.context['form'].fields
resp = resp.form.submit()
assert resp.context['form'].errors['condition'] == ['Invalid syntax.']
resp.form['condition'] = 'qf < 1'
resp = resp.form.submit()
criteria = Criteria.objects.latest('pk')
assert resp.location.endswith('/manage/pricing/criterias/')
assert criteria.label == 'QF < 1'
assert criteria.category == category
assert criteria.slug == 'qf-1'
assert criteria.condition == 'qf < 1'
assert criteria.order == 1
resp = app.get('/manage/pricing/criteria/category/%s/add/' % category.pk)
resp.form['label'] = 'QF < 1'
resp.form['condition'] = 'qf < 1'
resp = resp.form.submit()
assert resp.location.endswith('/manage/pricing/criterias/')
criteria = Criteria.objects.latest('pk')
assert criteria.label == 'QF < 1'
assert criteria.category == category
assert criteria.slug == 'qf-1-1'
assert criteria.condition == 'qf < 1'
assert criteria.order == 2
def test_add_criteria_as_manager(app, manager_user):
category = CriteriaCategory.objects.create(label='Foo bar')
app = login(app, username='manager', password='manager')
app.get('/manage/pricing/criteria/category/%s/add/' % category.pk, status=403)
def test_edit_criteria(app, admin_user):
category = CriteriaCategory.objects.create(label='QF')
criteria = Criteria.objects.create(label='QF 1', category=category)
criteria2 = Criteria.objects.create(label='QF 2', category=category)
category2 = CriteriaCategory.objects.create(label='Foo')
criteria3 = Criteria.objects.create(label='foo-bar', category=category2)
app = login(app)
resp = app.get('/manage/pricing/criterias/')
resp = resp.click(href='/manage/pricing/criteria/category/%s/%s/edit/' % (category.pk, criteria.pk))
resp.form['label'] = 'QF 1 bis'
resp.form['slug'] = criteria2.slug
resp.form['condition'] = 'qf <= 1 #'
resp = resp.form.submit()
assert resp.context['form'].errors['slug'] == ['Another criteria exists with the same identifier.']
assert resp.context['form'].errors['condition'] == ['Invalid syntax.']
resp.form['condition'] = 'qf <= 1'
resp.form['slug'] = criteria3.slug
resp = resp.form.submit()
assert resp.location.endswith('/manage/pricing/criterias/')
criteria.refresh_from_db()
assert criteria.label == 'QF 1 bis'
assert criteria.slug == 'foo-bar'
assert criteria.condition == 'qf <= 1'
def test_edit_criteria_as_manager(app, manager_user):
category = CriteriaCategory.objects.create(label='QF')
criteria = Criteria.objects.create(label='QF 1', category=category)
app = login(app, username='manager', password='manager')
app.get('/manage/pricing/criteria/category/%s/%s/edit/' % (category.pk, criteria.pk), status=403)
def test_delete_criteria(app, admin_user):
category = CriteriaCategory.objects.create(label='QF')
criteria = Criteria.objects.create(label='QF 1', category=category)
app = login(app)
resp = app.get('/manage/pricing/criterias/')
resp = resp.click(href='/manage/pricing/criteria/category/%s/%s/delete/' % (category.pk, criteria.pk))
resp = resp.form.submit()
assert resp.location.endswith('/manage/pricing/criterias/')
assert CriteriaCategory.objects.exists() is True
assert Criteria.objects.exists() is False
def test_delete_criteria_as_manager(app, manager_user):
category = CriteriaCategory.objects.create(label='QF')
criteria = Criteria.objects.create(label='QF 1', category=category)
app = login(app, username='manager', password='manager')
app.get('/manage/pricing/criteria/category/%s/%s/delete/' % (category.pk, criteria.pk), status=403)