manager: make agenda's groups foldable (#85616)
gitea/chrono/pipeline/head This commit looks good
Details
gitea/chrono/pipeline/head This commit looks good
Details
This commit is contained in:
parent
5fa96e62a8
commit
a64aa47c1e
|
@ -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')),
|
||||
]
|
||||
|
|
|
@ -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'),
|
||||
]
|
|
@ -0,0 +1,43 @@
|
|||
# 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.contrib.auth.decorators import login_required
|
||||
from django.http import HttpResponse, HttpResponseBadRequest
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.views.decorators.csrf import csrf_exempt
|
||||
|
||||
from . import models
|
||||
|
||||
|
||||
@csrf_exempt
|
||||
@login_required
|
||||
def save_preference(request):
|
||||
user_pref, dummy = models.UserPreferences.objects.get_or_create(user=request.user)
|
||||
|
||||
if len(request.body) > 1000:
|
||||
return HttpResponseBadRequest(_('Payload is too large'))
|
||||
try:
|
||||
prefs = json.loads(request.body)
|
||||
except json.JSONDecodeError:
|
||||
return HttpResponseBadRequest(_('Bad format'))
|
||||
if not isinstance(prefs, dict) or len(prefs) != 1:
|
||||
return HttpResponseBadRequest(_('Bad format'))
|
||||
|
||||
user_pref.preferences.update(prefs)
|
||||
user_pref.save()
|
||||
return HttpResponse('', status=204)
|
|
@ -0,0 +1,32 @@
|
|||
# Generated by Django 3.2.18 on 2024-04-11 15:30
|
||||
|
||||
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(
|
||||
on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
]
|
|
@ -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)
|
||||
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()
|
|
@ -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');
|
||||
|
|
|
@ -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 i=group.grouper.id|stringformat:"s" %}
|
||||
{% with foldname='folded-admin-forms-group-'|add:i %}
|
||||
<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 %}
|
||||
|
|
|
@ -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,9 @@ 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):
|
||||
user_preferences, dummy = UserPreferences.objects.get_or_create(user=user)
|
||||
return user_preferences.preferences.get(pref_name) or False
|
||||
|
|
|
@ -63,6 +63,7 @@ INSTALLED_APPS = (
|
|||
'chrono.apps.ants_hub',
|
||||
'chrono.apps.export_import',
|
||||
'chrono.apps.snapshot',
|
||||
'chrono.apps.user_preferences',
|
||||
)
|
||||
|
||||
MIDDLEWARE = (
|
||||
|
|
|
@ -0,0 +1,71 @@
|
|||
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):
|
||||
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.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.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.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.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.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}),
|
||||
'"not a dict"',
|
||||
'[1,2,3]',
|
||||
'{\'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=302)
|
|
@ -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,80 @@ 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')
|
||||
pref_name1 = f'folded-admin-forms-group-{category1.id}'
|
||||
pref_name2 = f'folded-admin-forms-group-{category2.id}'
|
||||
|
||||
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(f'div[data-section-folded-pref-name={pref_name1}]')
|
||||
assert len(elt) == 1
|
||||
assert 'foldable' in elt[0].classes
|
||||
assert 'folded' not in elt[0].classes
|
||||
|
||||
elt = resp.pyquery.find(f'div[data-section-folded-pref-name={pref_name2}]')
|
||||
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(pref_name1, True)
|
||||
resp = app.get('/manage/')
|
||||
|
||||
elt = resp.pyquery.find(f'div[data-section-folded-pref-name={pref_name1}]')
|
||||
assert len(elt) == 1
|
||||
assert 'foldable' in elt[0].classes
|
||||
assert 'folded' in elt[0].classes
|
||||
|
||||
elt = resp.pyquery.find(f'div[data-section-folded-pref-name={pref_name2}]')
|
||||
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
|
||||
category_temp = Category.objects.create(label='Tata0')
|
||||
category3 = Category.objects.create(label='Tata')
|
||||
pref_name3 = f'folded-admin-forms-group-{category3.id}'
|
||||
category_temp.delete()
|
||||
Agenda.objects.create(label='Titi', category=category3)
|
||||
|
||||
user_prefs.update_preference(pref_name1, False)
|
||||
user_prefs.update_preference(pref_name2, True)
|
||||
resp = app.get('/manage/')
|
||||
|
||||
elt = resp.pyquery.find(f'div[data-section-folded-pref-name={pref_name1}]')
|
||||
assert len(elt) == 1
|
||||
assert 'foldable' in elt[0].classes
|
||||
assert 'folded' not in elt[0].classes
|
||||
|
||||
elt = resp.pyquery.find(f'div[data-section-folded-pref-name={pref_name2}]')
|
||||
assert len(elt) == 1
|
||||
assert 'foldable' in elt[0].classes
|
||||
assert 'folded' in elt[0].classes
|
||||
|
||||
elt = resp.pyquery.find(f'div[data-section-folded-pref-name={pref_name3}]')
|
||||
assert len(elt) == 1
|
||||
assert 'foldable' in elt[0].classes
|
||||
assert 'folded' not in elt[0].classes
|
||||
|
||||
# Preferences are not "shifted" when a category is deleted
|
||||
agenda2.delete()
|
||||
resp = app.get('/manage/')
|
||||
elt = resp.pyquery.find(f'div[data-section-folded-pref-name={pref_name1}]')
|
||||
assert len(elt) == 1
|
||||
assert 'foldable' in elt[0].classes
|
||||
assert 'folded' not in elt[0].classes
|
||||
|
||||
elt = resp.pyquery.find(f'div[data-section-folded-pref-name={pref_name3}]')
|
||||
assert len(elt) == 1
|
||||
assert 'foldable' in elt[0].classes
|
||||
assert 'folded' not in elt[0].classes
|
||||
|
|
Loading…
Reference in New Issue