diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 33ce0cd8..4f041867 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -7,25 +7,25 @@ stages: variables: GIT_SUBMODULE_STRATEGY: recursive -# Debian Buster -# py37-django22: -# 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: +# Debian Bullseye +py39-django42: stage: test - image: ubuntu:20.04 + image: debian:bullseye + before_script: + - > + apt-get update && + apt-get install --no-install-recommends -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 py39-django42 + +# Ubuntu 22.04 +py310-django42: + stage: test + 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 +37,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 +52,13 @@ 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:bullseye + 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/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 9bbd100a..0e7ab270 100644 --- a/apps/activity/tables.py +++ b/apps/activity/tables.py @@ -5,11 +5,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): @@ -113,3 +115,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 = """ + +""" + + +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 %} +
+

+ {% trans "Openers" %} +

+
+
+ {% csrf_token %} + + {%include "autocomplete_model.html" %} +
+ +
+
+
+ {% render_table opener %} +
+{% endif %} + {% if guests.data %}

@@ -22,6 +42,8 @@ SPDX-License-Identifier: GPL-3.0-or-later {% endblock %} {% block extrajavascript %} + +