1
0
mirror of https://gitlab.crans.org/bde/nk20 synced 2025-06-23 19:06:37 +02:00

Compare commits

...

30 Commits

Author SHA1 Message Date
2ab5c4082a Merge branch 'beta' into 'main'
revert sort tables to member views

See merge request bde/nk20!262
2024-08-25 15:17:36 +02:00
053225c6dc revert sort tables to member views 2024-08-25 15:13:02 +02:00
ac7b86651d Merge branch 'beta' into 'main'
api errors (fix #113), sortable tables, calendar (fix #95), opener (fix #117), colored linters, inclusif, bug july 31, 403 (fix #65)

Closes #65, #117, #95, and #113

See merge request bde/nk20!260
2024-08-25 14:45:08 +02:00
21f5a5d566 Merge branch 'invoice_template' into 'main'
Update invoice_sample.tex, remove link toward bde.ens-cachan

See merge request bde/nk20!261
2024-08-25 14:34:37 +02:00
ff9c78ed4e added opener in admin and fixed the guest view 2024-08-25 14:29:06 +02:00
1e121297d1 Update invoice_sample.tex, remove link toward bde.ens-cachan 2024-08-23 00:32:37 +02:00
28117c8c61 Add developers, Opener comments 2024-08-10 11:50:27 +02:00
4be4a18dd1 Merge branch 'sortable_tables' into 'beta'
Sortable tables

See merge request bde/nk20!257
2024-08-08 17:37:31 +02:00
27b00ba4f0 Merge branch 'beta' into sortable_tables 2024-08-08 17:27:44 +02:00
3fcbb4f310 Merge branch 'no-api-error' into 'beta'
fix #113

See merge request bde/nk20!253
2024-08-08 17:05:25 +02:00
d1c9a2a7f1 Merge branch 'beta' into no-api-error 2024-08-08 16:54:21 +02:00
a673fd6871 Merge branch 'ouvreureuse' into 'beta'
Ouvreureuse

See merge request bde/nk20!256
2024-08-08 16:41:06 +02:00
a324d3a892 Merge branch 'beta' into ouvreureuse 2024-08-08 16:28:22 +02:00
951ba74f8f Merge branch 'bug_31_july' into 'beta'
bug du jour 31 juillet (bissextile)

See merge request bde/nk20!254
2024-08-08 16:23:21 +02:00
abc4f14bd1 Merge branch '404_or_403' into 'beta'
fix #65 Returning 403 when you don't have enough permissions

See merge request bde/nk20!259
2024-08-07 21:54:54 +02:00
47138bafd4 Merge branch 'traduction_inclusive_fr' into 'beta'
De l'inclusif, partout

See merge request bde/nk20!258
2024-08-07 21:45:05 +02:00
a3920fcae3 Merge branch 'Fix_time_zone_calendar.ics' into 'beta'
Update views.py - Fix calendar.ics

See merge request bde/nk20!237
2024-08-07 21:26:32 +02:00
ae4213d087 Merge branch 'colored_linters' into 'beta'
Colored linters

See merge request bde/nk20!255
2024-08-07 21:25:22 +02:00
cbf92651f0 Returning 403 when you don't have enough permissions 2024-08-04 21:58:57 +02:00
12c93ff9da bug du jour 31 juillet (bissextile) 2024-08-04 14:45:17 +02:00
354c79bb82 Inclusif manquant 2024-08-04 13:32:33 +02:00
1ea7b3dda1 documentation and modification of permissions 2024-08-02 15:21:34 +02:00
35ffbfcf55 Colored linters 2024-08-01 17:29:24 +02:00
162371042c Creation of "Opener", Fix #117 2024-08-01 14:49:52 +02:00
581715d804 Fix #95 (calendar) 2024-07-31 23:18:41 +02:00
c7c6f0350f Looks unused 2024-07-31 22:19:16 +02:00
9d1024024b Each table can be sorted (with a few exceptions) 2024-07-30 21:42:45 +02:00
7322d55789 Fix #113. Fix regex in views. 2024-07-19 20:00:33 +02:00
1a258dfe9e Parse input of search filters to prevent errors based on invalid regex, fixes #113
Signed-off-by: Yohann D'ANELLO <ynerant@crans.org>
2024-07-19 19:59:30 +02:00
6c61daf1c5 Update views.py
Passage à la time zone Europe/Paris
2024-03-11 10:25:48 +01:00
46 changed files with 1032 additions and 378 deletions

View File

@ -5,7 +5,7 @@ from django.contrib import admin
from note_kfet.admin import admin_site from note_kfet.admin import admin_site
from .forms import GuestForm 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) @admin.register(Activity, site=admin_site)
@ -45,3 +45,11 @@ class EntryAdmin(admin.ModelAdmin):
Admin customisation for Entry Admin customisation for Entry
""" """
list_display = ('note', 'activity', 'time', 'guest') list_display = ('note', 'activity', 'time', 'guest')
@admin.register(Opener, site=admin_site)
class OpenerAdmin(admin.ModelAdmin):
"""
Admin customisation for Opener
"""
list_display = ('activity', 'opener')

View File

@ -1,9 +1,11 @@
# Copyright (C) 2018-2024 by BDE ENS Paris-Saclay # Copyright (C) 2018-2024 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later # SPDX-License-Identifier: GPL-3.0-or-later
from django.utils.translation import gettext_lazy as _
from rest_framework import serializers 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): class ActivityTypeSerializer(serializers.ModelSerializer):
@ -59,3 +61,17 @@ class GuestTransactionSerializer(serializers.ModelSerializer):
class Meta: class Meta:
model = GuestTransaction model = GuestTransaction
fields = '__all__' 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"))]

View File

@ -1,7 +1,7 @@
# Copyright (C) 2018-2024 by BDE ENS Paris-Saclay # Copyright (C) 2018-2024 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later # 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): def register_activity_urls(router, path):
@ -12,3 +12,4 @@ def register_activity_urls(router, path):
router.register(path + '/type', ActivityTypeViewSet) router.register(path + '/type', ActivityTypeViewSet)
router.register(path + '/guest', GuestViewSet) router.register(path + '/guest', GuestViewSet)
router.register(path + '/entry', EntryViewSet) router.register(path + '/entry', EntryViewSet)
router.register(path + '/opener', OpenerViewSet)

View File

@ -1,12 +1,15 @@
# Copyright (C) 2018-2024 by BDE ENS Paris-Saclay # Copyright (C) 2018-2024 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later # SPDX-License-Identifier: GPL-3.0-or-later
from api.filters import RegexSafeSearchFilter
from api.viewsets import ReadProtectedModelViewSet from api.viewsets import ReadProtectedModelViewSet
from django.core.exceptions import ValidationError
from django_filters.rest_framework import DjangoFilterBackend 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 .serializers import ActivitySerializer, ActivityTypeSerializer, EntrySerializer, GuestSerializer, OpenerSerializer
from ..models import Activity, ActivityType, Entry, Guest from ..models import Activity, ActivityType, Entry, Guest, Opener
class ActivityTypeViewSet(ReadProtectedModelViewSet): class ActivityTypeViewSet(ReadProtectedModelViewSet):
@ -29,7 +32,7 @@ class ActivityViewSet(ReadProtectedModelViewSet):
""" """
queryset = Activity.objects.order_by('id') queryset = Activity.objects.order_by('id')
serializer_class = ActivitySerializer serializer_class = ActivitySerializer
filter_backends = [DjangoFilterBackend, SearchFilter] filter_backends = [DjangoFilterBackend, RegexSafeSearchFilter]
filterset_fields = ['name', 'description', 'activity_type', 'location', 'creater', 'organizer', 'attendees_club', filterset_fields = ['name', 'description', 'activity_type', 'location', 'creater', 'organizer', 'attendees_club',
'date_start', 'date_end', 'valid', 'open', ] 'date_start', 'date_end', 'valid', 'open', ]
search_fields = ['$name', '$description', '$location', '$creater__last_name', '$creater__first_name', search_fields = ['$name', '$description', '$location', '$creater__last_name', '$creater__first_name',
@ -47,7 +50,7 @@ class GuestViewSet(ReadProtectedModelViewSet):
""" """
queryset = Guest.objects.order_by('id') queryset = Guest.objects.order_by('id')
serializer_class = GuestSerializer serializer_class = GuestSerializer
filter_backends = [DjangoFilterBackend, SearchFilter] filter_backends = [DjangoFilterBackend, RegexSafeSearchFilter]
filterset_fields = ['activity', 'activity__name', 'last_name', 'first_name', 'inviter', 'inviter__alias__name', filterset_fields = ['activity', 'activity__name', 'last_name', 'first_name', 'inviter', 'inviter__alias__name',
'inviter__alias__normalized_name', ] 'inviter__alias__normalized_name', ]
search_fields = ['$activity__name', '$last_name', '$first_name', '$inviter__user__email', '$inviter__alias__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') queryset = Entry.objects.order_by('id')
serializer_class = EntrySerializer serializer_class = EntrySerializer
filter_backends = [DjangoFilterBackend, SearchFilter] filter_backends = [DjangoFilterBackend, RegexSafeSearchFilter]
filterset_fields = ['activity', 'time', 'note', 'guest', ] filterset_fields = ['activity', 'time', 'note', 'guest', ]
search_fields = ['$activity__name', '$note__user__email', '$note__alias__name', '$note__alias__normalized_name', search_fields = ['$activity__name', '$note__user__email', '$note__alias__name', '$note__alias__normalized_name',
'$guest__last_name', '$guest__first_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)

View File

@ -43,7 +43,7 @@ class ActivityForm(forms.ModelForm):
class Meta: class Meta:
model = Activity model = Activity
exclude = ('creater', 'valid', 'open', ) exclude = ('creater', 'valid', 'open', 'opener', )
widgets = { widgets = {
"organizer": Autocomplete( "organizer": Autocomplete(
model=Club, model=Club,

View File

@ -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')},
},
),
]

View File

@ -11,7 +11,7 @@ from django.db import models, transaction
from django.db.models import Q from django.db.models import Q
from django.utils import timezone from django.utils import timezone
from django.utils.translation import gettext_lazy as _ 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 from rest_framework.exceptions import ValidationError
@ -310,3 +310,31 @@ class GuestTransaction(Transaction):
@property @property
def type(self): def type(self):
return _('Invitation') 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))

View File

@ -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)
})

View File

@ -5,11 +5,13 @@ from django.utils import timezone
from django.utils.html import escape from django.utils.html import escape
from django.utils.safestring import mark_safe from django.utils.safestring import mark_safe
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from note_kfet.middlewares import get_current_request
import django_tables2 as tables import django_tables2 as tables
from django_tables2 import A from django_tables2 import A
from permission.backends import PermissionBackend
from note.templatetags.pretty_money import pretty_money 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): class ActivityTable(tables.Table):
@ -113,3 +115,34 @@ class EntryTable(tables.Table):
'data-last-name': lambda record: record.last_name, 'data-last-name': lambda record: record.last_name,
'data-first-name': lambda record: record.first_name, 'data-first-name': lambda record: record.first_name,
} }
# function delete_button(id) provided in template file
DELETE_TEMPLATE = """
<button id="{{ record.pk }}" class="btn btn-danger btn-sm" onclick="delete_button(this.id)"> {{ delete_trans }}</button>
"""
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"),)

View File

@ -4,11 +4,31 @@ SPDX-License-Identifier: GPL-3.0-or-later
{% endcomment %} {% endcomment %}
{% load i18n perms %} {% load i18n perms %}
{% load render_table from django_tables2 %} {% load render_table from django_tables2 %}
{% load static django_tables2 i18n %}
{% block content %} {% block content %}
<h1 class="text-white">{{ title }}</h1> <h1 class="text-white">{{ title }}</h1>
{% include "activity/includes/activity_info.html" %} {% include "activity/includes/activity_info.html" %}
{% if activity.activity_type.manage_entries and ".change__opener"|has_perm:activity %}
<div class="card bg-white mb-3">
<h3 class="card-header text-center">
{% trans "Openers" %}
</h3>
<div class="card-body">
<form class="input-group" method="POST" id="form_opener">
{% csrf_token %}
<input type="hidden" name="activity" value="{{ object.pk }}">
{%include "autocomplete_model.html" %}
<div class="input-group-append">
<input type="submit" class="btn btn-success" value="{% trans "Add" %}">
</div>
</form>
</div>
{% render_table opener %}
</div>
{% endif %}
{% if guests.data %} {% if guests.data %}
<div class="card bg-white mb-3"> <div class="card bg-white mb-3">
<h3 class="card-header text-center"> <h3 class="card-header text-center">
@ -22,6 +42,8 @@ SPDX-License-Identifier: GPL-3.0-or-later
{% endblock %} {% endblock %}
{% block extrajavascript %} {% block extrajavascript %}
<script src="{% static "activity/js/opener.js" %}"></script>
<script src="{% static "js/autocomplete_model.js" %}"></script>
<script> <script>
function remove_guest(guest_id) { function remove_guest(guest_id) {
$.ajax({ $.ajax({

View File

@ -18,14 +18,15 @@ from django.views import View
from django.views.decorators.cache import cache_page from django.views.decorators.cache import cache_page
from django.views.generic import DetailView, TemplateView, UpdateView from django.views.generic import DetailView, TemplateView, UpdateView
from django.views.generic.list import ListView from django.views.generic.list import ListView
from django_tables2.views import MultiTableMixin from django_tables2.views import MultiTableMixin, SingleTableMixin
from api.viewsets import is_regex
from note.models import Alias, NoteSpecial, NoteUser from note.models import Alias, NoteSpecial, NoteUser
from permission.backends import PermissionBackend from permission.backends import PermissionBackend
from permission.views import ProtectQuerysetMixin, ProtectedCreateView from permission.views import ProtectQuerysetMixin, ProtectedCreateView
from .forms import ActivityForm, GuestForm from .forms import ActivityForm, GuestForm
from .models import Activity, Entry, Guest from .models import Activity, Entry, Guest, Opener
from .tables import ActivityTable, EntryTable, GuestTable from .tables import ActivityTable, EntryTable, GuestTable, OpenerTable
class ActivityCreateView(ProtectQuerysetMixin, ProtectedCreateView): class ActivityCreateView(ProtectQuerysetMixin, ProtectedCreateView):
@ -63,19 +64,15 @@ class ActivityListView(ProtectQuerysetMixin, LoginRequiredMixin, MultiTableMixin
Displays all Activities, and classify if they are on-going or upcoming ones. Displays all Activities, and classify if they are on-going or upcoming ones.
""" """
model = Activity model = Activity
tables = [ActivityTable, ActivityTable] tables = [
lambda data: ActivityTable(data, prefix="all-"),
lambda data: ActivityTable(data, prefix="upcoming-"),
]
extra_context = {"title": _("Activities")} extra_context = {"title": _("Activities")}
def get_queryset(self, **kwargs): def get_queryset(self, **kwargs):
return super().get_queryset(**kwargs).distinct() return super().get_queryset(**kwargs).distinct()
def get_tables(self):
tables = super().get_tables()
tables[0].prefix = "all-"
tables[1].prefix = "upcoming-"
return tables
def get_tables_data(self): def get_tables_data(self):
# first table = all activities, second table = upcoming # first table = all activities, second table = upcoming
return [ return [
@ -99,7 +96,7 @@ class ActivityListView(ProtectQuerysetMixin, LoginRequiredMixin, MultiTableMixin
return context return context
class ActivityDetailView(ProtectQuerysetMixin, LoginRequiredMixin, DetailView): class ActivityDetailView(ProtectQuerysetMixin, LoginRequiredMixin, MultiTableMixin, DetailView):
""" """
Shows details about one activity. Add guest to context Shows details about one activity. Add guest to context
""" """
@ -107,15 +104,40 @@ class ActivityDetailView(ProtectQuerysetMixin, LoginRequiredMixin, DetailView):
context_object_name = "activity" context_object_name = "activity"
extra_context = {"title": _("Activity detail")} extra_context = {"title": _("Activity detail")}
tables = [
lambda data: GuestTable(data, prefix="guests-"),
lambda data: OpenerTable(data, prefix="opener-"),
]
def get_tables_data(self):
return [
Guest.objects.filter(activity=self.object)
.filter(PermissionBackend.filter_queryset(self.request, Guest, "view")),
self.object.opener.filter(activity=self.object)
.filter(PermissionBackend.filter_queryset(self.request, Opener, "view")),
]
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
context = super().get_context_data() context = super().get_context_data()
table = GuestTable(data=Guest.objects.filter(activity=self.object) tables = context["tables"]
.filter(PermissionBackend.filter_queryset(self.request, Guest, "view"))) for name, table in zip(["guests", "opener"], tables):
context["guests"] = table context[name] = table
context["activity_started"] = timezone.now() > timezone.localtime(self.object.date_start) context["activity_started"] = timezone.now() > timezone.localtime(self.object.date_start)
context["widget"] = {
"name": "opener",
"resetable": True,
"attrs": {
"class": "autocomplete form-control",
"id": "opener",
"api_url": "/api/note/alias/?note__polymorphic_ctype__model=noteuser",
"name_field": "name",
"placeholder": ""
}
}
return context return context
@ -172,12 +194,14 @@ class ActivityInviteView(ProtectQuerysetMixin, ProtectedCreateView):
return reverse_lazy('activity:activity_detail', kwargs={"pk": self.kwargs["pk"]}) return reverse_lazy('activity:activity_detail', kwargs={"pk": self.kwargs["pk"]})
class ActivityEntryView(LoginRequiredMixin, TemplateView): class ActivityEntryView(LoginRequiredMixin, SingleTableMixin, TemplateView):
""" """
Manages entry to an activity Manages entry to an activity
""" """
template_name = "activity/activity_entry.html" template_name = "activity/activity_entry.html"
table_class = EntryTable
def dispatch(self, request, *args, **kwargs): def dispatch(self, request, *args, **kwargs):
""" """
Don't display the entry interface if the user has no right to see it (no right to add an entry for itself), Don't display the entry interface if the user has no right to see it (no right to add an entry for itself),
@ -212,13 +236,16 @@ class ActivityEntryView(LoginRequiredMixin, TemplateView):
if "search" in self.request.GET and self.request.GET["search"]: if "search" in self.request.GET and self.request.GET["search"]:
pattern = self.request.GET["search"] pattern = self.request.GET["search"]
if pattern[0] != "^":
pattern = "^" + pattern # 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"
pattern = "^" + pattern if valid_regex and pattern[0] != "^" else pattern
guest_qs = guest_qs.filter( guest_qs = guest_qs.filter(
Q(first_name__iregex=pattern) Q(**{f"first_name{suffix}": pattern})
| Q(last_name__iregex=pattern) | Q(**{f"last_name{suffix}": pattern})
| Q(inviter__alias__name__iregex=pattern) | Q(**{f"inviter__alias__name{suffix}": pattern})
| Q(inviter__alias__normalized_name__iregex=Alias.normalize(pattern)) | Q(**{f"inviter__alias__normalized_name{suffix}": Alias.normalize(pattern)})
) )
else: else:
guest_qs = guest_qs.none() guest_qs = guest_qs.none()
@ -250,11 +277,15 @@ class ActivityEntryView(LoginRequiredMixin, TemplateView):
if "search" in self.request.GET and self.request.GET["search"]: if "search" in self.request.GET and self.request.GET["search"]:
pattern = 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 "__icontains"
note_qs = note_qs.filter( note_qs = note_qs.filter(
Q(note__noteuser__user__first_name__iregex=pattern) Q(**{f"note__noteuser__user__first_name{suffix}": pattern})
| Q(note__noteuser__user__last_name__iregex=pattern) | Q(**{f"note__noteuser__user__last_name{suffix}": pattern})
| Q(name__iregex=pattern) | Q(**{f"name{suffix}": pattern})
| Q(normalized_name__iregex=Alias.normalize(pattern)) | Q(**{f"normalized_name{suffix}": Alias.normalize(pattern)})
) )
else: else:
note_qs = note_qs.none() note_qs = note_qs.none()
@ -266,15 +297,9 @@ class ActivityEntryView(LoginRequiredMixin, TemplateView):
if settings.DATABASES[note_qs.db]["ENGINE"] == 'django.db.backends.postgresql' else note_qs.distinct()[:20] if settings.DATABASES[note_qs.db]["ENGINE"] == 'django.db.backends.postgresql' else note_qs.distinct()[:20]
return note_qs return note_qs
def get_context_data(self, **kwargs): def get_table_data(self):
"""
Query the list of Guest and Note to the activity and add information to makes entry with JS.
"""
context = super().get_context_data(**kwargs)
activity = Activity.objects.filter(PermissionBackend.filter_queryset(self.request, Activity, "view"))\ activity = Activity.objects.filter(PermissionBackend.filter_queryset(self.request, Activity, "view"))\
.distinct().get(pk=self.kwargs["pk"]) .distinct().get(pk=self.kwargs["pk"])
context["activity"] = activity
matched = [] matched = []
@ -287,8 +312,17 @@ class ActivityEntryView(LoginRequiredMixin, TemplateView):
note.activity = activity note.activity = activity
matched.append(note) matched.append(note)
table = EntryTable(data=matched) return matched
context["table"] = table
def get_context_data(self, **kwargs):
"""
Query the list of Guest and Note to the activity and add information to makes entry with JS.
"""
context = super().get_context_data(**kwargs)
activity = Activity.objects.filter(PermissionBackend.filter_queryset(self.request, Activity, "view"))\
.distinct().get(pk=self.kwargs["pk"])
context["activity"] = activity
context["entries"] = Entry.objects.filter(activity=activity) context["entries"] = Entry.objects.filter(activity=activity)
@ -330,8 +364,8 @@ X-WR-CALNAME:Kfet Calendar
NAME:Kfet Calendar NAME:Kfet Calendar
CALSCALE:GREGORIAN CALSCALE:GREGORIAN
BEGIN:VTIMEZONE BEGIN:VTIMEZONE
TZID:Europe/Berlin TZID:Europe/Paris
X-LIC-LOCATION:Europe/Berlin X-LIC-LOCATION:Europe/Paris
BEGIN:DAYLIGHT BEGIN:DAYLIGHT
TZOFFSETFROM:+0100 TZOFFSETFROM:+0100
TZOFFSETTO:+0200 TZOFFSETTO:+0200
@ -353,10 +387,10 @@ END:VTIMEZONE
DTSTAMP:{"{:%Y%m%dT%H%M%S}".format(activity.date_start)}Z DTSTAMP:{"{:%Y%m%dT%H%M%S}".format(activity.date_start)}Z
UID:{md5((activity.name + "$" + str(activity.id) + str(activity.date_start)).encode("UTF-8")).hexdigest()} UID:{md5((activity.name + "$" + str(activity.id) + str(activity.date_start)).encode("UTF-8")).hexdigest()}
SUMMARY;CHARSET=UTF-8:{self.multilines(activity.name, 75, 22)} SUMMARY;CHARSET=UTF-8:{self.multilines(activity.name, 75, 22)}
DTSTART;TZID=Europe/Berlin:{"{:%Y%m%dT%H%M%S}".format(activity.date_start)} DTSTART:{"{:%Y%m%dT%H%M%S}Z".format(activity.date_start)}
DTEND;TZID=Europe/Berlin:{"{:%Y%m%dT%H%M%S}".format(activity.date_end)} DTEND:{"{:%Y%m%dT%H%M%S}Z".format(activity.date_end)}
LOCATION:{self.multilines(activity.location, 75, 9) if activity.location else "Kfet"} LOCATION:{self.multilines(activity.location, 75, 9) if activity.location else "Kfet"}
DESCRIPTION;CHARSET=UTF-8:""" + self.multilines(activity.description.replace("\n", "\\n"), 75, 26) + """ DESCRIPTION;CHARSET=UTF-8:""" + self.multilines(activity.description.replace("\n", "\\n"), 75, 26) + f"""
-- {activity.organizer.name} -- {activity.organizer.name}
END:VEVENT END:VEVENT
""" """

42
apps/api/filters.py Normal file
View File

@ -0,0 +1,42 @@
import re
from functools import lru_cache
from rest_framework.filters import SearchFilter
class RegexSafeSearchFilter(SearchFilter):
@lru_cache
def validate_regex(self, search_term) -> bool:
try:
re.compile(search_term)
return True
except re.error:
return False
def get_search_fields(self, view, request):
"""
Ensure that given regex are valid.
If not, we consider that the user is trying to search by substring.
"""
search_fields = super().get_search_fields(view, request)
search_terms = self.get_search_terms(request)
for search_term in search_terms:
if not self.validate_regex(search_term):
# Invalid regex. We assume we don't query by regex but by substring.
search_fields = [f.replace('$', '') for f in search_fields]
break
return search_fields
def get_search_terms(self, request):
"""
Ensure that search field is a valid regex query. If not, we remove extra characters.
"""
terms = super().get_search_terms(request)
if not all(self.validate_regex(term) for term in terms):
# Invalid regex. If a ^ is prefixed to the search term, we remove it.
terms = [term[1:] if term[0] == '^' else term for term in terms]
# Same for dollars.
terms = [term[:-1] if term[-1] == '$' else term for term in terms]
return terms

View File

@ -12,11 +12,12 @@ from django.contrib.contenttypes.models import ContentType
from django.db.models.fields.files import ImageFieldFile from django.db.models.fields.files import ImageFieldFile
from django.test import TestCase from django.test import TestCase
from django_filters.rest_framework import DjangoFilterBackend from django_filters.rest_framework import DjangoFilterBackend
from phonenumbers import PhoneNumber
from rest_framework.filters import OrderingFilter
from api.filters import RegexSafeSearchFilter
from member.models import Membership, Club from member.models import Membership, Club
from note.models import NoteClub, NoteUser, Alias, Note from note.models import NoteClub, NoteUser, Alias, Note
from permission.models import PermissionMask, Permission, Role from permission.models import PermissionMask, Permission, Role
from phonenumbers import PhoneNumber
from rest_framework.filters import SearchFilter, OrderingFilter
from .viewsets import ContentTypeViewSet, UserViewSet from .viewsets import ContentTypeViewSet, UserViewSet
@ -87,7 +88,7 @@ class TestAPI(TestCase):
resp = self.client.get(url + f"?ordering=-{field}") resp = self.client.get(url + f"?ordering=-{field}")
self.assertEqual(resp.status_code, 200) self.assertEqual(resp.status_code, 200)
if SearchFilter in backends: if RegexSafeSearchFilter in backends:
# Basic search # Basic search
for field in viewset.search_fields: for field in viewset.search_fields:
obj = self.fix_note_object(obj, field) obj = self.fix_note_object(obj, field)

View File

@ -1,19 +1,29 @@
# Copyright (C) 2018-2024 by BDE ENS Paris-Saclay # Copyright (C) 2018-2024 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later # SPDX-License-Identifier: GPL-3.0-or-later
import re
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django_filters.rest_framework import DjangoFilterBackend from django_filters.rest_framework import DjangoFilterBackend
from django.db.models import Q from django.db.models import Q
from django.conf import settings from django.conf import settings
from django.contrib.auth.models import User from django.contrib.auth.models import User
from rest_framework.filters import SearchFilter
from rest_framework.viewsets import ReadOnlyModelViewSet, ModelViewSet from rest_framework.viewsets import ReadOnlyModelViewSet, ModelViewSet
from permission.backends import PermissionBackend from permission.backends import PermissionBackend
from note.models import Alias from note.models import Alias
from .filters import RegexSafeSearchFilter
from .serializers import UserSerializer, ContentTypeSerializer from .serializers import UserSerializer, ContentTypeSerializer
def is_regex(pattern):
try:
re.compile(pattern)
return True
except (re.error, TypeError):
return False
class ReadProtectedModelViewSet(ModelViewSet): class ReadProtectedModelViewSet(ModelViewSet):
""" """
Protect a ModelViewSet by filtering the objects that the user cannot see. Protect a ModelViewSet by filtering the objects that the user cannot see.
@ -60,34 +70,38 @@ class UserViewSet(ReadProtectedModelViewSet):
if "search" in self.request.GET: if "search" in self.request.GET:
pattern = 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 ""
# Filter with different rules # Filter with different rules
# We use union-all to keep each filter rule sorted in result # We use union-all to keep each filter rule sorted in result
queryset = queryset.filter( queryset = queryset.filter(
# Match without normalization # Match without normalization
note__alias__name__iregex="^" + pattern Q(**{f"note__alias__name{suffix}": prefix + pattern})
).union( ).union(
queryset.filter( queryset.filter(
# Match with normalization # Match with normalization
Q(note__alias__normalized_name__iregex="^" + Alias.normalize(pattern)) Q(**{f"note__alias__normalized_name{suffix}": prefix + Alias.normalize(pattern)})
& ~Q(note__alias__name__iregex="^" + pattern) & ~Q(**{f"note__alias__name{suffix}": prefix + pattern})
), ),
all=True, all=True,
).union( ).union(
queryset.filter( queryset.filter(
# Match on lower pattern # Match on lower pattern
Q(note__alias__normalized_name__iregex="^" + pattern.lower()) Q(**{f"note__alias__normalized_name{suffix}": prefix + pattern.lower()})
& ~Q(note__alias__normalized_name__iregex="^" + Alias.normalize(pattern)) & ~Q(**{f"note__alias__normalized_name{suffix}": prefix + Alias.normalize(pattern)})
& ~Q(note__alias__name__iregex="^" + pattern) & ~Q(**{f"note__alias__name{suffix}": prefix + pattern})
), ),
all=True, all=True,
).union( ).union(
queryset.filter( queryset.filter(
# Match on firstname or lastname # Match on firstname or lastname
(Q(last_name__iregex="^" + pattern) | Q(first_name__iregex="^" + pattern)) (Q(**{f"last_name{suffix}": prefix + pattern}) | Q(**{f"first_name{suffix}": prefix + pattern}))
& ~Q(note__alias__normalized_name__iregex="^" + pattern.lower()) & ~Q(**{f"note__alias__normalized_name{suffix}": prefix + pattern.lower()})
& ~Q(note__alias__normalized_name__iregex="^" + Alias.normalize(pattern)) & ~Q(**{f"note__alias__normalized_name{suffix}": prefix + Alias.normalize(pattern)})
& ~Q(note__alias__name__iregex="^" + pattern) & ~Q(**{f"note__alias__name{suffix}": prefix + pattern})
), ),
all=True, all=True,
) )
@ -107,6 +121,6 @@ class ContentTypeViewSet(ReadOnlyModelViewSet):
""" """
queryset = ContentType.objects.order_by('id') queryset = ContentType.objects.order_by('id')
serializer_class = ContentTypeSerializer serializer_class = ContentTypeSerializer
filter_backends = [DjangoFilterBackend, SearchFilter] filter_backends = [DjangoFilterBackend, RegexSafeSearchFilter]
filterset_fields = ['id', 'app_label', 'model', ] filterset_fields = ['id', 'app_label', 'model', ]
search_fields = ['$app_label', '$model', ] search_fields = ['$app_label', '$model', ]

View File

@ -2,7 +2,8 @@
# SPDX-License-Identifier: GPL-3.0-or-later # SPDX-License-Identifier: GPL-3.0-or-later
from django_filters.rest_framework import DjangoFilterBackend from django_filters.rest_framework import DjangoFilterBackend
from rest_framework.filters import OrderingFilter, SearchFilter from rest_framework.filters import OrderingFilter
from api.filters import RegexSafeSearchFilter
from api.viewsets import ReadProtectedModelViewSet from api.viewsets import ReadProtectedModelViewSet
from .serializers import ProfileSerializer, ClubSerializer, MembershipSerializer from .serializers import ProfileSerializer, ClubSerializer, MembershipSerializer
@ -17,7 +18,7 @@ class ProfileViewSet(ReadProtectedModelViewSet):
""" """
queryset = Profile.objects.order_by('id') queryset = Profile.objects.order_by('id')
serializer_class = ProfileSerializer serializer_class = ProfileSerializer
filter_backends = [DjangoFilterBackend, SearchFilter] filter_backends = [DjangoFilterBackend, RegexSafeSearchFilter]
filterset_fields = ['user', 'user__first_name', 'user__last_name', 'user__username', 'user__email', filterset_fields = ['user', 'user__first_name', 'user__last_name', 'user__username', 'user__email',
'user__note__alias__name', 'user__note__alias__normalized_name', 'phone_number', "section", 'user__note__alias__name', 'user__note__alias__normalized_name', 'phone_number', "section",
'department', 'promotion', 'address', 'paid', 'ml_events_registration', 'ml_sport_registration', 'department', 'promotion', 'address', 'paid', 'ml_events_registration', 'ml_sport_registration',
@ -34,7 +35,7 @@ class ClubViewSet(ReadProtectedModelViewSet):
""" """
queryset = Club.objects.order_by('id') queryset = Club.objects.order_by('id')
serializer_class = ClubSerializer serializer_class = ClubSerializer
filter_backends = [DjangoFilterBackend, SearchFilter] filter_backends = [DjangoFilterBackend, RegexSafeSearchFilter]
filterset_fields = ['name', 'email', 'note__alias__name', 'note__alias__normalized_name', 'parent_club', filterset_fields = ['name', 'email', 'note__alias__name', 'note__alias__normalized_name', 'parent_club',
'parent_club__name', 'require_memberships', 'membership_fee_paid', 'membership_fee_unpaid', 'parent_club__name', 'require_memberships', 'membership_fee_paid', 'membership_fee_unpaid',
'membership_duration', 'membership_start', 'membership_end', ] 'membership_duration', 'membership_start', 'membership_end', ]
@ -49,7 +50,7 @@ class MembershipViewSet(ReadProtectedModelViewSet):
""" """
queryset = Membership.objects.order_by('id') queryset = Membership.objects.order_by('id')
serializer_class = MembershipSerializer serializer_class = MembershipSerializer
filter_backends = [DjangoFilterBackend, OrderingFilter, SearchFilter] filter_backends = [DjangoFilterBackend, OrderingFilter, RegexSafeSearchFilter]
filterset_fields = ['club__name', 'club__email', 'club__note__alias__name', 'club__note__alias__normalized_name', filterset_fields = ['club__name', 'club__email', 'club__note__alias__name', 'club__note__alias__normalized_name',
'user__username', 'user__last_name', 'user__first_name', 'user__email', 'user__username', 'user__last_name', 'user__first_name', 'user__email',
'user__note__alias__name', 'user__note__alias__normalized_name', 'user__note__alias__name', 'user__note__alias__normalized_name',

View File

@ -0,0 +1,18 @@
# Generated by Django 2.2.28 on 2024-08-01 12:36
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('member', '0012_club_add_registration_form'),
]
operations = [
migrations.AlterField(
model_name='profile',
name='promotion',
field=models.PositiveSmallIntegerField(default=2024, help_text='Year of entry to the school (None if not ENS student)', null=True, verbose_name='promotion'),
),
]

View File

@ -295,7 +295,14 @@ class Club(models.Model):
today = datetime.date.today() today = datetime.date.today()
while (today - self.membership_start).days >= 365: # Avoid any problems on February 29
if self.membership_start.month == 2 and self.membership_start.day == 29:
self.membership_start -= datetime.timedelta(days=1)
if self.membership_end.month == 2 and self.membership_end.day == 29:
self.membership_end += datetime.timedelta(days=1)
while today >= datetime.date(self.membership_start.year + 1,
self.membership_start.month, self.membership_start.day):
if self.membership_start: if self.membership_start:
self.membership_start = datetime.date(self.membership_start.year + 1, self.membership_start = datetime.date(self.membership_start.year + 1,
self.membership_start.month, self.membership_start.day) self.membership_start.month, self.membership_start.day)

View File

@ -16,8 +16,9 @@ from django.utils import timezone
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from django.views.generic import DetailView, UpdateView, TemplateView from django.views.generic import DetailView, UpdateView, TemplateView
from django.views.generic.edit import FormMixin 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 rest_framework.authtoken.models import Token
from api.viewsets import is_regex
from note.models import Alias, NoteClub, NoteUser, Trust from note.models import Alias, NoteClub, NoteUser, Trust
from note.models.transactions import Transaction, SpecialTransaction from note.models.transactions import Transaction, SpecialTransaction
from note.tables import HistoryTable, AliasTable, TrustTable, TrustedTable from note.tables import HistoryTable, AliasTable, TrustTable, TrustedTable
@ -219,16 +220,20 @@ class UserListView(ProtectQuerysetMixin, LoginRequiredMixin, SingleTableView):
if "search" in self.request.GET and self.request.GET["search"]: if "search" in self.request.GET and self.request.GET["search"]:
pattern = 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( qs = qs.filter(
username__iregex="^" + pattern Q(**{f"username{suffix}": prefix + pattern})
).union( ).union(
qs.filter( qs.filter(
(Q(alias__iregex="^" + pattern) (Q(**{f"alias{suffix}": prefix + pattern})
| Q(normalized_alias__iregex="^" + Alias.normalize(pattern)) | Q(**{f"normalized_alias{suffix}": prefix + Alias.normalize(pattern)})
| Q(last_name__iregex="^" + pattern) | Q(**{f"last_name{suffix}": prefix + pattern})
| Q(first_name__iregex="^" + pattern) | Q(**{f"first_name{suffix}": prefix + pattern})
| Q(email__istartswith=pattern)) | Q(email__istartswith=pattern))
& ~Q(username__iregex="^" + pattern) & ~Q(**{f"username{suffix}": prefix + pattern})
), all=True) ), all=True)
else: else:
qs = qs.none() qs = qs.none()
@ -243,7 +248,7 @@ class UserListView(ProtectQuerysetMixin, LoginRequiredMixin, SingleTableView):
return context return context
class ProfileTrustView(ProtectQuerysetMixin, LoginRequiredMixin, DetailView): class ProfileTrustView(ProtectQuerysetMixin, LoginRequiredMixin, MultiTableMixin, DetailView):
""" """
View and manage user trust relationships View and manage user trust relationships
""" """
@ -252,13 +257,25 @@ class ProfileTrustView(ProtectQuerysetMixin, LoginRequiredMixin, DetailView):
context_object_name = 'user_object' context_object_name = 'user_object'
extra_context = {"title": _("Note friendships")} 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): def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs) context = super().get_context_data(**kwargs)
note = context['object'].note
context["trusting"] = TrustTable( tables = context["tables"]
note.trusting.filter(PermissionBackend.filter_queryset(self.request, Trust, "view")).distinct().all()) for name, table in zip(["trusting", "trusted_by"], tables):
context["trusted_by"] = TrustedTable( context[name] = table
note.trusted.filter(PermissionBackend.filter_queryset(self.request, Trust, "view")).distinct().all())
context["can_create"] = PermissionBackend.check_perm(self.request, "note.add_trust", Trust( context["can_create"] = PermissionBackend.check_perm(self.request, "note.add_trust", Trust(
trusting=context["object"].note, trusting=context["object"].note,
trusted=context["object"].note trusted=context["object"].note
@ -277,7 +294,7 @@ class ProfileTrustView(ProtectQuerysetMixin, LoginRequiredMixin, DetailView):
return context return context
class ProfileAliasView(ProtectQuerysetMixin, LoginRequiredMixin, DetailView): class ProfileAliasView(ProtectQuerysetMixin, LoginRequiredMixin, SingleTableMixin, DetailView):
""" """
View and manage user aliases. View and manage user aliases.
""" """
@ -286,12 +303,15 @@ class ProfileAliasView(ProtectQuerysetMixin, LoginRequiredMixin, DetailView):
context_object_name = 'user_object' context_object_name = 'user_object'
extra_context = {"title": _("Note aliases")} 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): def get_context_data(self, **kwargs):
context = super().get_context_data(**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( context["can_create"] = PermissionBackend.check_perm(self.request, "note.add_alias", Alias(
note=context["object"].note, note=context["object"].note,
name="", name="",
@ -410,10 +430,15 @@ class ClubListView(ProtectQuerysetMixin, LoginRequiredMixin, SingleTableView):
if "search" in self.request.GET: if "search" in self.request.GET:
pattern = 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( qs = qs.filter(
Q(name__iregex=pattern) Q(**{f"name{suffix}": prefix + pattern})
| Q(note__alias__name__iregex=pattern) | Q(**{f"note__alias__name{suffix}": prefix + pattern})
| Q(note__alias__normalized_name__iregex=Alias.normalize(pattern)) | Q(**{f"note__alias__normalized_name{suffix}": prefix + Alias.normalize(pattern)})
) )
return qs return qs
@ -510,7 +535,7 @@ class ClubDetailView(ProtectQuerysetMixin, LoginRequiredMixin, DetailView):
return context return context
class ClubAliasView(ProtectQuerysetMixin, LoginRequiredMixin, DetailView): class ClubAliasView(ProtectQuerysetMixin, LoginRequiredMixin, SingleTableMixin, DetailView):
""" """
Manage aliases of a club. Manage aliases of a club.
""" """
@ -519,11 +544,16 @@ class ClubAliasView(ProtectQuerysetMixin, LoginRequiredMixin, DetailView):
context_object_name = 'club' context_object_name = 'club'
extra_context = {"title": _("Note aliases")} 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): def get_context_data(self, **kwargs):
context = super().get_context_data(**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( context["can_create"] = PermissionBackend.check_perm(self.request, "note.add_alias", Alias(
note=context["object"].note, note=context["object"].note,
name="", name="",
@ -912,10 +942,15 @@ class ClubMembersListView(ProtectQuerysetMixin, LoginRequiredMixin, SingleTableV
if 'search' in self.request.GET: if 'search' in self.request.GET:
pattern = 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( qs = qs.filter(
Q(user__first_name__iregex='^' + pattern) Q(**{f"user__first_name{suffix}": prefix + pattern})
| Q(user__last_name__iregex='^' + pattern) | Q(**{f"user__last_name{suffix}": prefix + pattern})
| Q(user__note__alias__normalized_name__iregex='^' + Alias.normalize(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' only_active = "only_active" not in self.request.GET or self.request.GET["only_active"] != '0'

View File

@ -1,16 +1,16 @@
# Copyright (C) 2018-2024 by BDE ENS Paris-Saclay # Copyright (C) 2018-2024 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later # SPDX-License-Identifier: GPL-3.0-or-later
import re
from django.conf import settings from django.conf import settings
from django.db.models import Q from django.db.models import Q
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django_filters.rest_framework import DjangoFilterBackend from django_filters.rest_framework import DjangoFilterBackend
from rest_framework.filters import OrderingFilter, SearchFilter from rest_framework.filters import OrderingFilter
from rest_framework import viewsets from rest_framework import status, viewsets
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework import status from api.filters import RegexSafeSearchFilter
from api.viewsets import ReadProtectedModelViewSet, ReadOnlyProtectedModelViewSet from api.viewsets import ReadProtectedModelViewSet, ReadOnlyProtectedModelViewSet, \
is_regex
from permission.backends import PermissionBackend from permission.backends import PermissionBackend
from .serializers import NotePolymorphicSerializer, AliasSerializer, ConsumerSerializer, \ from .serializers import NotePolymorphicSerializer, AliasSerializer, ConsumerSerializer, \
@ -29,7 +29,7 @@ class NotePolymorphicViewSet(ReadProtectedModelViewSet):
""" """
queryset = Note.objects.order_by('id') queryset = Note.objects.order_by('id')
serializer_class = NotePolymorphicSerializer 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', ] filterset_fields = ['alias__name', 'polymorphic_ctype', 'is_active', 'balance', 'last_negative', 'created_at', ]
search_fields = ['$alias__normalized_name', '$alias__name', '$polymorphic_ctype__model', search_fields = ['$alias__normalized_name', '$alias__name', '$polymorphic_ctype__model',
'$noteuser__user__last_name', '$noteuser__user__first_name', '$noteuser__user__email', '$noteuser__user__last_name', '$noteuser__user__first_name', '$noteuser__user__email',
@ -48,10 +48,14 @@ class NotePolymorphicViewSet(ReadProtectedModelViewSet):
.distinct() .distinct()
alias = self.request.query_params.get("alias", ".*") 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( queryset = queryset.filter(
Q(alias__name__iregex="^" + alias) Q(**{f"alias__name{suffix}": alias_prefix + alias})
| Q(alias__normalized_name__iregex="^" + Alias.normalize(alias)) | Q(**{f"alias__normalized_name{suffix}": alias_prefix + Alias.normalize(alias)})
| Q(alias__normalized_name__iregex="^" + alias.lower()) | Q(**{f"alias__normalized_name{suffix}": alias_prefix + alias.lower()})
) )
return queryset.order_by("id") return queryset.order_by("id")
@ -65,7 +69,7 @@ class TrustViewSet(ReadProtectedModelViewSet):
""" """
queryset = Trust.objects queryset = Trust.objects
serializer_class = TrustSerializer serializer_class = TrustSerializer
filter_backends = [SearchFilter, DjangoFilterBackend, OrderingFilter] filter_backends = [RegexSafeSearchFilter, DjangoFilterBackend, OrderingFilter]
search_fields = ['$trusting__alias__name', '$trusting__alias__normalized_name', search_fields = ['$trusting__alias__name', '$trusting__alias__normalized_name',
'$trusted__alias__name', '$trusted__alias__normalized_name'] '$trusted__alias__name', '$trusted__alias__normalized_name']
filterset_fields = ['trusting', 'trusting__noteuser__user', 'trusted', 'trusted__noteuser__user'] filterset_fields = ['trusting', 'trusting__noteuser__user', 'trusted', 'trusted__noteuser__user']
@ -91,11 +95,11 @@ class AliasViewSet(ReadProtectedModelViewSet):
""" """
REST API View set. REST API View set.
The djangorestframework plugin will get all `Alias` objects, serialize it to JSON with the given serializer, 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 queryset = Alias.objects
serializer_class = AliasSerializer serializer_class = AliasSerializer
filter_backends = [SearchFilter, DjangoFilterBackend, OrderingFilter] filter_backends = [RegexSafeSearchFilter, DjangoFilterBackend, OrderingFilter]
search_fields = ['$normalized_name', '$name', '$note__polymorphic_ctype__model', ] search_fields = ['$normalized_name', '$name', '$note__polymorphic_ctype__model', ]
filterset_fields = ['name', 'normalized_name', 'note', 'note__noteuser__user', filterset_fields = ['name', 'normalized_name', 'note', 'note__noteuser__user',
'note__noteclub__club', 'note__polymorphic_ctype__model', ] 'note__noteclub__club', 'note__polymorphic_ctype__model', ]
@ -126,18 +130,22 @@ class AliasViewSet(ReadProtectedModelViewSet):
alias = self.request.query_params.get("alias", None) alias = self.request.query_params.get("alias", None)
if alias: 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( queryset = queryset.filter(
name__iregex="^" + alias **{f"name{suffix}": alias_prefix + alias}
).union( ).union(
queryset.filter( queryset.filter(
Q(normalized_name__iregex="^" + Alias.normalize(alias)) Q(**{f"normalized_name{suffix}": alias_prefix + Alias.normalize(alias)})
& ~Q(name__iregex="^" + alias) & ~Q(**{f"name{suffix}": alias_prefix + alias})
), ),
all=True).union( all=True).union(
queryset.filter( queryset.filter(
Q(normalized_name__iregex="^" + alias.lower()) Q(**{f"normalized_name{suffix}": "^" + alias.lower()})
& ~Q(normalized_name__iregex="^" + Alias.normalize(alias)) & ~Q(**{f"normalized_name{suffix}": "^" + Alias.normalize(alias)})
& ~Q(name__iregex="^" + alias) & ~Q(**{f"name{suffix}": "^" + alias})
), ),
all=True) all=True)
@ -147,7 +155,7 @@ class AliasViewSet(ReadProtectedModelViewSet):
class ConsumerViewSet(ReadOnlyProtectedModelViewSet): class ConsumerViewSet(ReadOnlyProtectedModelViewSet):
queryset = Alias.objects queryset = Alias.objects
serializer_class = ConsumerSerializer serializer_class = ConsumerSerializer
filter_backends = [SearchFilter, OrderingFilter, DjangoFilterBackend] filter_backends = [RegexSafeSearchFilter, OrderingFilter, DjangoFilterBackend]
search_fields = ['$normalized_name', '$name', '$note__polymorphic_ctype__model', ] search_fields = ['$normalized_name', '$name', '$note__polymorphic_ctype__model', ]
filterset_fields = ['name', 'normalized_name', 'note', 'note__noteuser__user', filterset_fields = ['name', 'normalized_name', 'note', 'note__noteuser__user',
'note__noteclub__club', 'note__polymorphic_ctype__model', ] 'note__noteclub__club', 'note__polymorphic_ctype__model', ]
@ -166,11 +174,7 @@ class ConsumerViewSet(ReadOnlyProtectedModelViewSet):
alias = self.request.query_params.get("alias", None) alias = self.request.query_params.get("alias", None)
# Check if this is a valid regex. If not, we won't check regex # Check if this is a valid regex. If not, we won't check regex
try: valid_regex = is_regex(alias)
re.compile(alias)
valid_regex = True
except (re.error, TypeError):
valid_regex = False
suffix = '__iregex' if valid_regex else '__istartswith' suffix = '__iregex' if valid_regex else '__istartswith'
alias_prefix = '^' if valid_regex else '' alias_prefix = '^' if valid_regex else ''
queryset = queryset.prefetch_related('note') queryset = queryset.prefetch_related('note')
@ -207,7 +211,7 @@ class TemplateCategoryViewSet(ReadProtectedModelViewSet):
""" """
queryset = TemplateCategory.objects.order_by('name') queryset = TemplateCategory.objects.order_by('name')
serializer_class = TemplateCategorySerializer serializer_class = TemplateCategorySerializer
filter_backends = [DjangoFilterBackend, SearchFilter] filter_backends = [DjangoFilterBackend, RegexSafeSearchFilter]
filterset_fields = ['name', 'templates', 'templates__name'] filterset_fields = ['name', 'templates', 'templates__name']
search_fields = ['$name', '$templates__name', ] search_fields = ['$name', '$templates__name', ]
@ -220,7 +224,7 @@ class TransactionTemplateViewSet(viewsets.ModelViewSet):
""" """
queryset = TransactionTemplate.objects.order_by('name') queryset = TransactionTemplate.objects.order_by('name')
serializer_class = TransactionTemplateSerializer serializer_class = TransactionTemplateSerializer
filter_backends = [SearchFilter, DjangoFilterBackend, OrderingFilter] filter_backends = [RegexSafeSearchFilter, DjangoFilterBackend, OrderingFilter]
filterset_fields = ['name', 'amount', 'display', 'category', 'category__name', ] filterset_fields = ['name', 'amount', 'display', 'category', 'category__name', ]
search_fields = ['$name', '$category__name', ] search_fields = ['$name', '$category__name', ]
ordering_fields = ['amount', ] ordering_fields = ['amount', ]
@ -234,7 +238,7 @@ class TransactionViewSet(ReadProtectedModelViewSet):
""" """
queryset = Transaction.objects.order_by('-created_at') queryset = Transaction.objects.order_by('-created_at')
serializer_class = TransactionPolymorphicSerializer 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', filterset_fields = ['source', 'source_alias', 'source__alias__name', 'source__alias__normalized_name',
'destination', 'destination_alias', 'destination__alias__name', 'destination', 'destination_alias', 'destination__alias__name',
'destination__alias__normalized_name', 'quantity', 'polymorphic_ctype', 'amount', 'destination__alias__normalized_name', 'quantity', 'polymorphic_ctype', 'amount',

View File

@ -18,6 +18,7 @@ def create_special_notes(apps, schema_editor):
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
('note', '0001_initial'), ('note', '0001_initial'),
('logs', '0001_initial'),
] ]
operations = [ operations = [

View File

@ -13,6 +13,7 @@ from django.views.generic import CreateView, UpdateView, DetailView
from django.urls import reverse_lazy from django.urls import reverse_lazy
from django_tables2 import SingleTableView from django_tables2 import SingleTableView
from activity.models import Entry from activity.models import Entry
from api.viewsets import is_regex
from permission.backends import PermissionBackend from permission.backends import PermissionBackend
from permission.views import ProtectQuerysetMixin from permission.views import ProtectQuerysetMixin
from note_kfet.inputs import AmountInput from note_kfet.inputs import AmountInput
@ -89,11 +90,15 @@ class TransactionTemplateListView(ProtectQuerysetMixin, LoginRequiredMixin, Sing
qs = super().get_queryset().distinct() qs = super().get_queryset().distinct()
if "search" in self.request.GET: if "search" in self.request.GET:
pattern = 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 "__icontains"
qs = qs.filter( qs = qs.filter(
Q(name__iregex=pattern) Q(**{f"name{suffix}": pattern})
| Q(destination__club__name__iregex=pattern) | Q(**{f"destination__club__name{suffix}": pattern})
| Q(category__name__iregex=pattern) | Q(**{f"category__name{suffix}": pattern})
| Q(description__iregex=pattern) | Q(**{f"description{suffix}": pattern})
) )
qs = qs.order_by('-display', 'category__name', 'destination__club__name', 'name') qs = qs.order_by('-display', 'category__name', 'destination__club__name', 'name')
@ -223,7 +228,10 @@ class TransactionSearchView(ProtectQuerysetMixin, LoginRequiredMixin, DetailView
if "type" in data and data["type"]: if "type" in data and data["type"]:
transactions = transactions.filter(polymorphic_ctype__in=data["type"]) transactions = transactions.filter(polymorphic_ctype__in=data["type"])
if "reason" in data and data["reason"]: if "reason" in data and data["reason"]:
transactions = transactions.filter(reason__iregex=data["reason"]) # Check if this is a valid regex. If not, we won't check regex
valid_regex = is_regex(data["reason"])
suffix = "__iregex" if valid_regex else "__istartswith"
transactions = transactions.filter(Q(**{f"reason{suffix}": data["reason"]}))
if "valid" in data and data["valid"]: if "valid" in data and data["valid"]:
transactions = transactions.filter(valid=data["valid"]) transactions = transactions.filter(valid=data["valid"])
if "amount_gte" in data and data["amount_gte"]: if "amount_gte" in data and data["amount_gte"]:

View File

@ -1,9 +1,9 @@
# Copyright (C) 2018-2024 by BDE ENS Paris-Saclay # Copyright (C) 2018-2024 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later # SPDX-License-Identifier: GPL-3.0-or-later
from api.viewsets import ReadOnlyProtectedModelViewSet
from django_filters.rest_framework import DjangoFilterBackend from django_filters.rest_framework import DjangoFilterBackend
from rest_framework.filters import SearchFilter from api.filters import RegexSafeSearchFilter
from api.viewsets import ReadOnlyProtectedModelViewSet
from .serializers import PermissionSerializer, RoleSerializer from .serializers import PermissionSerializer, RoleSerializer
from ..models import Permission, Role from ..models import Permission, Role
@ -17,9 +17,9 @@ class PermissionViewSet(ReadOnlyProtectedModelViewSet):
""" """
queryset = Permission.objects.order_by('id') queryset = Permission.objects.order_by('id')
serializer_class = PermissionSerializer serializer_class = PermissionSerializer
filter_backends = [DjangoFilterBackend, SearchFilter] filter_backends = [DjangoFilterBackend, RegexSafeSearchFilter]
filterset_fields = ['model', 'type', 'query', 'mask', 'field', 'permanent', ] filterset_fields = ['model', 'type', 'query', 'mask', 'field', 'permanent', ]
search_fields = ['$model__name', '$query', '$description', ] search_fields = ['$model__model', '$query', '$description', ]
class RoleViewSet(ReadOnlyProtectedModelViewSet): class RoleViewSet(ReadOnlyProtectedModelViewSet):
@ -30,6 +30,6 @@ class RoleViewSet(ReadOnlyProtectedModelViewSet):
""" """
queryset = Role.objects.order_by('id') queryset = Role.objects.order_by('id')
serializer_class = RoleSerializer serializer_class = RoleSerializer
filter_backends = [DjangoFilterBackend, SearchFilter] filter_backends = [DjangoFilterBackend, RegexSafeSearchFilter]
filterset_fields = ['name', 'permissions', 'for_club', 'memberships__user', ] filterset_fields = ['name', 'permissions', 'for_club', 'memberships__user', ]
search_fields = ['$name', '$for_club__name', ] search_fields = ['$name', '$for_club__name', ]

View File

@ -3111,6 +3111,199 @@
"description": "Voir ceux nous ayant pour ami, pour toujours" "description": "Voir ceux nous ayant pour ami, pour toujours"
} }
}, },
{
"model": "permission.permission",
"pk": 199,
"fields": {
"model": [
"activity",
"activity"
],
"query": "{\"opener__in\": [\"user\", \"note\", \"activity_responsible\", [\"all\"]], \"open\": true, \"activity_type__manage_entries\":true}",
"type": "view",
"mask": 2,
"field": "",
"permanent": false,
"description": "Voir les activités ouvertes dont l'utilisateur⋅rice est ouvreur⋅se"
}
},
{
"model": "permission.permission",
"pk": 200,
"fields": {
"model": [
"activity",
"activity"
],
"query": "{\"opener__in\": [\"user\", \"note\", \"activity_responsible\", [\"all\"]], \"open\": true, \"activity_type__manage_entries\":true}",
"type": "change",
"mask": 2,
"field": "open",
"permanent": false,
"description": "Fermer les activités ouvertes dont l'utilisateur⋅rice est ouvreur⋅se"
}
},
{
"model": "permission.permission",
"pk": 201,
"fields": {
"model": [
"activity",
"entry"
],
"query": "{\"activity__opener__in\": [\"user\", \"note\", \"activity_responsible\", [\"all\"]], \"activity__open\": true, \"activity__activity_type__manage_entries\":true}",
"type": "add",
"mask": 2,
"field": "",
"permanent": false,
"description": "Faire les entrées des activités ouvertes dont l'utilisateur⋅rice est ouvreur⋅se"
}
},
{
"model": "permission.permission",
"pk": 202,
"fields": {
"model": [
"activity",
"entry"
],
"query": "{\"activity__opener__in\": [\"user\", \"note\", \"activity_responsible\", [\"all\"]]}",
"type": "view",
"mask": 2,
"field": "",
"permanent": false,
"description": "Voir les entrées des activités dont l'utilisateur⋅rice est ouvreur⋅se"
}
},
{
"model": "permission.permission",
"pk": 203,
"fields": {
"model": [
"activity",
"guest"
],
"query": "{\"activity__opener__in\": [\"user\", \"note\", \"activity_responsible\", [\"all\"]]}",
"type": "view",
"mask": 2,
"field": "",
"permanent": false,
"description": "Voir les invité⋅es des activités dont l'utilisateur⋅rice est ouvreur⋅se"
}
},
{
"model": "permission.permission",
"pk": 204,
"fields": {
"model": [
"activity",
"guesttransaction"
],
"query": "[\"NOT\", {\"pk__isnull\": [\"user\", \"note\", \"activity_responsible\", [\"filter\", {\"activity__open\": true, \"activity__activity_type__manage_entries\":true}], [\"exists\"]]}]",
"type": "add",
"mask": 2,
"field": "",
"permanent": false,
"description": "Créer une transaction d'invitation lorsque l'utilisateur⋅rice est ouvreur⋅se d'une activité ouverte"
}
},
{
"model": "permission.permission",
"pk": 205,
"fields": {
"model": [
"note",
"specialtransaction"
],
"query": "[\"NOT\", {\"pk__isnull\": [\"user\", \"note\", \"activity_responsible\", [\"filter\", {\"activity__open\": true, \"activity__activity_type__manage_entries\":true}], [\"exists\"]]}]",
"type": "add",
"mask": 2,
"field": "",
"permanent": false,
"description": "Créer un crédit ou un retrait quelconque lorsque l'utilisateur⋅rice est ouvreur⋅se d'une activité ouverte"
}
},
{
"model": "permission.permission",
"pk": 206,
"fields": {
"model": [
"note",
"notespecial"
],
"query": "[\"NOT\", {\"pk__isnull\": [\"user\", \"note\", \"activity_responsible\", [\"filter\", {\"activity__open\": true, \"activity__activity_type__manage_entries\":true}], [\"exists\"]]}]",
"type": "view",
"mask": 2,
"field": "",
"permanent": false,
"description": "Afficher l'interface crédit/retrait lorsque l'utilisateur⋅rice est ouvreur⋅se d'une activité ouverte"
}
},
{
"model": "permission.permission",
"pk": 207,
"fields": {
"model": [
"activity",
"opener"
],
"query": "{}",
"type": "view",
"mask": 2,
"field": "",
"permanent": false,
"description": "Voir les ouvreur⋅ses des activités"
}
},
{
"model": "permission.permission",
"pk": 208,
"fields": {
"model": [
"activity",
"opener"
],
"query": "{}",
"type": "add",
"mask": 2,
"field": "",
"permanent": false,
"description": "Ajouter des ouvreur⋅ses aux activités"
}
},
{
"model": "permission.permission",
"pk": 209,
"fields": {
"model": [
"activity",
"opener"
],
"query": "{}",
"type": "delete",
"mask": 2,
"field": "",
"permanent": false,
"description": "Supprimer des ouvreur⋅ses aux activités"
}
},
{
"model": "permission.permission",
"pk": 210,
"fields": {
"model": [
"activity",
"activity"
],
"query": "{}",
"type": "change",
"mask": 2,
"field": "opener",
"permanent": false,
"description": "Voir le tableau des ouvreur⋅ses"
}
},
{ {
"model": "permission.role", "model": "permission.role",
"pk": 1, "pk": 1,
@ -3152,7 +3345,15 @@
191, 191,
195, 195,
196, 196,
198 198,
199,
200,
201,
202,
203,
204,
205,
206
] ]
} }
}, },
@ -3414,7 +3615,11 @@
46, 46,
148, 148,
149, 149,
182 182,
207,
208,
209,
210
] ]
} }
}, },

View File

@ -12,6 +12,7 @@ from django.forms import HiddenInput
from django.http import Http404 from django.http import Http404
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from django.views.generic import UpdateView, TemplateView, CreateView from django.views.generic import UpdateView, TemplateView, CreateView
from django_tables2 import MultiTableMixin
from member.models import Membership from member.models import Membership
from .backends import PermissionBackend from .backends import PermissionBackend
@ -35,11 +36,9 @@ class ProtectQuerysetMixin:
try: try:
return super().get_object(queryset) return super().get_object(queryset)
except Http404 as e: except Http404 as e:
try: if self.get_queryset(filter_permissions=False).count() == self.get_queryset().count():
super().get_object(self.get_queryset(filter_permissions=False))
raise PermissionDenied()
except Http404:
raise e raise e
raise PermissionDenied()
def get_form(self, form_class=None): def get_form(self, form_class=None):
form = super().get_form(form_class) form = super().get_form(form_class)
@ -107,10 +106,31 @@ class ProtectedCreateView(LoginRequiredMixin, CreateView):
return super().dispatch(request, *args, **kwargs) return super().dispatch(request, *args, **kwargs)
class RightsView(TemplateView): class RightsView(MultiTableMixin, TemplateView):
template_name = "permission/all_rights.html" template_name = "permission/all_rights.html"
extra_context = {"title": _("Rights")} extra_context = {"title": _("Rights")}
tables = [
lambda data: RightsTable(data, prefix="clubs-"),
lambda data: SuperuserTable(data, prefix="superusers-"),
]
def get_tables_data(self):
special_memberships = Membership.objects.filter(
date_start__lte=date.today(),
date_end__gte=date.today(),
).filter(roles__in=Role.objects.filter((~(Q(name="Adhérent⋅e BDE")
| Q(name="Adhérent⋅e Kfet")
| Q(name="Membre de club")
| Q(name="Bureau de club"))
& Q(weirole__isnull=True))))\
.order_by("club__name", "user__last_name")\
.distinct().all()
return [
special_memberships,
User.objects.filter(is_superuser=True).order_by("last_name"),
]
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs) context = super().get_context_data(**kwargs)
@ -128,19 +148,9 @@ class RightsView(TemplateView):
role.clubs = [membership.club for membership in active_memberships if role in membership.roles.all()] role.clubs = [membership.club for membership in active_memberships if role in membership.roles.all()]
if self.request.user.is_authenticated: if self.request.user.is_authenticated:
special_memberships = Membership.objects.filter( tables = context["tables"]
date_start__lte=date.today(), for name, table in zip(["special_memberships_table", "superusers"], tables):
date_end__gte=date.today(), context[name] = table
).filter(roles__in=Role.objects.filter((~(Q(name="Adhérent⋅e BDE")
| Q(name="Adhérent⋅e Kfet")
| Q(name="Membre de club")
| Q(name="Bureau de club"))
& Q(weirole__isnull=True))))\
.order_by("club__name", "user__last_name")\
.distinct().all()
context["special_memberships_table"] = RightsTable(special_memberships, prefix="clubs-")
context["superusers"] = SuperuserTable(User.objects.filter(is_superuser=True).order_by("last_name").all(),
prefix="superusers-")
return context return context

View File

@ -16,6 +16,7 @@ from django.views import View
from django.views.generic import CreateView, TemplateView, DetailView from django.views.generic import CreateView, TemplateView, DetailView
from django.views.generic.edit import FormMixin from django.views.generic.edit import FormMixin
from django_tables2 import SingleTableView from django_tables2 import SingleTableView
from api.viewsets import is_regex
from member.forms import ProfileForm from member.forms import ProfileForm
from member.models import Membership, Club from member.models import Membership, Club
from note.models import SpecialTransaction, Alias from note.models import SpecialTransaction, Alias
@ -192,11 +193,16 @@ class FutureUserListView(ProtectQuerysetMixin, LoginRequiredMixin, SingleTableVi
if "search" in self.request.GET and self.request.GET["search"]: if "search" in self.request.GET and self.request.GET["search"]:
pattern = 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_username = "__iregex" if valid_regex else "__icontains"
suffix = "__iregex" if valid_regex else "__istartswith"
prefix = "^" if valid_regex else ""
qs = qs.filter( qs = qs.filter(
Q(first_name__iregex=pattern) Q(**{f"first_name{suffix}": pattern})
| Q(last_name__iregex=pattern) | Q(**{f"last_name{suffix}": pattern})
| Q(profile__section__iregex=pattern) | Q(**{f"profile__section{suffix}": pattern})
| Q(username__iregex="^" + pattern) | Q(**{f"username{suffix_username}": prefix + pattern})
) )
return qs return qs

View File

@ -2,7 +2,7 @@
# SPDX-License-Identifier: GPL-3.0-or-later # SPDX-License-Identifier: GPL-3.0-or-later
from django_filters.rest_framework import DjangoFilterBackend from django_filters.rest_framework import DjangoFilterBackend
from rest_framework.filters import SearchFilter from api.filters import RegexSafeSearchFilter
from api.viewsets import ReadProtectedModelViewSet from api.viewsets import ReadProtectedModelViewSet
from .serializers import InvoiceSerializer, ProductSerializer, RemittanceTypeSerializer, RemittanceSerializer, \ from .serializers import InvoiceSerializer, ProductSerializer, RemittanceTypeSerializer, RemittanceSerializer, \
@ -18,7 +18,7 @@ class InvoiceViewSet(ReadProtectedModelViewSet):
""" """
queryset = Invoice.objects.order_by('id') queryset = Invoice.objects.order_by('id')
serializer_class = InvoiceSerializer serializer_class = InvoiceSerializer
filter_backends = [DjangoFilterBackend, SearchFilter] filter_backends = [DjangoFilterBackend, RegexSafeSearchFilter]
filterset_fields = ['bde', 'object', 'description', 'name', 'address', 'date', 'acquitted', 'locked', ] filterset_fields = ['bde', 'object', 'description', 'name', 'address', 'date', 'acquitted', 'locked', ]
search_fields = ['$object', '$description', '$name', '$address', ] search_fields = ['$object', '$description', '$name', '$address', ]
@ -31,7 +31,7 @@ class ProductViewSet(ReadProtectedModelViewSet):
""" """
queryset = Product.objects.order_by('invoice_id', 'id') queryset = Product.objects.order_by('invoice_id', 'id')
serializer_class = ProductSerializer serializer_class = ProductSerializer
filter_backends = [DjangoFilterBackend, SearchFilter] filter_backends = [DjangoFilterBackend, RegexSafeSearchFilter]
filterset_fields = ['invoice', 'designation', 'quantity', 'amount', ] filterset_fields = ['invoice', 'designation', 'quantity', 'amount', ]
search_fields = ['$designation', '$invoice__object', ] search_fields = ['$designation', '$invoice__object', ]
@ -44,7 +44,7 @@ class RemittanceTypeViewSet(ReadProtectedModelViewSet):
""" """
queryset = RemittanceType.objects.order_by('id') queryset = RemittanceType.objects.order_by('id')
serializer_class = RemittanceTypeSerializer serializer_class = RemittanceTypeSerializer
filter_backends = [DjangoFilterBackend, SearchFilter] filter_backends = [DjangoFilterBackend, RegexSafeSearchFilter]
filterset_fields = ['note', ] filterset_fields = ['note', ]
search_fields = ['$note__special_type', ] search_fields = ['$note__special_type', ]
@ -57,7 +57,7 @@ class RemittanceViewSet(ReadProtectedModelViewSet):
""" """
queryset = Remittance.objects.order_by('id') queryset = Remittance.objects.order_by('id')
serializer_class = RemittanceSerializer serializer_class = RemittanceSerializer
filter_backends = [DjangoFilterBackend, SearchFilter] filter_backends = [DjangoFilterBackend, RegexSafeSearchFilter]
filterset_fields = ['date', 'remittance_type', 'comment', 'closed', 'transaction_proxies__transaction', ] filterset_fields = ['date', 'remittance_type', 'comment', 'closed', 'transaction_proxies__transaction', ]
search_fields = ['$remittance_type__note__special_type', '$comment', ] search_fields = ['$remittance_type__note__special_type', '$comment', ]
@ -70,7 +70,7 @@ class SogeCreditViewSet(ReadProtectedModelViewSet):
""" """
queryset = SogeCredit.objects.order_by('id') queryset = SogeCredit.objects.order_by('id')
serializer_class = SogeCreditSerializer serializer_class = SogeCreditSerializer
filter_backends = [DjangoFilterBackend, SearchFilter] filter_backends = [DjangoFilterBackend, RegexSafeSearchFilter]
filterset_fields = ['user', 'user__last_name', 'user__first_name', 'user__email', 'user__note__alias__name', filterset_fields = ['user', 'user__last_name', 'user__first_name', 'user__email', 'user__note__alias__name',
'user__note__alias__normalized_name', 'transactions', 'credit_transaction', ] 'user__note__alias__normalized_name', 'transactions', 'credit_transaction', ]
search_fields = ['$user__last_name', '$user__first_name', '$user__email', '$user__note__alias__name', search_fields = ['$user__last_name', '$user__first_name', '$user__email', '$user__note__alias__name',

View File

@ -109,7 +109,7 @@
\renewcommand{\headrulewidth}{0pt} \renewcommand{\headrulewidth}{0pt}
\cfoot{ \cfoot{
\small{\MonNom ~--~ \MonAdresseRue ~ \MonAdresseVille ~--~ Téléphone : +33(0)7 78 17 22 34\newline \small{\MonNom ~--~ \MonAdresseRue ~ \MonAdresseVille ~--~ Téléphone : +33(0)7 78 17 22 34\newline
Site web : bde.ens-cachan.fr ~--~ E-mail : tresorerie.bde@lists.crans.org \newline Numéro SIRET : 399 485 838 00029 E-mail : tresorerie.bde@lists.crans.org ~--~ Numéro SIRET : 399 485 838 00029
} }
} }

View File

@ -19,7 +19,8 @@ from django.utils.translation import gettext_lazy as _
from django.views.generic import UpdateView, DetailView from django.views.generic import UpdateView, DetailView
from django.views.generic.base import View, TemplateView from django.views.generic.base import View, TemplateView
from django.views.generic.edit import BaseFormView, DeleteView from django.views.generic.edit import BaseFormView, DeleteView
from django_tables2 import SingleTableView from django_tables2 import MultiTableMixin, SingleTableMixin, SingleTableView
from api.viewsets import is_regex
from note.models import SpecialTransaction, NoteSpecial, Alias from note.models import SpecialTransaction, NoteSpecial, Alias
from note_kfet.settings.base import BASE_DIR from note_kfet.settings.base import BASE_DIR
from permission.backends import PermissionBackend from permission.backends import PermissionBackend
@ -251,21 +252,26 @@ class RemittanceCreateView(ProtectQuerysetMixin, ProtectedCreateView):
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs) context = super().get_context_data(**kwargs)
context["table"] = RemittanceTable(
data=Remittance.objects.filter(
PermissionBackend.filter_queryset(self.request, Remittance, "view")).all())
context["special_transactions"] = SpecialTransactionTable(data=SpecialTransaction.objects.none()) context["special_transactions"] = SpecialTransactionTable(data=SpecialTransaction.objects.none())
return context return context
class RemittanceListView(LoginRequiredMixin, TemplateView): class RemittanceListView(LoginRequiredMixin, MultiTableMixin, TemplateView):
""" """
List existing Remittances List existing Remittances
""" """
template_name = "treasury/remittance_list.html" template_name = "treasury/remittance_list.html"
extra_context = {"title": _("Remittances list")} extra_context = {"title": _("Remittances list")}
tables = [
lambda data: RemittanceTable(data, prefix="opened-remittances-"),
lambda data: RemittanceTable(data, prefix="closed-remittances-"),
lambda data: SpecialTransactionTable(data, prefix="no-remittance-", exclude=('remittance_remove', )),
lambda data: SpecialTransactionTable(data, prefix="with-remittance-", exclude=('remittance_add', )),
]
paginate_by = 10 # number of rows in tables
def dispatch(self, request, *args, **kwargs): def dispatch(self, request, *args, **kwargs):
# Check that the user is authenticated # Check that the user is authenticated
if not request.user.is_authenticated: if not request.user.is_authenticated:
@ -275,49 +281,37 @@ class RemittanceListView(LoginRequiredMixin, TemplateView):
raise PermissionDenied(_("You are not able to see the treasury interface.")) raise PermissionDenied(_("You are not able to see the treasury interface."))
return super().dispatch(request, *args, **kwargs) return super().dispatch(request, *args, **kwargs)
def get_tables_data(self):
return [
Remittance.objects.filter(closed=False).filter(
PermissionBackend.filter_queryset(self.request, Remittance, "view")),
Remittance.objects.filter(closed=True).filter(
PermissionBackend.filter_queryset(self.request, Remittance, "view")),
SpecialTransaction.objects.filter(source__in=NoteSpecial.objects.filter(~Q(remittancetype=None)),
specialtransactionproxy__remittance=None).filter(
PermissionBackend.filter_queryset(self.request, Remittance, "view")),
SpecialTransaction.objects.filter(source__in=NoteSpecial.objects.filter(~Q(remittancetype=None)),
specialtransactionproxy__remittance__closed=False).filter(
PermissionBackend.filter_queryset(self.request, Remittance, "view")),
]
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs) context = super().get_context_data(**kwargs)
opened_remittances = RemittanceTable( tables = context["tables"]
data=Remittance.objects.filter(closed=False).filter( names = [
PermissionBackend.filter_queryset(self.request, Remittance, "view")).all(), "opened_remittances",
prefix="opened-remittances-", "closed_remittances",
) "special_transactions_no_remittance",
opened_remittances.paginate(page=self.request.GET.get("opened-remittances-page", 1), per_page=10) "special_transactions_with_remittance",
context["opened_remittances"] = opened_remittances ]
for name, table in zip(names, tables):
closed_remittances = RemittanceTable( context[name] = table
data=Remittance.objects.filter(closed=True).filter(
PermissionBackend.filter_queryset(self.request, Remittance, "view")).all(),
prefix="closed-remittances-",
)
closed_remittances.paginate(page=self.request.GET.get("closed-remittances-page", 1), per_page=10)
context["closed_remittances"] = closed_remittances
no_remittance_tr = SpecialTransactionTable(
data=SpecialTransaction.objects.filter(source__in=NoteSpecial.objects.filter(~Q(remittancetype=None)),
specialtransactionproxy__remittance=None).filter(
PermissionBackend.filter_queryset(self.request, Remittance, "view")).all(),
exclude=('remittance_remove', ),
prefix="no-remittance-",
)
no_remittance_tr.paginate(page=self.request.GET.get("no-remittance-page", 1), per_page=10)
context["special_transactions_no_remittance"] = no_remittance_tr
with_remittance_tr = SpecialTransactionTable(
data=SpecialTransaction.objects.filter(source__in=NoteSpecial.objects.filter(~Q(remittancetype=None)),
specialtransactionproxy__remittance__closed=False).filter(
PermissionBackend.filter_queryset(self.request, Remittance, "view")).all(),
exclude=('remittance_add', ),
prefix="with-remittance-",
)
with_remittance_tr.paginate(page=self.request.GET.get("with-remittance-page", 1), per_page=10)
context["special_transactions_with_remittance"] = with_remittance_tr
return context return context
class RemittanceUpdateView(ProtectQuerysetMixin, LoginRequiredMixin, UpdateView): class RemittanceUpdateView(ProtectQuerysetMixin, LoginRequiredMixin, SingleTableMixin, UpdateView):
""" """
Update Remittance Update Remittance
""" """
@ -325,19 +319,18 @@ class RemittanceUpdateView(ProtectQuerysetMixin, LoginRequiredMixin, UpdateView)
form_class = RemittanceForm form_class = RemittanceForm
extra_context = {"title": _("Update a remittance")} extra_context = {"title": _("Update a remittance")}
table_class = SpecialTransactionTable
context_table_name = "special_transactions"
def get_success_url(self): def get_success_url(self):
return reverse_lazy('treasury:remittance_list') return reverse_lazy('treasury:remittance_list')
def get_context_data(self, **kwargs): def get_table_data(self):
context = super().get_context_data(**kwargs) return SpecialTransaction.objects.filter(specialtransactionproxy__remittance=self.object).filter(
PermissionBackend.filter_queryset(self.request, Remittance, "view"))
data = SpecialTransaction.objects.filter(specialtransactionproxy__remittance=self.object).filter( def get_table_kwargs(self):
PermissionBackend.filter_queryset(self.request, Remittance, "view")).all() return {"exclude": ('remittance_add', 'remittance_remove', ) if self.object.closed else ('remittance_add', )}
context["special_transactions"] = SpecialTransactionTable(
data=data,
exclude=('remittance_add', 'remittance_remove', ) if self.object.closed else ('remittance_add', ))
return context
class LinkTransactionToRemittanceView(ProtectQuerysetMixin, LoginRequiredMixin, UpdateView): class LinkTransactionToRemittanceView(ProtectQuerysetMixin, LoginRequiredMixin, UpdateView):
@ -411,11 +404,16 @@ class SogeCreditListView(LoginRequiredMixin, ProtectQuerysetMixin, SingleTableVi
if "search" in self.request.GET: if "search" in self.request.GET:
pattern = self.request.GET["search"] pattern = self.request.GET["search"]
if pattern: if pattern:
# Check if this is a valid regex. If not, we won't check regex
valid_regex = is_regex(pattern)
suffix_alias = "__iregex" if valid_regex else "__icontains"
suffix = "__iregex" if valid_regex else "__istartswith"
prefix = "^" if valid_regex else ""
qs = qs.filter( qs = qs.filter(
Q(user__first_name__iregex=pattern) Q(**{f"user__first_name{suffix}": pattern})
| Q(user__last_name__iregex=pattern) | Q(**{f"user__last_name{suffix}": pattern})
| Q(user__note__alias__name__iregex="^" + pattern) | Q(**{f"user__note__alias__name{suffix_alias}": prefix + pattern})
| Q(user__note__alias__normalized_name__iregex="^" + Alias.normalize(pattern)) | Q(**{f"user__note__alias__normalized_name{suffix_alias}": prefix + Alias.normalize(pattern)})
) )
if "valid" not in self.request.GET or not self.request.GET["valid"]: if "valid" not in self.request.GET or not self.request.GET["valid"]:

View File

@ -2,7 +2,8 @@
# SPDX-License-Identifier: GPL-3.0-or-later # SPDX-License-Identifier: GPL-3.0-or-later
from django_filters.rest_framework import DjangoFilterBackend from django_filters.rest_framework import DjangoFilterBackend
from rest_framework.filters import OrderingFilter, SearchFilter from rest_framework.filters import OrderingFilter
from api.filters import RegexSafeSearchFilter
from api.viewsets import ReadProtectedModelViewSet from api.viewsets import ReadProtectedModelViewSet
from .serializers import WEIClubSerializer, BusSerializer, BusTeamSerializer, WEIRoleSerializer, \ from .serializers import WEIClubSerializer, BusSerializer, BusTeamSerializer, WEIRoleSerializer, \
@ -18,7 +19,7 @@ class WEIClubViewSet(ReadProtectedModelViewSet):
""" """
queryset = WEIClub.objects.order_by('id') queryset = WEIClub.objects.order_by('id')
serializer_class = WEIClubSerializer serializer_class = WEIClubSerializer
filter_backends = [DjangoFilterBackend, SearchFilter] filter_backends = [DjangoFilterBackend, RegexSafeSearchFilter]
filterset_fields = ['name', 'year', 'date_start', 'date_end', 'email', 'note__alias__name', filterset_fields = ['name', 'year', 'date_start', 'date_end', 'email', 'note__alias__name',
'note__alias__normalized_name', 'parent_club', 'parent_club__name', 'require_memberships', 'note__alias__normalized_name', 'parent_club', 'parent_club__name', 'require_memberships',
'membership_fee_paid', 'membership_fee_unpaid', 'membership_duration', 'membership_start', 'membership_fee_paid', 'membership_fee_unpaid', 'membership_duration', 'membership_start',
@ -34,7 +35,7 @@ class BusViewSet(ReadProtectedModelViewSet):
""" """
queryset = Bus.objects.order_by('id') queryset = Bus.objects.order_by('id')
serializer_class = BusSerializer serializer_class = BusSerializer
filter_backends = [DjangoFilterBackend, SearchFilter] filter_backends = [DjangoFilterBackend, RegexSafeSearchFilter]
filterset_fields = ['name', 'wei', 'description', ] filterset_fields = ['name', 'wei', 'description', ]
search_fields = ['$name', '$wei__name', '$description', ] search_fields = ['$name', '$wei__name', '$description', ]
@ -47,7 +48,7 @@ class BusTeamViewSet(ReadProtectedModelViewSet):
""" """
queryset = BusTeam.objects.order_by('id') queryset = BusTeam.objects.order_by('id')
serializer_class = BusTeamSerializer serializer_class = BusTeamSerializer
filter_backends = [DjangoFilterBackend, SearchFilter] filter_backends = [DjangoFilterBackend, RegexSafeSearchFilter]
filterset_fields = ['name', 'bus', 'color', 'description', 'bus__wei', ] filterset_fields = ['name', 'bus', 'color', 'description', 'bus__wei', ]
search_fields = ['$name', '$bus__name', '$bus__wei__name', '$description', ] search_fields = ['$name', '$bus__name', '$bus__wei__name', '$description', ]
@ -60,7 +61,7 @@ class WEIRoleViewSet(ReadProtectedModelViewSet):
""" """
queryset = WEIRole.objects.order_by('id') queryset = WEIRole.objects.order_by('id')
serializer_class = WEIRoleSerializer serializer_class = WEIRoleSerializer
filter_backends = [DjangoFilterBackend, SearchFilter] filter_backends = [DjangoFilterBackend, RegexSafeSearchFilter]
filterset_fields = ['name', 'permissions', 'memberships', ] filterset_fields = ['name', 'permissions', 'memberships', ]
search_fields = ['$name', ] search_fields = ['$name', ]
@ -73,7 +74,7 @@ class WEIRegistrationViewSet(ReadProtectedModelViewSet):
""" """
queryset = WEIRegistration.objects.order_by('id') queryset = WEIRegistration.objects.order_by('id')
serializer_class = WEIRegistrationSerializer serializer_class = WEIRegistrationSerializer
filter_backends = [DjangoFilterBackend, SearchFilter] filter_backends = [DjangoFilterBackend, RegexSafeSearchFilter]
filterset_fields = ['user', 'user__username', 'user__first_name', 'user__last_name', 'user__email', filterset_fields = ['user', 'user__username', 'user__first_name', 'user__last_name', 'user__email',
'user__note__alias__name', 'user__note__alias__normalized_name', 'wei', 'wei__name', 'user__note__alias__name', 'user__note__alias__normalized_name', 'wei', 'wei__name',
'wei__email', 'wei__year', 'soge_credit', 'caution_check', 'birth_date', 'gender', 'wei__email', 'wei__year', 'soge_credit', 'caution_check', 'birth_date', 'gender',
@ -92,7 +93,7 @@ class WEIMembershipViewSet(ReadProtectedModelViewSet):
""" """
queryset = WEIMembership.objects.order_by('id') queryset = WEIMembership.objects.order_by('id')
serializer_class = WEIMembershipSerializer serializer_class = WEIMembershipSerializer
filter_backends = [DjangoFilterBackend, OrderingFilter, SearchFilter] filter_backends = [DjangoFilterBackend, OrderingFilter, RegexSafeSearchFilter]
filterset_fields = ['club__name', 'club__email', 'club__note__alias__name', filterset_fields = ['club__name', 'club__email', 'club__note__alias__name',
'club__note__alias__normalized_name', 'user__username', 'user__last_name', 'club__note__alias__normalized_name', 'user__username', 'user__last_name',
'user__first_name', 'user__email', 'user__note__alias__name', 'user__first_name', 'user__email', 'user__note__alias__name',

View File

@ -22,7 +22,8 @@ from django.views import View
from django.views.generic import DetailView, UpdateView, RedirectView, TemplateView from django.views.generic import DetailView, UpdateView, RedirectView, TemplateView
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from django.views.generic.edit import BaseFormView, DeleteView from django.views.generic.edit import BaseFormView, DeleteView
from django_tables2 import SingleTableView from django_tables2 import SingleTableView, MultiTableMixin
from api.viewsets import is_regex
from member.models import Membership, Club from member.models import Membership, Club
from note.models import Transaction, NoteClub, Alias, SpecialTransaction, NoteSpecial from note.models import Transaction, NoteClub, Alias, SpecialTransaction, NoteSpecial
from note.tables import HistoryTable from note.tables import HistoryTable
@ -100,7 +101,7 @@ class WEICreateView(ProtectQuerysetMixin, ProtectedCreateView):
return reverse_lazy("wei:wei_detail", kwargs={"pk": self.object.pk}) return reverse_lazy("wei:wei_detail", kwargs={"pk": self.object.pk})
class WEIDetailView(ProtectQuerysetMixin, LoginRequiredMixin, DetailView): class WEIDetailView(ProtectQuerysetMixin, LoginRequiredMixin, MultiTableMixin, DetailView):
""" """
View WEI information View WEI information
""" """
@ -108,34 +109,40 @@ class WEIDetailView(ProtectQuerysetMixin, LoginRequiredMixin, DetailView):
context_object_name = "club" context_object_name = "club"
extra_context = {"title": _("WEI Detail")} extra_context = {"title": _("WEI Detail")}
def get_context_data(self, **kwargs): tables = [
context = super().get_context_data(**kwargs) lambda data: HistoryTable(data, prefix="history-"),
lambda data: WEIMembershipTable(data, prefix="membership-"),
club = context["club"] lambda data: WEIRegistrationTable(data, prefix="pre-registration-"),
lambda data: BusTable(data, prefix="bus-"),
]
paginate_by = 20 # number of rows in tables
def get_tables_data(self):
club = self.object
club_transactions = Transaction.objects.all().filter(Q(source=club.note) | Q(destination=club.note)) \ club_transactions = Transaction.objects.all().filter(Q(source=club.note) | Q(destination=club.note)) \
.filter(PermissionBackend.filter_queryset(self.request, Transaction, "view")) \ .filter(PermissionBackend.filter_queryset(self.request, Transaction, "view")) \
.order_by('-created_at', '-id') .order_by('-created_at', '-id')
history_table = HistoryTable(club_transactions, prefix="history-")
history_table.paginate(per_page=20, page=self.request.GET.get('history-page', 1))
context['history_list'] = history_table
club_member = WEIMembership.objects.filter( club_member = WEIMembership.objects.filter(
club=club, club=club,
date_end__gte=date.today(), date_end__gte=date.today(),
).filter(PermissionBackend.filter_queryset(self.request, WEIMembership, "view")) ).filter(PermissionBackend.filter_queryset(self.request, WEIMembership, "view"))
membership_table = WEIMembershipTable(data=club_member, prefix="membership-")
membership_table.paginate(per_page=20, page=self.request.GET.get('membership-page', 1))
context['member_list'] = membership_table
pre_registrations = WEIRegistration.objects.filter( pre_registrations = WEIRegistration.objects.filter(
PermissionBackend.filter_queryset(self.request, WEIRegistration, "view")).filter( PermissionBackend.filter_queryset(self.request, WEIRegistration, "view")).filter(
membership=None, membership=None,
wei=club wei=club
) )
pre_registrations_table = WEIRegistrationTable(data=pre_registrations, prefix="pre-registration-") buses = Bus.objects.filter(PermissionBackend.filter_queryset(self.request, Bus, "view")) \
pre_registrations_table.paginate(per_page=20, page=self.request.GET.get('pre-registration-page', 1)) .filter(wei=self.object).annotate(count=Count("memberships")).order_by("name")
context['pre_registrations'] = pre_registrations_table return [club_transactions, club_member, pre_registrations, buses, ]
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
club = context["club"]
tables = context["tables"]
for name, table in zip(["history_list", "member_list", "pre_registrations", "buses"], tables):
context[name] = table
my_registration = WEIRegistration.objects.filter(wei=club, user=self.request.user) my_registration = WEIRegistration.objects.filter(wei=club, user=self.request.user)
if my_registration.exists(): if my_registration.exists():
@ -144,11 +151,6 @@ class WEIDetailView(ProtectQuerysetMixin, LoginRequiredMixin, DetailView):
my_registration = None my_registration = None
context["my_registration"] = my_registration context["my_registration"] = my_registration
buses = Bus.objects.filter(PermissionBackend.filter_queryset(self.request, Bus, "view")) \
.filter(wei=self.object).annotate(count=Count("memberships")).order_by("name")
bus_table = BusTable(data=buses, prefix="bus-")
context['buses'] = bus_table
random_user = User.objects.filter(~Q(wei__wei__in=[club])).first() random_user = User.objects.filter(~Q(wei__wei__in=[club])).first()
if random_user is None: if random_user is None:
@ -219,13 +221,18 @@ class WEIMembershipsView(ProtectQuerysetMixin, LoginRequiredMixin, SingleTableVi
if not pattern: if not pattern:
return qs.none() return qs.none()
# Check if this is a valid regex. If not, we won't check regex
valid_regex = is_regex(pattern)
suffix_alias = "__iregex" if valid_regex else "__istartswith"
suffix = "__iregex" if valid_regex else "__icontains"
prefix = "^" if valid_regex else ""
qs = qs.filter( qs = qs.filter(
Q(user__first_name__iregex=pattern) Q(**{f"user__first_name{suffix}": pattern})
| Q(user__last_name__iregex=pattern) | Q(**{f"user__last_name{suffix}": pattern})
| Q(user__note__alias__name__iregex="^" + pattern) | Q(**{f"user__note__alias__name{suffix_alias}": prefix + pattern})
| Q(user__note__alias__normalized_name__iregex="^" + Alias.normalize(pattern)) | Q(**{f"user__note__alias__normalized_name{suffix_alias}": prefix + Alias.normalize(pattern)})
| Q(bus__name__iregex=pattern) | Q(**{f"bus__name{suffix}": pattern})
| Q(team__name__iregex=pattern) | Q(**{f"team__name{suffix}": pattern})
) )
return qs return qs
@ -255,11 +262,16 @@ class WEIRegistrationsView(ProtectQuerysetMixin, LoginRequiredMixin, SingleTable
pattern = self.request.GET.get("search", "") pattern = self.request.GET.get("search", "")
if pattern: if pattern:
# Check if this is a valid regex. If not, we won't check regex
valid_regex = is_regex(pattern)
suffix_alias = "__iregex" if valid_regex else "__istartswith"
suffix = "__iregex" if valid_regex else "__icontains"
prefix = "^" if valid_regex else ""
qs = qs.filter( qs = qs.filter(
Q(user__first_name__iregex=pattern) Q(**{f"user__first_name{suffix}": pattern})
| Q(user__last_name__iregex=pattern) | Q(**{f"user__last_name{suffix}": pattern})
| Q(user__note__alias__name__iregex="^" + pattern) | Q(**{f"user__note__alias__name{suffix_alias}": prefix + pattern})
| Q(user__note__alias__normalized_name__iregex="^" + Alias.normalize(pattern)) | Q(**{f"user__note__alias__normalized_name{suffix_alias}": prefix + Alias.normalize(pattern)})
) )
return qs return qs

View File

@ -8,7 +8,7 @@ peuvent être diffusées via des calendriers ou la mailing list d'événements.
Modèles Modèles
------- -------
L'application comporte 5 modèles : activités, types d'activité, invité⋅es, entrées et transactions d'invitation. L'application comporte 6 modèles : activités, types d'activité, invité⋅es, entrées et transactions d'invitation et les ouvreur⋅ses.
Types d'activité Types d'activité
~~~~~~~~~~~~~~~~ ~~~~~~~~~~~~~~~~
@ -71,6 +71,17 @@ comportent qu'un champ supplémentaire, de type ``OneToOneField`` vers ``Guest``
Ce modèle aurait pu appartenir à l'application ``note``, mais afin de rester modulaire et que l'application ``note`` Ce modèle aurait pu appartenir à l'application ``note``, mais afin de rester modulaire et que l'application ``note``
ne dépende pas de cette application, on procède de cette manière. ne dépende pas de cette application, on procède de cette manière.
Ouvreur⋅ses
~~~~~~~~~~~
Depuis la page d'une activité, il est possible d'ajouter des personnes en tant qu'« ouvreur⋅se ». Cela permet à une
personne sans aucun droit note de pouvoir faire les entrées d'une ``Activity``. Ce rôle n'est valable que pendant que
l'activité est ouverte et sur aucune autre activité. Les ouvreur⋅ses ont aussi accès à l'interface des transactions.
Ce modèle regroupe :
* Activité (clé étrangère)
* Note (clé étrangère)
Graphe Graphe
~~~~~~ ~~~~~~
@ -108,3 +119,6 @@ apparaîssent, afin de régler la taxe d'invitation : l'un prélève directement
permettent un paiement par espèces ou par carte bancaire. En réalité, les deux derniers boutons enregistrent permettent un paiement par espèces ou par carte bancaire. En réalité, les deux derniers boutons enregistrent
automatiquement un crédit sur la note de l'hôte, puis une transaction (de type ``GuestTransaction``) est faite depuis automatiquement un crédit sur la note de l'hôte, puis une transaction (de type ``GuestTransaction``) est faite depuis
la note de l'hôte vers la note du club organisateur de l'événement. la note de l'hôte vers la note du club organisateur de l'événement.
Si une personne souhaite faire les entrées, il est possible de l'ajouter dans la liste des ouvreur⋅ses depuis la page
de l'activité.

View File

@ -51,7 +51,7 @@ Applications packagées
`<https://django-polymorphic.readthedocs.io/en/stable/>`_ `<https://django-polymorphic.readthedocs.io/en/stable/>`_
* ``crispy_forms`` * ``crispy_forms``
Utiliser pour générer des formulairess avec Bootstrap4 Utiliser pour générer des formulaires avec Bootstrap4
* ``django_tables2`` * ``django_tables2``
utiliser pour afficher des tables de données et les formater, en Python plutôt qu'en HTML. utiliser pour afficher des tables de données et les formater, en Python plutôt qu'en HTML.
* ``restframework`` * ``restframework``

View File

@ -21,7 +21,7 @@ Utilisateur⋅rice
Le modèle ``User`` est directement implémenté dans Django et n'appartient pas à l'application ``member``, mais il est Le modèle ``User`` est directement implémenté dans Django et n'appartient pas à l'application ``member``, mais il est
bon de rappeler à quoi ressemble ce modèle. bon de rappeler à quoi ressemble ce modèle.
* ``date_joined`` : ``DateTimeField``, date à laquelle l'utilisateur⋅rice a été inscrit (*inutilisé dans la Note*) * ``date_joined`` : ``DateTimeField``, date à laquelle l'utilisateur⋅rice a été inscrit⋅e (*inutilisé dans la Note*)
* ``email`` : ``EmailField``, adresse e-mail de l'utilisateur⋅rice. * ``email`` : ``EmailField``, adresse e-mail de l'utilisateur⋅rice.
* ``first_name`` : ``CharField``, prénom de l'utilisateur⋅rice. * ``first_name`` : ``CharField``, prénom de l'utilisateur⋅rice.
* ``is_active`` : ``BooleanField``, indique si le compte est actif et peut se connecter. * ``is_active`` : ``BooleanField``, indique si le compte est actif et peut se connecter.

View File

@ -6,7 +6,7 @@ L'application ``note`` gère tout ce qui est en lien avec les flux d'argent et l
La gestion des consommations s'effectue principalement via la page dédiée, dont le fonctionnement est expliqué La gestion des consommations s'effectue principalement via la page dédiée, dont le fonctionnement est expliqué
dans la page `Consommations <consumptions>`_. dans la page `Consommations <consumptions>`_.
Le fonctionnement des crédit/débit de note (avec le « monde extérieur »» donc avec de l'argent réel) ainsi que les Le fonctionnement des crédit/débit de note (avec le « monde extérieur » donc avec de l'argent réel) ainsi que les
transferts/dons entre notes est détaillé sur la page `Transferts <transactions>`_. transferts/dons entre notes est détaillé sur la page `Transferts <transactions>`_.
.. toctree:: .. toctree::

View File

@ -130,8 +130,8 @@ Exemples
Masques de permissions Masques de permissions
---------------------- ----------------------
Chaque permission est associée à un masque. À la connexion, l'utilisateur⋅rice choisit le masque de droits avec lequel il Chaque permission est associée à un masque. À la connexion, l'utilisateur⋅rice choisit le masque de droits avec lequel iel
souhaite se connecter. Les masques sont ordonnés totalement, et l'utilisateur⋅rice aura effectivement une permission s'il est souhaite se connecter. Les masques sont ordonnés totalement, et l'utilisateur⋅rice aura effectivement une permission si iel est
en droit d'avoir la permission et si son masque est suffisamment haut. en droit d'avoir la permission et si son masque est suffisamment haut.
Par exemple, si la permission de voir toutes les transactions est associée au masque « Droits note uniquement », Par exemple, si la permission de voir toutes les transactions est associée au masque « Droits note uniquement »,

View File

@ -19,7 +19,7 @@ Champs hérités de ``Club`` de l'application ``member`` :
* ``parent_club`` : ``ForeignKey(Club)``. Ce champ vaut toujours ``Kfet`` dans le cas d'un WEI : on doit être membre du * ``parent_club`` : ``ForeignKey(Club)``. Ce champ vaut toujours ``Kfet`` dans le cas d'un WEI : on doit être membre du
club Kfet pour participer au WEI. club Kfet pour participer au WEI.
* ``email`` : ``EmailField``, adresse e-mail sur laquelle contacter les gérants du WEI. * ``email`` : ``EmailField``, adresse e-mail sur laquelle contacter les gérant⋅es du WEI.
* ``membership_start`` : ``DateField``, date à partir de laquelle il est possible de s'inscrire au WEI. * ``membership_start`` : ``DateField``, date à partir de laquelle il est possible de s'inscrire au WEI.
* ``membership_end`` : ``DateField``, date de fin d'adhésion possible au WEI. * ``membership_end`` : ``DateField``, date de fin d'adhésion possible au WEI.
* ``membership_duration`` : ``PositiveIntegerField``, inutilisé dans le cas d'un WEI, vaut ``None``. * ``membership_duration`` : ``PositiveIntegerField``, inutilisé dans le cas d'un WEI, vaut ``None``.
@ -291,7 +291,7 @@ pour unique effet d'appeler la fonction ``run_algorithm`` décrite plus tôt. Un
n'a pas été évoqué d'adhésion. L'adhésion est ensuite manuelle, l'algorithme ne fournit qu'une suggestion. n'a pas été évoqué d'adhésion. L'adhésion est ensuite manuelle, l'algorithme ne fournit qu'une suggestion.
Cette structure, complexe mais raisonnable, permet de gérer plus ou moins proprement la répartition des 1A, Cette structure, complexe mais raisonnable, permet de gérer plus ou moins proprement la répartition des 1A,
en limitant très fortement le hard code. Ami nouvelleeau développeur⋅se, merci de bien penser à la propreté du code :) en limitant très fortement le hard code. Ami⋅e nouvelleau développeur⋅se, merci de bien penser à la propreté du code :)
En particulier, on évitera de mentionner dans le code le nom des bus, et profiter du champ ``information_json`` En particulier, on évitera de mentionner dans le code le nom des bus, et profiter du champ ``information_json``
présent dans le modèle ``Bus``. présent dans le modèle ``Bus``.

View File

@ -15,7 +15,7 @@ Applications externes
Puisque la Note Kfet recense tous les comptes des adhérent⋅es BDE, les clubs ont alors Puisque la Note Kfet recense tous les comptes des adhérent⋅es BDE, les clubs ont alors
la possibilité de développer leurs propres applications et de les interfacer avec la la possibilité de développer leurs propres applications et de les interfacer avec la
note. De cette façon, chaque application peut authentifier ses utilisateur⋅rices via la note, note. De cette façon, chaque application peut authentifier ses utilisateur⋅rices via la note,
et récupérer leurs adhésion, leur nom de note afin d'éventuellement faire des transferts et récupérer leurs adhésions, leur nom de note afin d'éventuellement faire des transferts
via l'API. via l'API.
Deux protocoles d'authentification sont implémentées : Deux protocoles d'authentification sont implémentées :

View File

@ -6,24 +6,23 @@ Des transactions anormales sont apparues sur mon compte.
.. note:: .. note::
Tu dois immédiatement contacter les trésorièr⋅es du BDE (voir ci-dessous) pour Tu dois immédiatement contacter les trésorièr⋅es du BDE (voir ci-dessous) pour
signaler l'incident. Précise bien ton nom de note, l'heure de la transaction signaler l'incident. Précise bien ton nom de note, l'heure de la transaction ainsi que
ainsi que l'alias utilisé pour faire la transaction (en plaçant ta souris sur l'alias utilisé pour faire la transaction (en plaçant ta souris sur ton pseudo sur la
ton pseudo sur la ligne de transaction, l'alias utilisé apparaît). La raison ligne de transaction, l'alias utilisé apparaît). La raison la plus courante est que tu
la plus courante est que tu as un alias qui est trop proche d'un autre d'une as un alias qui est trop proche d'une autre personne. Même si la Note Kfet 2020 essaie
autre personne. Même si la Note Kfet 2020 essaie d'éviter ça, tu es invité⋅e d'éviter ça, tu es invité⋅e à supprimer l'alias problématique, ou tout du moins
à supprimer l'alias problématique, ou tout du moins t'assurer que la confusion t'assurer que la confusion ne puisse plus arriver.
ne puisse plus arriver.
Je souhaite consommer mais le solde de ma note est insuffisant Je souhaite consommer mais le solde de ma note est insuffisant
-------------------------------------------------------------- --------------------------------------------------------------
.. note:: .. note::
Le BDE ne fait pas crédit à ses adhérent⋅es. Il est de ton devoir de t'assurer Le BDE ne fait pas crédit à ses adhérent⋅es. Il est de ton devoir de t'assurer d'avoir
d'avoir en permanence un solde positif sur ta note. Les permanencièr⋅es à la en permanence un solde positif sur ta note. Les permanencièr⋅es à la Kfet ont la
Kfet ont la possibilité de refuser une consommation qui fait passer en négatif, possibilité de refuser une consommation qui fait passer en négatif, et ont obligation
et ont obligation de refuser si la consommation est alcoolisée, en accord avec de refuser si la consommation est alcoolisée, en accord avec la règlementation en
la règlementation en vigueur. vigueur.
Les trésorièr⋅es connaissent la liste des personnes en situation irrégulière et Les trésorièr⋅es connaissent la liste des personnes en situation irrégulière et
n'hésiteront pas à faire des rappels pour recharger la note. n'hésiteront pas à faire des rappels pour recharger la note.
@ -33,10 +32,10 @@ Comment recharger ma note ?
--------------------------- ---------------------------
.. note:: .. note::
Le solde de la note peut être rechargé soit par espèces, par chèque à l'ordre Le solde de la note peut être rechargé soit par espèces, par chèque à l'ordre de
de l'amicale des élèves de l'ENS Paris-Saclay, par carte bancaire via un terminal l'amicale des élèves de l'ENS Paris-Saclay, par carte bancaire via un terminal de
de paiement électronique ou encore par virement bancaire, dont les coordonnées paiement électronique ou encore par virement bancaire, dont les coordonnées sont à
sont à demander auprès des trésorièr⋅es BDE. demander auprès des trésorièr⋅es BDE.
Les trois premières options sont à faire directement dans la Kfet. Les trois premières options sont à faire directement dans la Kfet.
@ -45,9 +44,9 @@ Je pars en stage / en vacances. Puis-je bloquer ma note ?
--------------------------------------------------------- ---------------------------------------------------------
.. note:: .. note::
Bien sûr : il te suffit de te rendre sur ton compte et de cliquer sur le bouton Bien sûr : il te suffit de te rendre sur ton compte et de cliquer sur le bouton dédié.
dédié. Ta note ne sera plus affichée par les autres personnes et les transferts Ta note ne sera plus affichée par les autres personnes et les transferts seront
seront impossibles, sauf pour les trésorièr⋅es BDE et respo info. impossibles, sauf pour les trésorièr⋅es BDE et respo info.
Il est toutefois de ton devoir de rembourser tout ce que tu dois. Il est toutefois de ton devoir de rembourser tout ce que tu dois.
@ -59,43 +58,42 @@ Quelle est la limite maximale au nombre d'alias d'une note ?
Certain⋅es parlent d'une dizaine d'alias par note. Certain⋅es parlent d'une dizaine d'alias par note.
Sois conscient⋅e qu'ajouter des alias ne peut qu'augmenter la probabilité de Sois conscient⋅e qu'ajouter des alias ne peut qu'augmenter la probabilité de
collisions avec une autre note, et peut aussi retarder la livraison de ta collisions avec une autre note, et peut aussi retarder la livraison de ta commande
commande lors d'un perm bouffe. lors d'une perm bouffe.
Je suis trésorièr⋅e d'un club, qu'ai-je le droit de faire ? Je suis trésorièr⋅e d'un club, qu'ai-je le droit de faire ?
----------------------------------------------------------- -----------------------------------------------------------
.. note:: .. note::
Être trésorièr⋅e d'un club donne la responsabilité de gérer la trésorerie du Être trésorièr⋅e d'un club donne la responsabilité de gérer la trésorerie du club, et
club, et donc de gérer sa note. Vous obtenez donc le droit d'effectuer donc de gérer sa note. Vous obtenez donc le droit d'effectuer n'importe quelle
n'importe quelle transaction via la note en provenance ou à destination de transaction via la note en provenance ou à destination de la note de votre club. Vous
la note de votre club. Vous pouvez également gérer les adhésions de votre club, pouvez également gérer les adhésions de votre club, en permettant à n'importe quel⋅le
en permettant à n'importe quel⋅le adhérent⋅e BDE de rejoindre votre club, en adhérent⋅e BDE de rejoindre votre club, en prélevant d'éventuels frais d'adhésion. Les
prélevant d'éventuels frais d'adhésion. Les paramètres du club peuvent être paramètres du club peuvent être également modifiés.
également modifiés.
.. danger:: .. danger::
Avoir des droits sur la Note Kfet ne signifie pas que vous devez les utiliser. Avoir des droits sur la Note Kfet ne signifie pas que vous devez les utiliser. Chaque
Chaque opération nécessitant des droits doit être fait pour une bonne raison, opération nécessitant des droits doit être fait pour une bonne raison, et doit avoir
et doit avoir un lien avec votre club. Vous n'avez par exemple pas le droit un lien avec votre club. Vous n'avez par exemple pas le droit d'aller récupérer des
d'aller récupérer des informations personnelles d'adhérent⋅es pour une raison informations personnelles d'adhérent⋅es pour une raison personnelle. En revanche,
personnelle. En revanche, faire le lien entre nom/prénom et nom de note est faire le lien entre nom/prénom et nom de note est bien sûr permis pour faciliter des
bien sûr permis pour faciliter des transferts. Tout abus de droits constaté transferts. Tout abus de droits constaté pourra mener à des sanctions prises par le
pourra mener à des sanctions prises par le bureau du BDE. bureau du BDE.
Je suis trésorièr⋅e d'un club, je n'arrive pas à voir le solde du club / faire des transactions Je suis trésorièr⋅e d'un club, je n'arrive pas à voir le solde du club / faire des transactions
----------------------------------------------------------------------------------------------------- -----------------------------------------------------------------------------------------------
.. note:: .. note::
As-tu bien vérifié que tu t'es connecté⋅e initialement avec tous tes droits ? As-tu bien vérifié que tu t'es connecté⋅e initialement avec tous tes droits ? Sinon,
Sinon, si tes droits sont tout récents, tu dois te déconnecter et te reconnecter si tes droits sont tout récents, tu dois te déconnecter et te reconnecter pour que tes
pour que tes droits soient bien pris en compte. droits soient bien pris en compte.
La Note permet de se connecter avec différents filtres de permission afin de La Note permet de se connecter avec différents filtres de permission afin de pouvoir
pouvoir prêter son ordinateur avec une session ouverte pour faire quelques prêter son ordinateur avec une session ouverte pour faire quelques opérations en
opérations en empêchant l'accès à des opérations trop sensibles. empêchant l'accès à des opérations trop sensibles.
Je suis trésorièr⋅e d'un club. Puis-je créer un bouton ? Je suis trésorièr⋅e d'un club. Puis-je créer un bouton ?
@ -104,15 +102,14 @@ Je suis trésorièr⋅e d'un club. Puis-je créer un bouton ?
.. note:: .. note::
Oui bien sûr ! Tant qu'il redirige bien vers la note de ton club. Oui bien sûr ! Tant qu'il redirige bien vers la note de ton club.
Pour cela, rends-toi à la page `</note/buttons/>`_ pour afficher la liste des Pour cela, rends-toi à la page `</note/buttons/>`_ pour afficher la liste des boutons,
boutons, puis tu auras accès à l'interface pour créer un bouton. Une fois le puis tu auras accès à l'interface pour créer un bouton. Une fois le bouton créé, il
bouton créé, il apparaîtra dans l'onglet ``Consommations``. apparaîtra dans l'onglet ``Consommations``.
Il faut noter que tant qu'il n'y a pas de boutons visibles pour ton club, tu Il faut noter que tant qu'il n'y a pas de boutons visibles pour ton club, tu n'auras
n'auras pas accès à l'interface de consommations, et tu devras nécessairement pas accès à l'interface de consommations, et tu devras nécessairement cliquer sur le
cliquer sur le lien ci-dessus pour accéder à l'interface d'édition des boutons. lien ci-dessus pour accéder à l'interface d'édition des boutons. Une fois qu'un bouton
Une fois qu'un bouton pour ton club est visible, l'interface consommations pour ton club est visible, l'interface consommations devient accessible.
devient accessible.
Après passation, je suis trésorièr⋅e d'un club. Comment récupérer mes droits note ? Après passation, je suis trésorièr⋅e d'un club. Comment récupérer mes droits note ?
@ -120,8 +117,8 @@ Après passation, je suis trésorièr⋅e d'un club. Comment récupérer mes dro
.. note:: .. note::
Tu dois pour cela contacter les trésorièr⋅es BDE (voir ci-dessous). Iels vous Tu dois pour cela contacter les trésorièr⋅es BDE (voir ci-dessous). Iels vous
expliqueront en détails vos droits et vos interdits et vous donneront les expliqueront en détails vos droits et vos interdits et vous donneront les droits
droits requis. requis.
Je souhaite contacter un⋅e trésorièr⋅e Je souhaite contacter un⋅e trésorièr⋅e
@ -129,18 +126,18 @@ Je souhaite contacter un⋅e trésorièr⋅e
.. note:: .. note::
Pour contacter un⋅e trésorièr⋅e, il te suffit d'envoyer un mail à l'adresse Pour contacter un⋅e trésorièr⋅e, il te suffit d'envoyer un mail à l'adresse
`tresorerie.bde@lists.crans.org <tresorerie.bde@lists.crans.org>`_. Pense bien `tresorerie.bde@lists.crans.org <tresorerie.bde@lists.crans.org>`_. Pense bien à
à donner ton nom de note, voire à envoyer un scan de ta carte d'identité si ta donner ton nom de note, voire à envoyer un scan de ta carte d'identité si ta demande
demande concerne un virement entre le compte du BDE et ton propre compte. concerne un virement entre le compte du BDE et ton propre compte.
J'ai trouvé un bug, comment le signaler ? J'ai trouvé un bug, comment le signaler ?
----------------------------------------- -----------------------------------------
.. note:: .. note::
La Note Kfet est développée bénévolement par des membres du BDE. Nous mettons La Note Kfet est développée bénévolement par des membres du BDE. Nous mettons tous nos
tous nos efforts pour fournir une plateforme sans erreur et la plus ergonomique efforts pour fournir une plateforme sans erreur et la plus ergonomique possible.
possible. Toutefois, il n'est évidemment pas exclu que des bugs soient présents :) Toutefois, il n'est évidemment pas exclu que des bugs soient présents :)
Pour nous soumettre un bug, tu peux envoyer un mail à Pour nous soumettre un bug, tu peux envoyer un mail à
`notekfet2020@lists.crans.org <notekfet2020@lists.crans.org>`_ `notekfet2020@lists.crans.org <notekfet2020@lists.crans.org>`_
@ -157,13 +154,13 @@ Je souhaite contribuer
---------------------- ----------------------
.. note:: .. note::
La Note Kfet est essentiellement développée par des responsables informatiques du La Note Kfet est essentiellement développée par des responsables informatiques du BDE
BDE de l'ENS Paris-Saclay. Toutefois, si vous souhaitez contribuer, vous pouvez de l'ENS Paris-Saclay. Toutefois, si vous souhaitez contribuer, vous pouvez bien sûr
bien sûr le faire en accord avec la licence GPLv3 avec laquelle la Note Kfet est le faire en accord avec la licence GPLv3 avec laquelle la Note Kfet est distribuée.
distribuée. Pour cela, si vous êtes adhérent⋅e Crans, vous pouvez proposer une Pour cela, si vous êtes adhérent⋅e Crans, vous pouvez proposer une demande de fusion
demande de fusion de votre code. Si ce n'est pas le cas, vous pouvez envoyer un de votre code. Si ce n'est pas le cas, vous pouvez envoyer un mail à
mail à `notekfet2020@lists.crans.org <notekfet2020@lists.crans.org>`_. `notekfet2020@lists.crans.org <notekfet2020@lists.crans.org>`_. Dans les deux cas,
Dans les deux cas, merci de rejoindre le canal ``#note`` sur IRC :) merci de rejoindre le canal ``#note`` sur IRC :)
Contributeur⋅rices Contributeur⋅rices
@ -171,20 +168,22 @@ Contributeur⋅rices
.. note:: .. note::
La version 2020 de la Note Kfet a été développée sous le mandat de la La version 2020 de la Note Kfet a été développée sous le mandat de la
Saper[list]popette. Son développement a commencé à l'été 2019. Après un mois de beta Saper[list]popette. Son développement a commencé à l'été 2019. Après un mois de beta à
à l'été 2020, son déploiement en production s'est fait le samedi 5 septembre 2020. l'été 2020, son déploiement en production s'est fait le samedi 5 septembre 2020.
Elle succède à presque 6 années de la Elle succède à presque 6 années de la
`Note Kfet 2015 <https://wiki.crans.org/NoteKfet/NoteKfet2015>`_, alors en production `Note Kfet 2015 <https://wiki.crans.org/NoteKfet/NoteKfet2015>`_, alors en production
depuis le 6 octobre 2014. depuis le 6 octobre 2014.
Liste des contributeurs majeurs, par ordre alphabétique : Liste des contributeur⋅rices majeur⋅es, par ordre alphabétique :
* Pierre-André « PAC » COMBY * bleizi
* Emmy « ÿnérant » D'ANELLO * erdnaxe
* Benjamin « esum » GRAILLOT * esum
* Alexandre « erdnaxe » IOOSS * korenst1
* Nicolas « nicomarg » MARGULIES * nicomarg
* PAC
* ÿnérant
Hébergement Hébergement
@ -192,8 +191,8 @@ Hébergement
.. note:: .. note::
En accord entre le BDE de l'ENS Paris-Saclay et le Crans, l'instance de production En accord entre le BDE de l'ENS Paris-Saclay et le Crans, l'instance de production
présente sur `<https://note.crans.org/>`_ est hébergée sur l'un des serveurs du présente sur `<https://note.crans.org/>`_ est hébergée sur l'un des serveurs du Crans.
Crans. Les données sont hébergées à l'adresse : Les données sont hébergées à l'adresse :
.. code:: .. code::

View File

@ -75,7 +75,7 @@ présentes sont :
* Département * Département
* Promotion * Promotion
* Adresse * Adresse
* Élève/étudiant * Élève/étudiant⋅e
* Inscription aux listes de diffusion du BDE, du BDA et du BDS * Inscription aux listes de diffusion du BDE, du BDA et du BDS
Les trois premières informations sont obligatoires pour pouvoir vous contacter Les trois premières informations sont obligatoires pour pouvoir vous contacter

View File

@ -82,7 +82,7 @@ Pour cela, on peut simplement faire :
$ source env/bin/activate $ source env/bin/activate
(env) $ (env) $
À noter que ``source`` peut s'abbréger par ``.`` uniquement. À noter que ``source`` peut s'abréger par ``.`` uniquement.
Vous êtes donc dans un environnement virtuel Python. Pour installer les dépendances Vous êtes donc dans un environnement virtuel Python. Pour installer les dépendances
de la note : de la note :

View File

@ -221,7 +221,7 @@ Avec l'option ``--SUPER, -S``, la personne avec ce pseudo devient super-utilisat
et obtiens donc les pleins pouvoirs sur la note. À ne donner qu'aux respos info. et obtiens donc les pleins pouvoirs sur la note. À ne donner qu'aux respos info.
Avec l'option ``--STAFF, -s``, la personne avec ce pseudo acquiert le statut équipe, Avec l'option ``--STAFF, -s``, la personne avec ce pseudo acquiert le statut équipe,
et obtiens l'accès à django-admin. À ne donner qu'aux respos info. et obtient l'accès à django-admin. À ne donner qu'aux respos info.
Rafraîchissement des activités Rafraîchissement des activités

View File

@ -20,6 +20,7 @@ msgstr ""
#: apps/activity/apps.py:10 apps/activity/models.py:127 #: apps/activity/apps.py:10 apps/activity/models.py:127
#: apps/activity/models.py:167 #: apps/activity/models.py:167
#: apps/activity/models.py:323
msgid "activity" msgid "activity"
msgstr "activité" msgstr "activité"
@ -124,7 +125,7 @@ msgstr "utilisateur⋅rice"
#: apps/activity/models.py:96 #: apps/activity/models.py:96
#: apps/activity/templates/activity/includes/activity_info.html:36 #: apps/activity/templates/activity/includes/activity_info.html:36
msgid "organizer" msgid "organizer"
msgstr "organisateur·ice" msgstr "organisateur·rice"
#: apps/activity/models.py:97 #: apps/activity/models.py:97
msgid "Club that organizes the activity. The entry fees will go to this club." msgid "Club that organizes the activity. The entry fees will go to this club."
@ -238,6 +239,16 @@ msgstr "invité·e·s"
msgid "Invitation" msgid "Invitation"
msgstr "Invitation" msgstr "Invitation"
#: apps/activity/models.py:330
#: apps/activity/models.py:334
msgid "Opener"
msgstr "Ouvreur⋅se"
#: apps/activity/models.py:335
#: apps/activity/templates/activity_detail.html:16
msgid "Openers"
msgstr "Ouvreur⋅ses"
#: apps/activity/tables.py:27 #: apps/activity/tables.py:27
msgid "The activity is currently open." msgid "The activity is currently open."
msgstr "Cette activité est actuellement ouverte." msgstr "Cette activité est actuellement ouverte."
@ -783,7 +794,7 @@ msgstr "date de dernier rapport"
#: apps/member/models.py:127 #: apps/member/models.py:127
msgid "email confirmed" msgid "email confirmed"
msgstr "adresse email confirmée" msgstr "adresse e-mail confirmée"
#: apps/member/models.py:132 #: apps/member/models.py:132
msgid "registration valid" msgid "registration valid"
@ -795,7 +806,7 @@ msgstr "Charte VSS lue"
#: apps/member/models.py:142 apps/member/models.py:143 #: apps/member/models.py:142 apps/member/models.py:143
msgid "user profile" msgid "user profile"
msgstr "profil utilisateur·ice" msgstr "profil utilisateur·rice"
#: apps/member/models.py:177 #: apps/member/models.py:177
msgid "Activate your Note Kfet account" msgid "Activate your Note Kfet account"
@ -893,11 +904,11 @@ msgstr "Le rôle {role} ne s'applique pas au club {club}."
#: apps/member/models.py:381 apps/member/views.py:712 #: apps/member/models.py:381 apps/member/views.py:712
msgid "User is already a member of the club" msgid "User is already a member of the club"
msgstr "L'utilisateur·ice est déjà membre du club" msgstr "L'utilisateur·rice est déjà membre du club"
#: apps/member/models.py:393 apps/member/views.py:721 #: apps/member/models.py:393 apps/member/views.py:721
msgid "User is not a member of the parent club" msgid "User is not a member of the parent club"
msgstr "L'utilisateur·ice n'est pas membre du club parent" msgstr "L'utilisateur·rice n'est pas membre du club parent"
#: apps/member/tables.py:139 #: apps/member/tables.py:139
msgid "Renew" msgid "Renew"
@ -910,7 +921,7 @@ msgid ""
"%(pretty_fee)s will be charged to renew automatically the membership in this/" "%(pretty_fee)s will be charged to renew automatically the membership in this/"
"these club·s." "these club·s."
msgstr "" msgstr ""
"Cet·te utilisateur·ice n'est pas membre du/des club·s parent·s %(clubs)s. Un " "Cet·te utilisateur·rice n'est pas membre du/des club·s parent·s %(clubs)s. Un "
"montant supplémentaire de %(pretty_fee)s sera débité afin de renouveler " "montant supplémentaire de %(pretty_fee)s sera débité afin de renouveler "
"automatiquement l'adhésion dans ce·s club·s." "automatiquement l'adhésion dans ce·s club·s."
@ -1252,7 +1263,7 @@ msgstr "Modifier le club"
#: apps/member/views.py:574 #: apps/member/views.py:574
msgid "Add new member to the club" msgid "Add new member to the club"
msgstr "Ajouter un·e nouvelle·au membre au club" msgstr "Ajouter un·e nouvelleau membre au club"
#: apps/member/views.py:703 apps/wei/views.py:973 #: apps/member/views.py:703 apps/wei/views.py:973
msgid "" msgid ""
@ -2025,7 +2036,7 @@ msgstr "inscription"
#: apps/registration/forms.py:40 #: apps/registration/forms.py:40
msgid "This email address is already used." msgid "This email address is already used."
msgstr "Cette adresse email est déjà prise." msgstr "Cette adresse e-mail est déjà prise."
#: apps/registration/forms.py:60 #: apps/registration/forms.py:60
msgid "Register to the WEI" msgid "Register to the WEI"
@ -2080,7 +2091,7 @@ msgstr "Activation du compte"
msgid "" msgid ""
"An email has been sent. Please click on the link to activate your account." "An email has been sent. Please click on the link to activate your account."
msgstr "" msgstr ""
"Un email vient de vous être envoyé. Merci de cliquer sur le lien de " "Un e-mail vient de vous être envoyé. Merci de cliquer sur le lien de "
"validation pour activer votre compte." "validation pour activer votre compte."
#: apps/registration/templates/registration/email_validation_email_sent.html:17 #: apps/registration/templates/registration/email_validation_email_sent.html:17
@ -2123,7 +2134,7 @@ msgid ""
"confirm your registration." "confirm your registration."
msgstr "" msgstr ""
"Vous vous êtes inscrit·e récemment sur la Note Kfet. Merci de cliquer sur le " "Vous vous êtes inscrit·e récemment sur la Note Kfet. Merci de cliquer sur le "
"lien ci-dessous pour confirmer votre adresse email." "lien ci-dessous pour confirmer votre adresse e-mail."
#: apps/registration/templates/registration/mails/email_validation_email.html:26 #: apps/registration/templates/registration/mails/email_validation_email.html:26
#: apps/registration/templates/registration/mails/email_validation_email.txt:9 #: apps/registration/templates/registration/mails/email_validation_email.txt:9
@ -2157,7 +2168,7 @@ msgstr "Enregistrer un⋅e nouvel⋅le utilisateur⋅rice"
#: apps/registration/views.py:99 #: apps/registration/views.py:99
msgid "Email validation" msgid "Email validation"
msgstr "Validation de l'adresse mail" msgstr "Validation de l'adresse e-mail"
#: apps/registration/views.py:101 #: apps/registration/views.py:101
msgid "Validate email" msgid "Validate email"
@ -2165,11 +2176,11 @@ msgstr "Valider l'adresse e-mail"
#: apps/registration/views.py:145 #: apps/registration/views.py:145
msgid "Email validation unsuccessful" msgid "Email validation unsuccessful"
msgstr "La validation de l'adresse mail a échoué" msgstr "La validation de l'adresse e-mail a échoué"
#: apps/registration/views.py:156 #: apps/registration/views.py:156
msgid "Email validation email sent" msgid "Email validation email sent"
msgstr "L'email de vérification de l'adresse email a bien été envoyé" msgstr "L'e-mail de vérification de l'adresse e-mail a bien été envoyé"
#: apps/registration/views.py:164 #: apps/registration/views.py:164
msgid "Resend email validation link" msgid "Resend email validation link"
@ -2181,7 +2192,7 @@ msgstr "Liste des utilisateur⋅rices en attente d'inscription"
#: apps/registration/views.py:206 #: apps/registration/views.py:206
msgid "Unregistered users" msgid "Unregistered users"
msgstr "Utilisateur·rice·s en attente d'inscription" msgstr "Utilisateur·rices en attente d'inscription"
#: apps/registration/views.py:219 #: apps/registration/views.py:219
msgid "Registration detail" msgid "Registration detail"
@ -2869,19 +2880,19 @@ msgstr "Nombre de membres"
#: apps/wei/tables.py:226 apps/wei/tables.py:257 #: apps/wei/tables.py:226 apps/wei/tables.py:257
msgid "members" msgid "members"
msgstr "adhérent·e·s" msgstr "adhérent·es"
#: apps/wei/tables.py:287 #: apps/wei/tables.py:287
msgid "suggested first year" msgid "suggested first year"
msgstr "1A suggéré·e·s" msgstr "1A suggéré·es"
#: apps/wei/tables.py:293 #: apps/wei/tables.py:293
msgid "validated first year" msgid "validated first year"
msgstr "1A validé·e·s" msgstr "1A validé·es"
#: apps/wei/tables.py:299 #: apps/wei/tables.py:299
msgid "validated staff" msgid "validated staff"
msgstr "2A+ validé·e·s" msgstr "2A+ validé·es"
#: apps/wei/tables.py:310 #: apps/wei/tables.py:310
msgid "free seats" msgid "free seats"
@ -3307,7 +3318,7 @@ msgid ""
"sent to webmasters with the detail of the error, and this will be fixed " "sent to webmasters with the detail of the error, and this will be fixed "
"soon. You can now drink a beer." "soon. You can now drink a beer."
msgstr "" msgstr ""
"Désolé, une erreur est survenue lors de l'analyse de votre requête. Un email " "Désolé, une erreur est survenue lors de l'analyse de votre requête. Un e-mail "
"a été envoyé aux responsables de la plateforme avec les détails de cette " "a été envoyé aux responsables de la plateforme avec les détails de cette "
"erreur, qui sera corrigée rapidement. Vous pouvez désormais aller boire une " "erreur, qui sera corrigée rapidement. Vous pouvez désormais aller boire une "
"bière, avec modération." "bière, avec modération."
@ -3322,7 +3333,7 @@ msgstr "La note du BDE de l'ENS Paris-Saclay."
#: note_kfet/templates/base.html:78 #: note_kfet/templates/base.html:78
msgid "Users" msgid "Users"
msgstr "Utilisateur·rice·s" msgstr "Utilisateur·rices"
#: note_kfet/templates/base.html:84 #: note_kfet/templates/base.html:84
msgid "Clubs" msgid "Clubs"
@ -3601,7 +3612,7 @@ msgid ""
"If you don't receive an email, please make sure you've entered the address " "If you don't receive an email, please make sure you've entered the address "
"you registered with, and check your spam folder." "you registered with, and check your spam folder."
msgstr "" msgstr ""
"Si vous ne recevez pas d'email, vérifiez que vous avez bien utilisé " "Si vous ne recevez pas d'e-mail, vérifiez que vous avez bien utilisé "
"l'adresse associée à votre compte, et regarder également le dossier spam." "l'adresse associée à votre compte, et regarder également le dossier spam."
#: note_kfet/templates/registration/password_reset_form.html:13 #: note_kfet/templates/registration/password_reset_form.html:13

View File

@ -17,6 +17,14 @@ msgstr ""
"Content-Transfer-Encoding: 8bit\n" "Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=(n > 1);\n" "Plural-Forms: nplurals=2; plural=(n > 1);\n"
#: apps/member/static/member/js/alias.js:17
msgid "Opener successfully added"
msgstr "Ouvreureuse ajouté avec succès"
#: apps/member/static/member/js/alias.js:17
msgid "Opener successfully deleted"
msgstr "Ouvreureuse supprimé avec succès"
#: apps/member/static/member/js/alias.js:17 #: apps/member/static/member/js/alias.js:17
msgid "Alias successfully added" msgid "Alias successfully added"
msgstr "Alias ajouté avec succès" msgstr "Alias ajouté avec succès"

View File

@ -51,4 +51,4 @@ max-complexity = 15
max-line-length = 160 max-line-length = 160
import-order-style = google import-order-style = google
application-import-names = flake8 application-import-names = flake8
format = ${cyan}%(path)s${reset}:${yellow_bold}%(row)d${reset}:${green_bold}%(col)d${reset}: ${red_bold}%(code)s${reset} %(text)s format = %(cyan)s%(path)s%(reset)s:%(yellow)s%(bold)s%(row)d%(reset)s:%(green)s%(bold)s%(col)d%(reset)s: %(red)s%(bold)s%(code)s%(reset)s %(text)s