manager: make agenda's groups foldable (#85616)
gitea/chrono/pipeline/head This commit looks good Details

This commit is contained in:
Yann Weber 2024-03-21 12:11:02 +01:00
parent 07512150e8
commit f81dfa9ca6
14 changed files with 378 additions and 14 deletions

View File

@ -156,4 +156,5 @@ urlpatterns = [
path('statistics/', views.statistics_list, name='api-statistics-list'),
path('statistics/bookings/', views.bookings_statistics, name='api-statistics-bookings'),
path('ants/', include('chrono.apps.ants_hub.api_urls')),
path('user_preferences/', include('chrono.apps.user_preferences.api_urls')),
]

View File

View File

@ -0,0 +1,23 @@
# chrono - agendas system
# Copyright (C) 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('save', api_views.save_preference, name='api-user-preferences'),
]

View File

@ -0,0 +1,52 @@
# chrono - agendas system
# Copyright (C) 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 json
from django.core.exceptions import PermissionDenied
from django.http import HttpResponse
from django.utils.translation import gettext_lazy as _
from django.views.decorators.csrf import csrf_exempt
from . import models
@csrf_exempt
def save_preference(request):
'''Save a user preference
Given a JSON with a single boolean value identified by a str, save the
key <-> value association in UserPreference
'''
if not request.user or not request.user.is_authenticated:
raise PermissionDenied()
user_pref = models.UserPreferences.objects.get_or_create(user=request.user)[0]
if len(request.body) > 1000:
return HttpResponse(_('Payload is too large').encode(), status=400)
bad_fmt_response = HttpResponse(_('Bad format').encode(), status=400)
try:
prefs = json.loads(request.body)
except json.JSONDecodeError:
return bad_fmt_response
if not isinstance(prefs, dict) or len(prefs) != 1:
return bad_fmt_response
name, value = list(prefs.items())[0]
if not isinstance(name, str) or not isinstance(value, bool):
return bad_fmt_response
user_pref.update_preference(name, value)
return HttpResponse(b'', status=204)

View File

@ -0,0 +1,32 @@
# Generated by Django 3.2.18 on 2024-04-02 13:32
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='UserPreferences',
fields=[
(
'id',
models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
),
('preferences', models.JSONField(default=dict, verbose_name='Preferences')),
(
'user',
models.ForeignKey(
null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL
),
),
],
),
]

View File

@ -0,0 +1,31 @@
# chrono - agendas system
# Copyright (C) 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.conf import settings
from django.db import models
from django.utils.translation import gettext_lazy as _
class UserPreferences(models.Model):
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, null=True)
preferences = models.JSONField(_('Preferences'), default=dict)
def get_preference(self, name):
return self.preferences.get(name) or False
def update_preference(self, name, value):
self.preferences.update({name: value})
self.save()

View File

@ -1,4 +1,23 @@
$(function() {
const foldableClassObserver = new MutationObserver((mutations) => {
mutations.forEach(mu => {
const old_folded = (mu.oldValue.indexOf('folded') != -1);
const new_folded = mu.target.classList.contains('folded')
if (old_folded == new_folded) { return; }
var pref_message = Object();
pref_message[mu.target.dataset.sectionFoldedPrefName] = new_folded;
fetch('/api/user_preferences/save', {
method: 'POST',
credentials: 'include',
headers: {'Accept': 'application/json', 'Content-Type': 'application/json'},
body: JSON.stringify(pref_message)
});
});
});
document.querySelectorAll('[data-section-folded-pref-name]').forEach(
elt => foldableClassObserver.observe(elt, {attributes: true, attributeFilter: ['class'], attributeOldValue: true})
);
$('[data-total]').each(function() {
var total = $(this).data('total');
var booked = $(this).data('booked');

View File

@ -1,5 +1,5 @@
{% extends "chrono/manager_base.html" %}
{% load i18n thumbnail %}
{% load i18n thumbnail chrono %}
{% block appbar %}
{% include 'chrono/includes/application_appbar_fragment.html' with title_no_application=_('Agendas outside applications') title_object_list=_('Agendas') %}
@ -16,19 +16,23 @@
{% if object_list %}
{% regroup object_list by category as agenda_groups %}
{% for group in agenda_groups %}
<div class="section">
{% if group.grouper %}<h3>{{ group.grouper }}</h3>{% elif not forloop.first %}<h3>{% trans "Misc" %}</h3>{% endif %}
<ul class="objects-list single-links">
{% for object in group.list %}
<li>
<a href="{% url 'chrono-manager-agenda-view' pk=object.id %}">
<span class="badge">{{ object.get_real_kind_display }}</span>
{% include 'chrono/includes/application_icon_fragment.html' %}
{{ object.label }}{% if user.is_staff %} <span class="identifier">[{% trans "identifier:" %} {{ object.slug }}]{% endif %}</span>
</a>
</li>
{% endfor %}
</ul>
{% with forloop.counter|stringformat:"s" as i %}
{% with 'folded-admin-forms-group-'|add:i as foldname %}
<div class="section foldable {% if user|get_preference:foldname %}folded{% endif %}" data-section-folded-pref-name="{{foldname}}">
{% endwith %}
{% endwith %}
{% if group.grouper %}<h3>{{ group.grouper }}</h3>{% elif not forloop.first %}<h3>{% trans "Misc" %}</h3>{% endif %}
<ul class="objects-list single-links">
{% for object in group.list %}
<li>
<a href="{% url 'chrono-manager-agenda-view' pk=object.id %}">
<span class="badge">{{ object.get_real_kind_display }}</span>
{% include 'chrono/includes/application_icon_fragment.html' %}
{{ object.label }}{% if user.is_staff %} <span class="identifier">[{% trans "identifier:" %} {{ object.slug }}]{% endif %}</span>
</a>
</li>
{% endfor %}
</ul>
</div>
{% endfor %}
{% elif not no_application %}

View File

@ -17,6 +17,8 @@
from django import template
from django.utils.formats import date_format
from chrono.apps.user_preferences.models import UserPreferences
register = template.Library()
@ -31,3 +33,11 @@ def human_date_range(date_start, date_end):
date_start_format = 'd'
return '%s %s' % (date_format(date_start, date_start_format), date_format(date_end, date_end_format))
@register.filter
def get_preference(user, pref_name):
if not user:
return None
user_preferences, _ = UserPreferences.objects.get_or_create(user=user)
return user_preferences.get_preference(pref_name) if user_preferences else None

View File

@ -63,6 +63,7 @@ INSTALLED_APPS = (
'chrono.apps.ants_hub',
'chrono.apps.export_import',
'chrono.apps.snapshot',
'chrono.apps.user_preferences',
)
MIDDLEWARE = (

View File

@ -0,0 +1,85 @@
import json
import pytest
from django.urls import reverse
from chrono.apps.user_preferences.models import UserPreferences
from tests.utils import login
pytestmark = pytest.mark.django_db
def test_user_preferences_api_ok(app, admin_user):
UserPreferences.objects.all().delete()
login(app)
fake_id = 'fake-id-1'
url = reverse('api-user-preferences')
app.post_json(url, params={fake_id: True}, status=204)
user_pref = UserPreferences.objects.get(user=admin_user)
assert user_pref
assert fake_id in user_pref.preferences
assert user_pref.preferences[fake_id] is True
app.post_json(url, params={fake_id: False}, status=204)
user_pref = UserPreferences.objects.get(user=admin_user)
assert user_pref
assert fake_id in user_pref.preferences
assert user_pref.preferences[fake_id] is False
fake_id2 = 'fake-id-2'
app.post_json(url, params={fake_id2: False}, status=204)
user_pref = UserPreferences.objects.get(user=admin_user)
assert user_pref
assert fake_id in user_pref.preferences
assert fake_id2 in user_pref.preferences
assert user_pref.preferences[fake_id] is False
assert user_pref.preferences[fake_id2] is False
app.post_json(url, params={fake_id2: False}, status=204)
user_pref = UserPreferences.objects.get(user=admin_user)
assert user_pref
assert fake_id in user_pref.preferences
assert fake_id2 in user_pref.preferences
assert user_pref.preferences[fake_id] is False
assert user_pref.preferences[fake_id2] is False
app.post_json(url, params={fake_id2: True}, status=204)
user_pref = UserPreferences.objects.get(user=admin_user)
assert user_pref
assert fake_id in user_pref.preferences
assert fake_id2 in user_pref.preferences
assert user_pref.preferences[fake_id] is False
assert user_pref.preferences[fake_id2] is True
@pytest.mark.parametrize(
'bad_body',
(
json.dumps({'fake-id-1': True, 'fake-id-2': False}),
json.dumps({'fake-id-1': 'true'}),
json.dumps({'fake-id-1': 1}),
'{\'fake-id-1\': true',
),
)
def test_user_preferences_api_invalid(app, admin_user, bad_body):
login(app)
url = reverse('api-user-preferences')
app.post(url, params=bad_body, status=400)
def test_user_preferences_api_large_payload(app, admin_user):
login(app)
url = reverse('api-user-preferences')
app.post(url, params='a' * 1024, status=400)
app.post_json(url, params={'b' * 1024: True}, status=400)
def test_user_preferences_api_unauthorized(app):
url = reverse('api-user-preferences')
app.post(url, params={'toto': True}, status=403)

View File

@ -4,6 +4,7 @@ from django.test.utils import CaptureQueriesContext
from chrono.agendas.models import Agenda, Category
from chrono.apps.snapshot.models import CategorySnapshot
from chrono.apps.user_preferences.models import UserPreferences
from tests.utils import login
pytestmark = pytest.mark.django_db
@ -97,3 +98,75 @@ def test_inspect_category(app, admin_user):
with CaptureQueriesContext(connection) as ctx:
resp = resp.click('Inspect')
assert len(ctx.captured_queries) == 3
def test_category_fold_preferences(app, admin_user):
category1 = Category.objects.create(label='Foo bar')
category2 = Category.objects.create(label='Toto')
Agenda.objects.create(label='Foo bar', category=category1)
agenda2 = Agenda.objects.create(label='Titi', category=category2)
app = login(app)
resp = app.get('/manage/')
elt = resp.pyquery.find('div[data-section-folded-pref-name=folded-admin-forms-group-1]')
assert len(elt) == 1
assert 'foldable' in elt[0].classes
assert 'folded' not in elt[0].classes
elt = resp.pyquery.find('div[data-section-folded-pref-name=folded-admin-forms-group-2]')
assert len(elt) == 1
assert 'foldable' in elt[0].classes
assert 'folded' not in elt[0].classes
user_prefs = UserPreferences.objects.get(user=admin_user)
user_prefs.update_preference('folded-admin-forms-group-1', True)
resp = app.get('/manage/')
elt = resp.pyquery.find('div[data-section-folded-pref-name=folded-admin-forms-group-1]')
assert len(elt) == 1
assert 'foldable' in elt[0].classes
assert 'folded' in elt[0].classes
elt = resp.pyquery.find('div[data-section-folded-pref-name=folded-admin-forms-group-2]')
assert len(elt) == 1
assert 'foldable' in elt[0].classes
assert 'folded' not in elt[0].classes
# Order is preserved when adding a new category : preferences are preserved
category3 = Category.objects.create(label='Tata')
Agenda.objects.create(label='Titi', category=category3)
user_prefs.update_preference('folded-admin-forms-group-1', False)
user_prefs.update_preference('folded-admin-forms-group-2', True)
resp = app.get('/manage/')
elt = resp.pyquery.find('div[data-section-folded-pref-name=folded-admin-forms-group-1]')
assert len(elt) == 1
assert 'foldable' in elt[0].classes
assert 'folded' not in elt[0].classes
elt = resp.pyquery.find('div[data-section-folded-pref-name=folded-admin-forms-group-2]')
assert len(elt) == 1
assert 'foldable' in elt[0].classes
assert 'folded' in elt[0].classes
elt = resp.pyquery.find('div[data-section-folded-pref-name=folded-admin-forms-group-3]')
assert len(elt) == 1
assert 'foldable' in elt[0].classes
assert 'folded' not in elt[0].classes
# Preferences are "shifted" when a category is deleted
agenda2.delete()
resp = app.get('/manage/')
elt = resp.pyquery.find('div[data-section-folded-pref-name=folded-admin-forms-group-1]')
assert len(elt) == 1
assert 'foldable' in elt[0].classes
assert 'folded' not in elt[0].classes
elt = resp.pyquery.find('div[data-section-folded-pref-name=folded-admin-forms-group-2]')
assert len(elt) == 1
assert 'foldable' in elt[0].classes
assert 'folded' in elt[0].classes

View File

@ -0,0 +1,33 @@
import pytest
from django.template import Context, Template
from chrono.apps.user_preferences.models import UserPreferences
from tests.utils import login
pytestmark = pytest.mark.django_db
def context(user):
return Context({'user': user})
def test_manager_templatetags_get_preference(app, simple_user):
t = Template('{% load chrono %}{% if user|get_preference:\'fake-id-1\' %}OK{% else %}KO{% endif %}')
res = t.render(Context())
assert res == 'KO'
login(app, 'user', 'user')
res = t.render(context(simple_user))
assert res == 'KO'
user_prefs = UserPreferences.objects.get_or_create(user=simple_user)[0]
user_prefs.update_preference('fake-id-1', False)
res = t.render(context(simple_user))
assert res == 'KO'
user_prefs.update_preference('fake-id-1', True)
res = t.render(context(simple_user))
assert res == 'OK'