diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml
index 97110ecd..2ba35d31 100644
--- a/.gitlab-ci.yml
+++ b/.gitlab-ci.yml
@@ -7,25 +7,10 @@ stages:
variables:
GIT_SUBMODULE_STRATEGY: recursive
-# Debian Buster
-py37-django22:
+# Ubuntu 22.04
+py310-django42:
stage: test
- image: debian:buster-backports
- before_script:
- - >
- apt-get update &&
- apt-get install --no-install-recommends -t buster-backports -y
- python3-django python3-django-crispy-forms
- python3-django-extensions python3-django-filters python3-django-polymorphic
- python3-djangorestframework python3-django-oauth-toolkit python3-psycopg2 python3-pil
- python3-babel python3-lockfile python3-pip python3-phonenumbers python3-memcache
- python3-bs4 python3-setuptools tox texlive-xetex
- script: tox -e py37-django22
-
-# Ubuntu 20.04
-py38-django22:
- stage: test
- image: ubuntu:20.04
+ image: ubuntu:22.04
before_script:
# Fix tzdata prompt
- ln -sf /usr/share/zoneinfo/Europe/Paris /etc/localtime && echo Europe/Paris > /etc/timezone
@@ -37,12 +22,12 @@ py38-django22:
python3-djangorestframework python3-django-oauth-toolkit python3-psycopg2 python3-pil
python3-babel python3-lockfile python3-pip python3-phonenumbers python3-memcache
python3-bs4 python3-setuptools tox texlive-xetex
- script: tox -e py38-django22
+ script: tox -e py310-django42
-# Debian Bullseye
-py39-django22:
+# Debian Bookworm
+py311-django42:
stage: test
- image: debian:bullseye
+ image: debian:bookworm
before_script:
- >
apt-get update &&
@@ -52,11 +37,11 @@ py39-django22:
python3-djangorestframework python3-django-oauth-toolkit python3-psycopg2 python3-pil
python3-babel python3-lockfile python3-pip python3-phonenumbers python3-memcache
python3-bs4 python3-setuptools tox texlive-xetex
- script: tox -e py39-django22
+ script: tox -e py311-django42
linters:
stage: quality-assurance
- image: debian:buster-backports
+ image: debian:bookworm
before_script:
- apt-get update && apt-get install -y tox
script: tox -e linters
diff --git a/.gitmodules b/.gitmodules
index 925f7178..ffc15af5 100644
--- a/.gitmodules
+++ b/.gitmodules
@@ -1,3 +1,3 @@
[submodule "apps/scripts"]
path = apps/scripts
- url = https://gitlab.crans.org/bde/nk20-scripts.git
+ url = https://gitlab.crans.org/bde/nk20-scripts
diff --git a/README.md b/README.md
index 98fe3713..7297a1b6 100644
--- a/README.md
+++ b/README.md
@@ -55,7 +55,7 @@ Bien que cela permette de créer une instance sur toutes les distributions,
(env)$ ./manage.py makemigrations
(env)$ ./manage.py migrate
(env)$ ./manage.py loaddata initial
- (env)$ ./manage.py createsuperuser # Création d'un utilisateur initial
+ (env)$ ./manage.py createsuperuser # Création d'un⋅e utilisateur⋅rice initial
```
6. Enjoy :
diff --git a/apps/activity/admin.py b/apps/activity/admin.py
index 88496361..3355d1aa 100644
--- a/apps/activity/admin.py
+++ b/apps/activity/admin.py
@@ -5,7 +5,7 @@ from django.contrib import admin
from note_kfet.admin import admin_site
from .forms import GuestForm
-from .models import Activity, ActivityType, Entry, Guest
+from .models import Activity, ActivityType, Entry, Guest, Opener
@admin.register(Activity, site=admin_site)
@@ -45,3 +45,11 @@ class EntryAdmin(admin.ModelAdmin):
Admin customisation for Entry
"""
list_display = ('note', 'activity', 'time', 'guest')
+
+
+@admin.register(Opener, site=admin_site)
+class OpenerAdmin(admin.ModelAdmin):
+ """
+ Admin customisation for Opener
+ """
+ list_display = ('activity', 'opener')
diff --git a/apps/activity/api/serializers.py b/apps/activity/api/serializers.py
index e4bc50b8..31c23cb8 100644
--- a/apps/activity/api/serializers.py
+++ b/apps/activity/api/serializers.py
@@ -1,9 +1,11 @@
# Copyright (C) 2018-2024 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later
+from django.utils.translation import gettext_lazy as _
from rest_framework import serializers
+from rest_framework.validators import UniqueTogetherValidator
-from ..models import Activity, ActivityType, Entry, Guest, GuestTransaction
+from ..models import Activity, ActivityType, Entry, Guest, GuestTransaction, Opener
class ActivityTypeSerializer(serializers.ModelSerializer):
@@ -59,3 +61,17 @@ class GuestTransactionSerializer(serializers.ModelSerializer):
class Meta:
model = GuestTransaction
fields = '__all__'
+
+
+class OpenerSerializer(serializers.ModelSerializer):
+ """
+ REST API Serializer for Openers.
+ The djangorestframework plugin will analyse the model `Opener` and parse all fields in the API.
+ """
+
+ class Meta:
+ model = Opener
+ fields = '__all__'
+ validators = [UniqueTogetherValidator(
+ queryset=Opener.objects.all(), fields=("opener", "activity"),
+ message=_("This opener already exists"))]
diff --git a/apps/activity/api/urls.py b/apps/activity/api/urls.py
index 5906705b..4ff977fe 100644
--- a/apps/activity/api/urls.py
+++ b/apps/activity/api/urls.py
@@ -1,7 +1,7 @@
# Copyright (C) 2018-2024 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later
-from .views import ActivityTypeViewSet, ActivityViewSet, EntryViewSet, GuestViewSet
+from .views import ActivityTypeViewSet, ActivityViewSet, EntryViewSet, GuestViewSet, OpenerViewSet
def register_activity_urls(router, path):
@@ -12,3 +12,4 @@ def register_activity_urls(router, path):
router.register(path + '/type', ActivityTypeViewSet)
router.register(path + '/guest', GuestViewSet)
router.register(path + '/entry', EntryViewSet)
+ router.register(path + '/opener', OpenerViewSet)
diff --git a/apps/activity/api/views.py b/apps/activity/api/views.py
index 97e6c40d..afa41ea7 100644
--- a/apps/activity/api/views.py
+++ b/apps/activity/api/views.py
@@ -1,12 +1,15 @@
# Copyright (C) 2018-2024 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later
+from api.filters import RegexSafeSearchFilter
from api.viewsets import ReadProtectedModelViewSet
+from django.core.exceptions import ValidationError
from django_filters.rest_framework import DjangoFilterBackend
-from rest_framework.filters import SearchFilter
+from rest_framework.response import Response
+from rest_framework import status
-from .serializers import ActivitySerializer, ActivityTypeSerializer, EntrySerializer, GuestSerializer
-from ..models import Activity, ActivityType, Entry, Guest
+from .serializers import ActivitySerializer, ActivityTypeSerializer, EntrySerializer, GuestSerializer, OpenerSerializer
+from ..models import Activity, ActivityType, Entry, Guest, Opener
class ActivityTypeViewSet(ReadProtectedModelViewSet):
@@ -29,7 +32,7 @@ class ActivityViewSet(ReadProtectedModelViewSet):
"""
queryset = Activity.objects.order_by('id')
serializer_class = ActivitySerializer
- filter_backends = [DjangoFilterBackend, SearchFilter]
+ filter_backends = [DjangoFilterBackend, RegexSafeSearchFilter]
filterset_fields = ['name', 'description', 'activity_type', 'location', 'creater', 'organizer', 'attendees_club',
'date_start', 'date_end', 'valid', 'open', ]
search_fields = ['$name', '$description', '$location', '$creater__last_name', '$creater__first_name',
@@ -47,7 +50,7 @@ class GuestViewSet(ReadProtectedModelViewSet):
"""
queryset = Guest.objects.order_by('id')
serializer_class = GuestSerializer
- filter_backends = [DjangoFilterBackend, SearchFilter]
+ filter_backends = [DjangoFilterBackend, RegexSafeSearchFilter]
filterset_fields = ['activity', 'activity__name', 'last_name', 'first_name', 'inviter', 'inviter__alias__name',
'inviter__alias__normalized_name', ]
search_fields = ['$activity__name', '$last_name', '$first_name', '$inviter__user__email', '$inviter__alias__name',
@@ -62,7 +65,36 @@ class EntryViewSet(ReadProtectedModelViewSet):
"""
queryset = Entry.objects.order_by('id')
serializer_class = EntrySerializer
- filter_backends = [DjangoFilterBackend, SearchFilter]
+ filter_backends = [DjangoFilterBackend, RegexSafeSearchFilter]
filterset_fields = ['activity', 'time', 'note', 'guest', ]
search_fields = ['$activity__name', '$note__user__email', '$note__alias__name', '$note__alias__normalized_name',
'$guest__last_name', '$guest__first_name', ]
+
+
+class OpenerViewSet(ReadProtectedModelViewSet):
+ """
+ REST Opener View set.
+ The djangorestframework plugin will get all `Opener` objects, serialize it to JSON with the given serializer,
+ then render it on /api/activity/opener/
+ """
+ queryset = Opener.objects
+ serializer_class = OpenerSerializer
+ filter_backends = [RegexSafeSearchFilter, DjangoFilterBackend]
+ search_fields = ['$opener__alias__name', '$opener__alias__normalized_name',
+ '$activity__name']
+ filterset_fields = ['opener', 'opener__noteuser__user', 'activity']
+
+ def get_serializer_class(self):
+ serializer_class = self.serializer_class
+ if self.request.method in ['PUT', 'PATCH']:
+ # opener-activity can't change
+ serializer_class.Meta.read_only_fields = ('opener', 'acitivity',)
+ return serializer_class
+
+ def destroy(self, request, *args, **kwargs):
+ instance = self.get_object()
+ try:
+ self.perform_destroy(instance)
+ except ValidationError as e:
+ return Response({e.code: str(e)}, status.HTTP_400_BAD_REQUEST)
+ return Response(status=status.HTTP_204_NO_CONTENT)
diff --git a/apps/activity/forms.py b/apps/activity/forms.py
index 6e1c35ff..1070f19d 100644
--- a/apps/activity/forms.py
+++ b/apps/activity/forms.py
@@ -4,13 +4,14 @@
from datetime import timedelta
from random import shuffle
+from bootstrap_datepicker_plus.widgets import DateTimePickerInput
from django import forms
from django.contrib.contenttypes.models import ContentType
from django.utils import timezone
from django.utils.translation import gettext_lazy as _
from member.models import Club
from note.models import Note, NoteUser
-from note_kfet.inputs import Autocomplete, DateTimePickerInput
+from note_kfet.inputs import Autocomplete
from note_kfet.middlewares import get_current_request
from permission.backends import PermissionBackend
@@ -43,7 +44,7 @@ class ActivityForm(forms.ModelForm):
class Meta:
model = Activity
- exclude = ('creater', 'valid', 'open', )
+ exclude = ('creater', 'valid', 'open', 'opener', )
widgets = {
"organizer": Autocomplete(
model=Club,
diff --git a/apps/activity/migrations/0004_opener.py b/apps/activity/migrations/0004_opener.py
new file mode 100644
index 00000000..942f5e76
--- /dev/null
+++ b/apps/activity/migrations/0004_opener.py
@@ -0,0 +1,28 @@
+# Generated by Django 2.2.28 on 2024-08-01 12:36
+
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('note', '0006_trust'),
+ ('activity', '0003_auto_20240323_1422'),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='Opener',
+ fields=[
+ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('activity', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='opener', to='activity.Activity', verbose_name='activity')),
+ ('opener', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='activity_responsible', to='note.Note', verbose_name='opener')),
+ ],
+ options={
+ 'verbose_name': 'opener',
+ 'verbose_name_plural': 'openers',
+ 'unique_together': {('opener', 'activity')},
+ },
+ ),
+ ]
diff --git a/apps/activity/migrations/0005_alter_opener_options_alter_opener_opener.py b/apps/activity/migrations/0005_alter_opener_options_alter_opener_opener.py
new file mode 100644
index 00000000..c09500e1
--- /dev/null
+++ b/apps/activity/migrations/0005_alter_opener_options_alter_opener_opener.py
@@ -0,0 +1,24 @@
+# Generated by Django 4.2.15 on 2024-08-28 08:00
+
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('note', '0006_trust'),
+ ('activity', '0004_opener'),
+ ]
+
+ operations = [
+ migrations.AlterModelOptions(
+ name='opener',
+ options={'verbose_name': 'Opener', 'verbose_name_plural': 'Openers'},
+ ),
+ migrations.AlterField(
+ model_name='opener',
+ name='opener',
+ field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='activity_responsible', to='note.note', verbose_name='Opener'),
+ ),
+ ]
diff --git a/apps/activity/models.py b/apps/activity/models.py
index 88cce457..c9f5842e 100644
--- a/apps/activity/models.py
+++ b/apps/activity/models.py
@@ -11,7 +11,7 @@ from django.db import models, transaction
from django.db.models import Q
from django.utils import timezone
from django.utils.translation import gettext_lazy as _
-from note.models import NoteUser, Transaction
+from note.models import NoteUser, Transaction, Note
from rest_framework.exceptions import ValidationError
@@ -310,3 +310,31 @@ class GuestTransaction(Transaction):
@property
def type(self):
return _('Invitation')
+
+
+class Opener(models.Model):
+ """
+ Allow the user to make activity entries without more rights
+ """
+ activity = models.ForeignKey(
+ Activity,
+ on_delete=models.CASCADE,
+ related_name='opener',
+ verbose_name=_('activity')
+ )
+
+ opener = models.ForeignKey(
+ Note,
+ on_delete=models.CASCADE,
+ related_name='activity_responsible',
+ verbose_name=_('Opener')
+ )
+
+ class Meta:
+ verbose_name = _("Opener")
+ verbose_name_plural = _("Openers")
+ unique_together = ("opener", "activity")
+
+ def __str__(self):
+ return _("{opener} is opener of activity {acivity}").format(
+ opener=str(self.opener), acivity=str(self.activity))
diff --git a/apps/activity/static/activity/js/opener.js b/apps/activity/static/activity/js/opener.js
new file mode 100644
index 00000000..801f27a8
--- /dev/null
+++ b/apps/activity/static/activity/js/opener.js
@@ -0,0 +1,57 @@
+/**
+ * On form submit, add a new opener
+ */
+function form_create_opener (e) {
+ // Do not submit HTML form
+ e.preventDefault()
+
+ // Get data and send to API
+ const formData = new FormData(e.target)
+ $.getJSON('/api/note/alias/'+formData.get('opener') + '/',
+ function (opener_alias) {
+ create_opener(formData.get('activity'), opener_alias.note)
+ }).fail(function (xhr, _textStatus, _error) {
+ errMsg(xhr.responseJSON)
+ })
+}
+
+/**
+ * Add an opener between an activity and a user
+ * @param activity:Integer activity id
+ * @param opener:Integer user note id
+ */
+function create_opener(activity, opener) {
+ $.post('/api/activity/opener/', {
+ activity: activity,
+ opener: opener,
+ csrfmiddlewaretoken: CSRF_TOKEN
+ }).done(function () {
+ // Reload tables
+ $('#opener_table').load(location.pathname + ' #opener_table')
+ addMsg(gettext('Opener successfully added'), 'success')
+ }).fail(function (xhr, _textStatus, _error) {
+ errMsg(xhr.responseJSON)
+ })
+}
+
+/**
+ * On click of "delete", delete the opener
+ * @param button_id:Integer Opener id to remove
+ */
+function delete_button (button_id) {
+ $.ajax({
+ url: '/api/activity/opener/' + button_id + '/',
+ method: 'DELETE',
+ headers: { 'X-CSRFTOKEN': CSRF_TOKEN }
+ }).done(function () {
+ addMsg(gettext('Opener successfully deleted'), 'success')
+ $('#opener_table').load(location.pathname + ' #opener_table')
+ }).fail(function (xhr, _textStatus, _error) {
+ errMsg(xhr.responseJSON)
+ })
+}
+
+$(document).ready(function () {
+ // Attach event
+ document.getElementById('form_opener').addEventListener('submit', form_create_opener)
+})
diff --git a/apps/activity/tables.py b/apps/activity/tables.py
index 6485952a..3128aaec 100644
--- a/apps/activity/tables.py
+++ b/apps/activity/tables.py
@@ -7,11 +7,13 @@ from django.utils import timezone
from django.utils.html import escape
from django.utils.safestring import mark_safe
from django.utils.translation import gettext_lazy as _
+from note_kfet.middlewares import get_current_request
import django_tables2 as tables
from django_tables2 import A
+from permission.backends import PermissionBackend
from note.templatetags.pretty_money import pretty_money
-from .models import Activity, Entry, Guest
+from .models import Activity, Entry, Guest, Opener
class ActivityTable(tables.Table):
@@ -118,3 +120,34 @@ class EntryTable(tables.Table):
'data-last-name': lambda record: record.last_name,
'data-first-name': lambda record: record.first_name,
}
+
+
+# function delete_button(id) provided in template file
+DELETE_TEMPLATE = """
+ {{ delete_trans }}
+"""
+
+
+class OpenerTable(tables.Table):
+ class Meta:
+ attrs = {
+ 'class': 'table table condensed table-striped',
+ 'id': "opener_table"
+ }
+ model = Opener
+ fields = ("opener",)
+ template_name = 'django_tables2/bootstrap4.html'
+
+ show_header = False
+ opener = tables.Column(attrs={'td': {'class': 'text-center'}})
+
+ delete_col = tables.TemplateColumn(
+ template_code=DELETE_TEMPLATE,
+ extra_context={"delete_trans": _('Delete')},
+ attrs={
+ 'td': {
+ 'class': lambda record: 'col-sm-1'
+ + (' d-none' if not PermissionBackend.check_perm(
+ get_current_request(), "activity.delete_opener", record)
+ else '')}},
+ verbose_name=_("Delete"),)
diff --git a/apps/activity/templates/activity/activity_detail.html b/apps/activity/templates/activity/activity_detail.html
index 0ba1d481..a94d1e37 100644
--- a/apps/activity/templates/activity/activity_detail.html
+++ b/apps/activity/templates/activity/activity_detail.html
@@ -4,11 +4,31 @@ SPDX-License-Identifier: GPL-3.0-or-later
{% endcomment %}
{% load i18n perms %}
{% load render_table from django_tables2 %}
+{% load static django_tables2 i18n %}
{% block content %}
{{ title }}
{% include "activity/includes/activity_info.html" %}
+{% if activity.activity_type.manage_entries and ".change__opener"|has_perm:activity %}
+
+
+
+ {% render_table opener %}
+
+{% endif %}
+
{% if guests.data %}
diff --git a/apps/member/tests/test_memberships.py b/apps/member/tests/test_memberships.py
index de9f3d3d..1a59c253 100644
--- a/apps/member/tests/test_memberships.py
+++ b/apps/member/tests/test_memberships.py
@@ -291,7 +291,7 @@ class TestMemberships(TestCase):
response = self.client.post(reverse("member:club_manage_roles", args=(self.membership.pk,)), data=dict(
roles=[role.id for role in Role.objects.filter(
- Q(name="Membre de club") | Q(name="Trésorier·ère de club") | Q(name="Bureau de club")).all()],
+ Q(name="Membre de club") | Q(name="Trésorièr⋅e de club") | Q(name="Bureau de club")).all()],
))
self.assertRedirects(response, self.user.profile.get_absolute_url(), 302, 200)
self.membership.refresh_from_db()
diff --git a/apps/member/views.py b/apps/member/views.py
index 7c72d3d4..348bf089 100644
--- a/apps/member/views.py
+++ b/apps/member/views.py
@@ -16,8 +16,9 @@ from django.utils import timezone
from django.utils.translation import gettext_lazy as _
from django.views.generic import DetailView, UpdateView, TemplateView
from django.views.generic.edit import FormMixin
-from django_tables2.views import SingleTableView
+from django_tables2.views import MultiTableMixin, SingleTableMixin, SingleTableView
from rest_framework.authtoken.models import Token
+from api.viewsets import is_regex
from note.models import Alias, NoteClub, NoteUser, Trust
from note.models.transactions import Transaction, SpecialTransaction
from note.tables import HistoryTable, AliasTable, TrustTable, TrustedTable
@@ -26,7 +27,7 @@ from permission.backends import PermissionBackend
from permission.models import Role
from permission.views import ProtectQuerysetMixin, ProtectedCreateView
-from .forms import UserForm, ProfileForm, ImageForm, ClubForm, MembershipForm,\
+from .forms import UserForm, ProfileForm, ImageForm, ClubForm, MembershipForm, \
CustomAuthenticationForm, MembershipRolesForm
from .models import Club, Membership
from .tables import ClubTable, UserTable, MembershipTable, ClubManagerTable
@@ -219,16 +220,20 @@ class UserListView(ProtectQuerysetMixin, LoginRequiredMixin, SingleTableView):
if "search" in self.request.GET and self.request.GET["search"]:
pattern = self.request.GET["search"]
+ # Check if this is a valid regex. If not, we won't check regex
+ valid_regex = is_regex(pattern)
+ suffix = "__iregex" if valid_regex else "__istartswith"
+ prefix = "^" if valid_regex else ""
qs = qs.filter(
- username__iregex="^" + pattern
+ Q(**{f"username{suffix}": prefix + pattern})
).union(
qs.filter(
- (Q(alias__iregex="^" + pattern)
- | Q(normalized_alias__iregex="^" + Alias.normalize(pattern))
- | Q(last_name__iregex="^" + pattern)
- | Q(first_name__iregex="^" + pattern)
+ (Q(**{f"alias{suffix}": prefix + pattern})
+ | Q(**{f"normalized_alias{suffix}": prefix + Alias.normalize(pattern)})
+ | Q(**{f"last_name{suffix}": prefix + pattern})
+ | Q(**{f"first_name{suffix}": prefix + pattern})
| Q(email__istartswith=pattern))
- & ~Q(username__iregex="^" + pattern)
+ & ~Q(**{f"username{suffix}": prefix + pattern})
), all=True)
else:
qs = qs.none()
@@ -243,7 +248,7 @@ class UserListView(ProtectQuerysetMixin, LoginRequiredMixin, SingleTableView):
return context
-class ProfileTrustView(ProtectQuerysetMixin, LoginRequiredMixin, DetailView):
+class ProfileTrustView(ProtectQuerysetMixin, LoginRequiredMixin, MultiTableMixin, DetailView):
"""
View and manage user trust relationships
"""
@@ -252,13 +257,25 @@ class ProfileTrustView(ProtectQuerysetMixin, LoginRequiredMixin, DetailView):
context_object_name = 'user_object'
extra_context = {"title": _("Note friendships")}
+ tables = [
+ lambda data: TrustTable(data, prefix="trust-"),
+ lambda data: TrustedTable(data, prefix="trusted-"),
+ ]
+
+ def get_tables_data(self):
+ note = self.object.note
+ return [
+ note.trusting.filter(PermissionBackend.filter_queryset(self.request, Trust, "view")).distinct(),
+ note.trusted.filter(PermissionBackend.filter_queryset(self.request, Trust, "view")).distinct(),
+ ]
+
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
- note = context['object'].note
- context["trusting"] = TrustTable(
- note.trusting.filter(PermissionBackend.filter_queryset(self.request, Trust, "view")).distinct().all())
- context["trusted_by"] = TrustedTable(
- note.trusted.filter(PermissionBackend.filter_queryset(self.request, Trust, "view")).distinct().all())
+
+ tables = context["tables"]
+ for name, table in zip(["trusting", "trusted_by"], tables):
+ context[name] = table
+
context["can_create"] = PermissionBackend.check_perm(self.request, "note.add_trust", Trust(
trusting=context["object"].note,
trusted=context["object"].note
@@ -277,7 +294,7 @@ class ProfileTrustView(ProtectQuerysetMixin, LoginRequiredMixin, DetailView):
return context
-class ProfileAliasView(ProtectQuerysetMixin, LoginRequiredMixin, DetailView):
+class ProfileAliasView(ProtectQuerysetMixin, LoginRequiredMixin, SingleTableMixin, DetailView):
"""
View and manage user aliases.
"""
@@ -286,12 +303,15 @@ class ProfileAliasView(ProtectQuerysetMixin, LoginRequiredMixin, DetailView):
context_object_name = 'user_object'
extra_context = {"title": _("Note aliases")}
+ table_class = AliasTable
+ context_table_name = "aliases"
+
+ def get_table_data(self):
+ return self.object.note.alias.filter(PermissionBackend.filter_queryset(self.request, Alias, "view")).distinct() \
+ .order_by('normalized_name')
+
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
- note = context['object'].note
- context["aliases"] = AliasTable(
- note.alias.filter(PermissionBackend.filter_queryset(self.request, Alias, "view")).distinct()
- .order_by('normalized_name').all())
context["can_create"] = PermissionBackend.check_perm(self.request, "note.add_alias", Alias(
note=context["object"].note,
name="",
@@ -326,12 +346,15 @@ class PictureUpdateView(ProtectQuerysetMixin, LoginRequiredMixin, FormMixin, Det
"""Save image to note"""
image = form.cleaned_data['image']
- # Rename as a PNG or GIF
- extension = image.name.split(".")[-1]
- if extension == "gif":
- image.name = "{}_pic.gif".format(self.object.note.pk)
+ if image is None:
+ image = "pic/default.png"
else:
- image.name = "{}_pic.png".format(self.object.note.pk)
+ # Rename as a PNG or GIF
+ extension = image.name.split(".")[-1]
+ if extension == "gif":
+ image.name = "{}_pic.gif".format(self.object.note.pk)
+ else:
+ image.name = "{}_pic.png".format(self.object.note.pk)
# Save
self.object.note.display_image = image
@@ -407,10 +430,15 @@ class ClubListView(ProtectQuerysetMixin, LoginRequiredMixin, SingleTableView):
if "search" in self.request.GET:
pattern = self.request.GET["search"]
+ # Check if this is a valid regex. If not, we won't check regex
+ valid_regex = is_regex(pattern)
+ suffix = "__iregex" if valid_regex else "__istartswith"
+ prefix = "^" if valid_regex else ""
+
qs = qs.filter(
- Q(name__iregex=pattern)
- | Q(note__alias__name__iregex=pattern)
- | Q(note__alias__normalized_name__iregex=Alias.normalize(pattern))
+ Q(**{f"name{suffix}": prefix + pattern})
+ | Q(**{f"note__alias__name{suffix}": prefix + pattern})
+ | Q(**{f"note__alias__normalized_name{suffix}": prefix + Alias.normalize(pattern)})
)
return qs
@@ -507,7 +535,7 @@ class ClubDetailView(ProtectQuerysetMixin, LoginRequiredMixin, DetailView):
return context
-class ClubAliasView(ProtectQuerysetMixin, LoginRequiredMixin, DetailView):
+class ClubAliasView(ProtectQuerysetMixin, LoginRequiredMixin, SingleTableMixin, DetailView):
"""
Manage aliases of a club.
"""
@@ -516,11 +544,16 @@ class ClubAliasView(ProtectQuerysetMixin, LoginRequiredMixin, DetailView):
context_object_name = 'club'
extra_context = {"title": _("Note aliases")}
+ table_class = AliasTable
+ context_table_name = "aliases"
+
+ def get_table_data(self):
+ return self.object.note.alias.filter(
+ PermissionBackend.filter_queryset(self.request, Alias, "view")).distinct()
+
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
- note = context['object'].note
- context["aliases"] = AliasTable(note.alias.filter(
- PermissionBackend.filter_queryset(self.request, Alias, "view")).distinct().all())
+
context["can_create"] = PermissionBackend.check_perm(self.request, "note.add_alias", Alias(
note=context["object"].note,
name="",
@@ -824,8 +857,8 @@ class ClubAddMemberView(ProtectQuerysetMixin, ProtectedCreateView):
ret = super().form_valid(form)
- member_role = Role.objects.filter(Q(name="Adhérent BDE") | Q(name="Membre de club")).all() \
- if club.name == "BDE" else Role.objects.filter(Q(name="Adhérent Kfet") | Q(name="Membre de club")).all() \
+ member_role = Role.objects.filter(Q(name="Adhérent⋅e BDE") | Q(name="Membre de club")).all() \
+ if club.name == "BDE" else Role.objects.filter(Q(name="Adhérent⋅e Kfet") | Q(name="Membre de club")).all() \
if club.name == "Kfet"else Role.objects.filter(name="Membre de club").all()
# Set the same roles as before
if old_membership:
@@ -861,7 +894,7 @@ class ClubAddMemberView(ProtectQuerysetMixin, ProtectedCreateView):
membership.refresh_from_db()
if old_membership.exists():
membership.roles.set(old_membership.get().roles.all())
- membership.roles.set(Role.objects.filter(Q(name="Adhérent Kfet") | Q(name="Membre de club")).all())
+ membership.roles.set(Role.objects.filter(Q(name="Adhérent⋅e Kfet") | Q(name="Membre de club")).all())
membership.save()
return ret
@@ -909,10 +942,15 @@ class ClubMembersListView(ProtectQuerysetMixin, LoginRequiredMixin, SingleTableV
if 'search' in self.request.GET:
pattern = self.request.GET['search']
+
+ # Check if this is a valid regex. If not, we won't check regex
+ valid_regex = is_regex(pattern)
+ suffix = "__iregex" if valid_regex else "__istartswith"
+ prefix = "^" if valid_regex else ""
qs = qs.filter(
- Q(user__first_name__iregex='^' + pattern)
- | Q(user__last_name__iregex='^' + pattern)
- | Q(user__note__alias__normalized_name__iregex='^' + Alias.normalize(pattern))
+ Q(**{f"user__first_name{suffix}": prefix + pattern})
+ | Q(**{f"user__last_name{suffix}": prefix + pattern})
+ | Q(**{f"user__note__alias__normalized_name{suffix}": prefix + Alias.normalize(pattern)})
)
only_active = "only_active" not in self.request.GET or self.request.GET["only_active"] != '0'
diff --git a/apps/note/api/views.py b/apps/note/api/views.py
index 39b9b270..5acda17c 100644
--- a/apps/note/api/views.py
+++ b/apps/note/api/views.py
@@ -1,19 +1,19 @@
# Copyright (C) 2018-2024 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later
-import re
from django.conf import settings
from django.db.models import Q
from django.core.exceptions import ValidationError
from django_filters.rest_framework import DjangoFilterBackend
-from rest_framework.filters import OrderingFilter, SearchFilter
-from rest_framework import viewsets
+from rest_framework.filters import OrderingFilter
+from rest_framework import status, viewsets
from rest_framework.response import Response
-from rest_framework import status
-from api.viewsets import ReadProtectedModelViewSet, ReadOnlyProtectedModelViewSet
+from api.filters import RegexSafeSearchFilter
+from api.viewsets import ReadProtectedModelViewSet, ReadOnlyProtectedModelViewSet, \
+ is_regex
from permission.backends import PermissionBackend
-from .serializers import NotePolymorphicSerializer, AliasSerializer, ConsumerSerializer,\
+from .serializers import NotePolymorphicSerializer, AliasSerializer, ConsumerSerializer, \
TemplateCategorySerializer, TransactionTemplateSerializer, TransactionPolymorphicSerializer, \
TrustSerializer
from ..models.notes import Note, Alias, NoteUser, NoteClub, NoteSpecial, Trust
@@ -29,7 +29,7 @@ class NotePolymorphicViewSet(ReadProtectedModelViewSet):
"""
queryset = Note.objects.order_by('id')
serializer_class = NotePolymorphicSerializer
- filter_backends = [DjangoFilterBackend, SearchFilter, OrderingFilter]
+ filter_backends = [DjangoFilterBackend, RegexSafeSearchFilter, OrderingFilter]
filterset_fields = ['alias__name', 'polymorphic_ctype', 'is_active', 'balance', 'last_negative', 'created_at', ]
search_fields = ['$alias__normalized_name', '$alias__name', '$polymorphic_ctype__model',
'$noteuser__user__last_name', '$noteuser__user__first_name', '$noteuser__user__email',
@@ -48,10 +48,14 @@ class NotePolymorphicViewSet(ReadProtectedModelViewSet):
.distinct()
alias = self.request.query_params.get("alias", ".*")
+ # Check if this is a valid regex. If not, we won't check regex
+ valid_regex = is_regex(alias)
+ suffix = '__iregex' if valid_regex else '__istartswith'
+ alias_prefix = '^' if valid_regex else ''
queryset = queryset.filter(
- Q(alias__name__iregex="^" + alias)
- | Q(alias__normalized_name__iregex="^" + Alias.normalize(alias))
- | Q(alias__normalized_name__iregex="^" + alias.lower())
+ Q(**{f"alias__name{suffix}": alias_prefix + alias})
+ | Q(**{f"alias__normalized_name{suffix}": alias_prefix + Alias.normalize(alias)})
+ | Q(**{f"alias__normalized_name{suffix}": alias_prefix + alias.lower()})
)
return queryset.order_by("id")
@@ -65,7 +69,7 @@ class TrustViewSet(ReadProtectedModelViewSet):
"""
queryset = Trust.objects
serializer_class = TrustSerializer
- filter_backends = [SearchFilter, DjangoFilterBackend, OrderingFilter]
+ filter_backends = [RegexSafeSearchFilter, DjangoFilterBackend, OrderingFilter]
search_fields = ['$trusting__alias__name', '$trusting__alias__normalized_name',
'$trusted__alias__name', '$trusted__alias__normalized_name']
filterset_fields = ['trusting', 'trusting__noteuser__user', 'trusted', 'trusted__noteuser__user']
@@ -91,11 +95,11 @@ class AliasViewSet(ReadProtectedModelViewSet):
"""
REST API View set.
The djangorestframework plugin will get all `Alias` objects, serialize it to JSON with the given serializer,
- then render it on /api/note/aliases/
+ then render it on /api/note/alias/
"""
queryset = Alias.objects
serializer_class = AliasSerializer
- filter_backends = [SearchFilter, DjangoFilterBackend, OrderingFilter]
+ filter_backends = [RegexSafeSearchFilter, DjangoFilterBackend, OrderingFilter]
search_fields = ['$normalized_name', '$name', '$note__polymorphic_ctype__model', ]
filterset_fields = ['name', 'normalized_name', 'note', 'note__noteuser__user',
'note__noteclub__club', 'note__polymorphic_ctype__model', ]
@@ -126,18 +130,22 @@ class AliasViewSet(ReadProtectedModelViewSet):
alias = self.request.query_params.get("alias", None)
if alias:
+ # Check if this is a valid regex. If not, we won't check regex
+ valid_regex = is_regex(alias)
+ suffix = '__iregex' if valid_regex else '__istartswith'
+ alias_prefix = '^' if valid_regex else ''
queryset = queryset.filter(
- name__iregex="^" + alias
+ **{f"name{suffix}": alias_prefix + alias}
).union(
queryset.filter(
- Q(normalized_name__iregex="^" + Alias.normalize(alias))
- & ~Q(name__iregex="^" + alias)
+ Q(**{f"normalized_name{suffix}": alias_prefix + Alias.normalize(alias)})
+ & ~Q(**{f"name{suffix}": alias_prefix + alias})
),
all=True).union(
queryset.filter(
- Q(normalized_name__iregex="^" + alias.lower())
- & ~Q(normalized_name__iregex="^" + Alias.normalize(alias))
- & ~Q(name__iregex="^" + alias)
+ Q(**{f"normalized_name{suffix}": "^" + alias.lower()})
+ & ~Q(**{f"normalized_name{suffix}": "^" + Alias.normalize(alias)})
+ & ~Q(**{f"name{suffix}": "^" + alias})
),
all=True)
@@ -147,7 +155,7 @@ class AliasViewSet(ReadProtectedModelViewSet):
class ConsumerViewSet(ReadOnlyProtectedModelViewSet):
queryset = Alias.objects
serializer_class = ConsumerSerializer
- filter_backends = [SearchFilter, OrderingFilter, DjangoFilterBackend]
+ filter_backends = [RegexSafeSearchFilter, OrderingFilter, DjangoFilterBackend]
search_fields = ['$normalized_name', '$name', '$note__polymorphic_ctype__model', ]
filterset_fields = ['name', 'normalized_name', 'note', 'note__noteuser__user',
'note__noteclub__club', 'note__polymorphic_ctype__model', ]
@@ -166,11 +174,7 @@ class ConsumerViewSet(ReadOnlyProtectedModelViewSet):
alias = self.request.query_params.get("alias", None)
# Check if this is a valid regex. If not, we won't check regex
- try:
- re.compile(alias)
- valid_regex = True
- except (re.error, TypeError):
- valid_regex = False
+ valid_regex = is_regex(alias)
suffix = '__iregex' if valid_regex else '__istartswith'
alias_prefix = '^' if valid_regex else ''
queryset = queryset.prefetch_related('note')
@@ -179,19 +183,10 @@ class ConsumerViewSet(ReadOnlyProtectedModelViewSet):
# We match first an alias if it is matched without normalization,
# then if the normalized pattern matches a normalized alias.
queryset = queryset.filter(
- **{f'name{suffix}': alias_prefix + alias}
- ).union(
- queryset.filter(
- Q(**{f'normalized_name{suffix}': alias_prefix + Alias.normalize(alias)})
- & ~Q(**{f'name{suffix}': alias_prefix + alias})
- ),
- all=True).union(
- queryset.filter(
- Q(**{f'normalized_name{suffix}': alias_prefix + alias.lower()})
- & ~Q(**{f'normalized_name{suffix}': alias_prefix + Alias.normalize(alias)})
- & ~Q(**{f'name{suffix}': alias_prefix + alias})
- ),
- all=True)
+ Q(**{f'name{suffix}': alias_prefix + alias})
+ | Q(**{f'normalized_name{suffix}': alias_prefix + Alias.normalize(alias)})
+ | Q(**{f'normalized_name{suffix}': alias_prefix + alias.lower()})
+ )
queryset = queryset if settings.DATABASES[queryset.db]["ENGINE"] == 'django.db.backends.postgresql' \
else queryset.order_by("name")
@@ -207,7 +202,7 @@ class TemplateCategoryViewSet(ReadProtectedModelViewSet):
"""
queryset = TemplateCategory.objects.order_by('name')
serializer_class = TemplateCategorySerializer
- filter_backends = [DjangoFilterBackend, SearchFilter]
+ filter_backends = [DjangoFilterBackend, RegexSafeSearchFilter]
filterset_fields = ['name', 'templates', 'templates__name']
search_fields = ['$name', '$templates__name', ]
@@ -220,7 +215,7 @@ class TransactionTemplateViewSet(viewsets.ModelViewSet):
"""
queryset = TransactionTemplate.objects.order_by('name')
serializer_class = TransactionTemplateSerializer
- filter_backends = [SearchFilter, DjangoFilterBackend, OrderingFilter]
+ filter_backends = [RegexSafeSearchFilter, DjangoFilterBackend, OrderingFilter]
filterset_fields = ['name', 'amount', 'display', 'category', 'category__name', ]
search_fields = ['$name', '$category__name', ]
ordering_fields = ['amount', ]
@@ -234,7 +229,7 @@ class TransactionViewSet(ReadProtectedModelViewSet):
"""
queryset = Transaction.objects.order_by('-created_at')
serializer_class = TransactionPolymorphicSerializer
- filter_backends = [SearchFilter, DjangoFilterBackend, OrderingFilter]
+ filter_backends = [RegexSafeSearchFilter, DjangoFilterBackend, OrderingFilter]
filterset_fields = ['source', 'source_alias', 'source__alias__name', 'source__alias__normalized_name',
'destination', 'destination_alias', 'destination__alias__name',
'destination__alias__normalized_name', 'quantity', 'polymorphic_ctype', 'amount',
diff --git a/apps/note/forms.py b/apps/note/forms.py
index 209b49d9..aa722b57 100644
--- a/apps/note/forms.py
+++ b/apps/note/forms.py
@@ -2,12 +2,13 @@
# SPDX-License-Identifier: GPL-3.0-or-later
from datetime import datetime
+from bootstrap_datepicker_plus.widgets import DateTimePickerInput
from django import forms
from django.contrib.contenttypes.models import ContentType
from django.forms import CheckboxSelectMultiple
from django.utils.timezone import make_aware
from django.utils.translation import gettext_lazy as _
-from note_kfet.inputs import Autocomplete, AmountInput, DateTimePickerInput
+from note_kfet.inputs import Autocomplete, AmountInput
from .models import TransactionTemplate, NoteClub, Alias
diff --git a/apps/note/migrations/0002_create_special_notes.py b/apps/note/migrations/0002_create_special_notes.py
index 12fa8583..07935d54 100644
--- a/apps/note/migrations/0002_create_special_notes.py
+++ b/apps/note/migrations/0002_create_special_notes.py
@@ -18,6 +18,7 @@ def create_special_notes(apps, schema_editor):
class Migration(migrations.Migration):
dependencies = [
('note', '0001_initial'),
+ ('logs', '0001_initial'),
]
operations = [
diff --git a/apps/note/migrations/0007_alter_note_polymorphic_ctype_and_more.py b/apps/note/migrations/0007_alter_note_polymorphic_ctype_and_more.py
new file mode 100644
index 00000000..8d0e8a19
--- /dev/null
+++ b/apps/note/migrations/0007_alter_note_polymorphic_ctype_and_more.py
@@ -0,0 +1,25 @@
+# Generated by Django 4.2.15 on 2024-08-28 08:00
+
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('contenttypes', '0002_remove_content_type_name'),
+ ('note', '0006_trust'),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name='note',
+ name='polymorphic_ctype',
+ field=models.ForeignKey(editable=False, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='polymorphic_%(app_label)s.%(class)s_set+', to='contenttypes.contenttype'),
+ ),
+ migrations.AlterField(
+ model_name='transaction',
+ name='polymorphic_ctype',
+ field=models.ForeignKey(editable=False, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='polymorphic_%(app_label)s.%(class)s_set+', to='contenttypes.contenttype'),
+ ),
+ ]
diff --git a/apps/note/tables.py b/apps/note/tables.py
index 3ca2d1d4..a4e944f9 100644
--- a/apps/note/tables.py
+++ b/apps/note/tables.py
@@ -260,11 +260,13 @@ class ButtonTable(tables.Table):
text=_('edit'),
accessor='pk',
verbose_name=_("Edit"),
+ orderable=False,
)
hideshow = tables.Column(
verbose_name=_("Hide/Show"),
accessor="pk",
+ orderable=False,
attrs={
'td': {
'class': 'col-sm-1',
@@ -276,7 +278,8 @@ class ButtonTable(tables.Table):
delete_col = tables.TemplateColumn(template_code=DELETE_TEMPLATE,
extra_context={"delete_trans": _('delete')},
attrs={'td': {'class': 'col-sm-1'}},
- verbose_name=_("Delete"), )
+ verbose_name=_("Delete"),
+ orderable=False, )
def render_amount(self, value):
return pretty_money(value)
diff --git a/apps/note/templates/note/amount_input.html b/apps/note/templates/note/amount_input.html
index d4873115..cbe9d160 100644
--- a/apps/note/templates/note/amount_input.html
+++ b/apps/note/templates/note/amount_input.html
@@ -9,7 +9,7 @@ SPDX-License-Identifier: GPL-3.0-or-later
name="{{ widget.name }}"
{# Other attributes are loaded #}
{% for name, value in widget.attrs.items %}
- {% ifnotequal value False %}{{ name }}{% ifnotequal value True %}="{{ value|stringformat:'s' }}"{% endifnotequal %}{% endifnotequal %}
+ {% if value is not False %}{{ name }}{% if value is not True %}="{{ value|stringformat:'s' }}"{% endif %}{% endif %}
{% endfor %}>