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

Compare commits

..

48 Commits

Author SHA1 Message Date
2cb9ac8735 replace "…" -> "..." (#130) and disable sorting on certain columns (#129) 2024-08-29 10:19:06 +02:00
35d4849a28 fix Oauth 2024-08-29 00:43:33 +02:00
2c56178b15 Merge branch 'main' into migration-django-4-2 2024-08-25 16:14:59 +02:00
48a5b04579 Merge branch 'beta' into migration-django-4-2 2024-08-25 16:13:01 +02:00
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
0d9891fbd8 Merge branch 'migration-django-4-2' of gitlab.crans.org:bde/nk20 into migration-django-4-2 2024-08-09 23:20:48 +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
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
bbbdcc7247 linters 2024-07-13 18:03:19 +02:00
feeb99041f Fix the Alias Search API 2024-07-13 12:41:59 +02:00
6c61daf1c5 Update views.py
Passage à la time zone Europe/Paris
2024-03-11 10:25:48 +01:00
96215cc1ff oidc_claim_scope in Class instead of method 2024-02-13 13:43:14 +01:00
b7a71d911d _get_validtion_exclusions() now return a set, PIL.Image.ANTIALIAS was renamed LANCZOS and typo in .gitlab-ci.yml 2024-02-12 22:56:43 +01:00
2ee7f41dfe tests with ubuntu 22.04, django-bootstrap-datepicker-plus is a standalone package and fix encoding in tests 2024-02-12 21:25:07 +01:00
fb3337966e bootstrap4 is now a standalone package from crispy-forms 2024-02-11 22:24:37 +01:00
399a32bece default auto field 2024-02-11 16:51:48 +01:00
82fea65b5e django_htcpcp_tea in middleware only if in apps 2024-02-07 20:03:57 +01:00
abc88d0118 replace url from django.conf.urls by re_path from django.urls 2024-02-07 18:21:08 +01:00
b6b81a8b8f typo 2024-02-07 18:05:32 +01:00
d228dbf225 fix some breaking changes and linters 2024-02-07 18:02:56 +01:00
516a7f4be5 Remove importation of django-htcpcp-tea which is not compatible with django 4.2 2024-01-24 20:14:32 +01:00
2f8c9b54e7 Remove importation of django-cas-server which is not compatible with django 4.2 2024-01-24 19:58:55 +01:00
e9f18c3ed9 migrate to django 4.2 (LTS), change requirement and tests. remove depreciated ifnotequal 2024-01-24 19:18:02 +01:00
62 changed files with 1075 additions and 659 deletions

View File

@ -7,25 +7,25 @@ stages:
variables:
GIT_SUBMODULE_STRATEGY: recursive
# Debian Buster
# py37-django22:
# stage: test
# image: debian:buster-backports
# before_script:
# - >
# apt-get update &&
# apt-get install --no-install-recommends -t buster-backports -y
# python3-django python3-django-crispy-forms
# python3-django-extensions python3-django-filters python3-django-polymorphic
# python3-djangorestframework python3-django-oauth-toolkit python3-psycopg2 python3-pil
# python3-babel python3-lockfile python3-pip python3-phonenumbers python3-memcache
# python3-bs4 python3-setuptools tox texlive-xetex
# script: tox -e py37-django22
# Ubuntu 20.04
py38-django22:
# Debian Bullseye
py39-django42:
stage: test
image: ubuntu:20.04
image: debian:bullseye
before_script:
- >
apt-get update &&
apt-get install --no-install-recommends -y
python3-django python3-django-crispy-forms
python3-django-extensions python3-django-filters python3-django-polymorphic
python3-djangorestframework python3-django-oauth-toolkit python3-psycopg2 python3-pil
python3-babel python3-lockfile python3-pip python3-phonenumbers python3-memcache
python3-bs4 python3-setuptools tox texlive-xetex
script: tox -e py39-django42
# Ubuntu 22.04
py310-django42:
stage: test
image: ubuntu:22.04
before_script:
# Fix tzdata prompt
- ln -sf /usr/share/zoneinfo/Europe/Paris /etc/localtime && echo Europe/Paris > /etc/timezone
@ -37,12 +37,12 @@ py38-django22:
python3-djangorestframework python3-django-oauth-toolkit python3-psycopg2 python3-pil
python3-babel python3-lockfile python3-pip python3-phonenumbers python3-memcache
python3-bs4 python3-setuptools tox texlive-xetex
script: tox -e py38-django22
script: tox -e py310-django42
# Debian Bullseye
py39-django22:
# Debian Bookworm
py311-django42:
stage: test
image: debian:bullseye
image: debian:bookworm
before_script:
- >
apt-get update &&
@ -52,11 +52,13 @@ py39-django22:
python3-djangorestframework python3-django-oauth-toolkit python3-psycopg2 python3-pil
python3-babel python3-lockfile python3-pip python3-phonenumbers python3-memcache
python3-bs4 python3-setuptools tox texlive-xetex
script: tox -e py39-django22
script: tox -e py311-django42
linters:
stage: quality-assurance
image: debian:bullseye
image: debian:bookworm
before_script:
- apt-get update && apt-get install -y tox
script: tox -e linters

View File

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

View File

@ -1,9 +1,11 @@
# Copyright (C) 2018-2024 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later
from django.utils.translation import gettext_lazy as _
from rest_framework import serializers
from rest_framework.validators import UniqueTogetherValidator
from ..models import Activity, ActivityType, Entry, Guest, GuestTransaction
from ..models import Activity, ActivityType, Entry, Guest, GuestTransaction, Opener
class ActivityTypeSerializer(serializers.ModelSerializer):
@ -59,3 +61,17 @@ class GuestTransactionSerializer(serializers.ModelSerializer):
class Meta:
model = GuestTransaction
fields = '__all__'
class OpenerSerializer(serializers.ModelSerializer):
"""
REST API Serializer for Openers.
The djangorestframework plugin will analyse the model `Opener` and parse all fields in the API.
"""
class Meta:
model = Opener
fields = '__all__'
validators = [UniqueTogetherValidator(
queryset=Opener.objects.all(), fields=("opener", "activity"),
message=_("This opener already exists"))]

View File

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

View File

@ -1,12 +1,15 @@
# Copyright (C) 2018-2024 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later
from api.filters import RegexSafeSearchFilter
from api.viewsets import ReadProtectedModelViewSet
from django.core.exceptions import ValidationError
from django_filters.rest_framework import DjangoFilterBackend
from rest_framework.filters import SearchFilter
from rest_framework.response import Response
from rest_framework import status
from .serializers import ActivitySerializer, ActivityTypeSerializer, EntrySerializer, GuestSerializer
from ..models import Activity, ActivityType, Entry, Guest
from .serializers import ActivitySerializer, ActivityTypeSerializer, EntrySerializer, GuestSerializer, OpenerSerializer
from ..models import Activity, ActivityType, Entry, Guest, Opener
class ActivityTypeViewSet(ReadProtectedModelViewSet):
@ -29,7 +32,7 @@ class ActivityViewSet(ReadProtectedModelViewSet):
"""
queryset = Activity.objects.order_by('id')
serializer_class = ActivitySerializer
filter_backends = [DjangoFilterBackend, SearchFilter]
filter_backends = [DjangoFilterBackend, RegexSafeSearchFilter]
filterset_fields = ['name', 'description', 'activity_type', 'location', 'creater', 'organizer', 'attendees_club',
'date_start', 'date_end', 'valid', 'open', ]
search_fields = ['$name', '$description', '$location', '$creater__last_name', '$creater__first_name',
@ -47,7 +50,7 @@ class GuestViewSet(ReadProtectedModelViewSet):
"""
queryset = Guest.objects.order_by('id')
serializer_class = GuestSerializer
filter_backends = [DjangoFilterBackend, SearchFilter]
filter_backends = [DjangoFilterBackend, RegexSafeSearchFilter]
filterset_fields = ['activity', 'activity__name', 'last_name', 'first_name', 'inviter', 'inviter__alias__name',
'inviter__alias__normalized_name', ]
search_fields = ['$activity__name', '$last_name', '$first_name', '$inviter__user__email', '$inviter__alias__name',
@ -62,7 +65,36 @@ class EntryViewSet(ReadProtectedModelViewSet):
"""
queryset = Entry.objects.order_by('id')
serializer_class = EntrySerializer
filter_backends = [DjangoFilterBackend, SearchFilter]
filter_backends = [DjangoFilterBackend, RegexSafeSearchFilter]
filterset_fields = ['activity', 'time', 'note', 'guest', ]
search_fields = ['$activity__name', '$note__user__email', '$note__alias__name', '$note__alias__normalized_name',
'$guest__last_name', '$guest__first_name', ]
class OpenerViewSet(ReadProtectedModelViewSet):
"""
REST Opener View set.
The djangorestframework plugin will get all `Opener` objects, serialize it to JSON with the given serializer,
then render it on /api/activity/opener/
"""
queryset = Opener.objects
serializer_class = OpenerSerializer
filter_backends = [RegexSafeSearchFilter, DjangoFilterBackend]
search_fields = ['$opener__alias__name', '$opener__alias__normalized_name',
'$activity__name']
filterset_fields = ['opener', 'opener__noteuser__user', 'activity']
def get_serializer_class(self):
serializer_class = self.serializer_class
if self.request.method in ['PUT', 'PATCH']:
# opener-activity can't change
serializer_class.Meta.read_only_fields = ('opener', 'acitivity',)
return serializer_class
def destroy(self, request, *args, **kwargs):
instance = self.get_object()
try:
self.perform_destroy(instance)
except ValidationError as e:
return Response({e.code: str(e)}, status.HTTP_400_BAD_REQUEST)
return Response(status=status.HTTP_204_NO_CONTENT)

View File

@ -4,13 +4,14 @@
from datetime import timedelta
from random import shuffle
from bootstrap_datepicker_plus.widgets import DateTimePickerInput
from django import forms
from django.contrib.contenttypes.models import ContentType
from django.utils import timezone
from django.utils.translation import gettext_lazy as _
from member.models import Club
from note.models import Note, NoteUser
from note_kfet.inputs import Autocomplete, DateTimePickerInput
from note_kfet.inputs import Autocomplete
from note_kfet.middlewares import get_current_request
from permission.backends import PermissionBackend
@ -43,7 +44,7 @@ class ActivityForm(forms.ModelForm):
class Meta:
model = Activity
exclude = ('creater', 'valid', 'open', )
exclude = ('creater', 'valid', 'open', 'opener', )
widgets = {
"organizer": Autocomplete(
model=Club,

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.utils import timezone
from django.utils.translation import gettext_lazy as _
from note.models import NoteUser, Transaction
from note.models import NoteUser, Transaction, Note
from rest_framework.exceptions import ValidationError
@ -310,3 +310,31 @@ class GuestTransaction(Transaction):
@property
def type(self):
return _('Invitation')
class Opener(models.Model):
"""
Allow the user to make activity entries without more rights
"""
activity = models.ForeignKey(
Activity,
on_delete=models.CASCADE,
related_name='opener',
verbose_name=_('activity')
)
opener = models.ForeignKey(
Note,
on_delete=models.CASCADE,
related_name='activity_responsible',
verbose_name=_('Opener')
)
class Meta:
verbose_name = _("Opener")
verbose_name_plural = _("Openers")
unique_together = ("opener", "activity")
def __str__(self):
return _("{opener} is opener of activity {acivity}").format(
opener=str(self.opener), acivity=str(self.activity))

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.safestring import mark_safe
from django.utils.translation import gettext_lazy as _
from note_kfet.middlewares import get_current_request
import django_tables2 as tables
from django_tables2 import A
from permission.backends import PermissionBackend
from note.templatetags.pretty_money import pretty_money
from .models import Activity, Entry, Guest
from .models import Activity, Entry, Guest, Opener
class ActivityTable(tables.Table):
@ -113,3 +115,34 @@ class EntryTable(tables.Table):
'data-last-name': lambda record: record.last_name,
'data-first-name': lambda record: record.first_name,
}
# function delete_button(id) provided in template file
DELETE_TEMPLATE = """
<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 %}
{% load i18n perms %}
{% load render_table from django_tables2 %}
{% load static django_tables2 i18n %}
{% block content %}
<h1 class="text-white">{{ title }}</h1>
{% 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 %}
<div class="card bg-white mb-3">
<h3 class="card-header text-center">
@ -22,6 +42,8 @@ SPDX-License-Identifier: GPL-3.0-or-later
{% endblock %}
{% block extrajavascript %}
<script src="{% static "activity/js/opener.js" %}"></script>
<script src="{% static "js/autocomplete_model.js" %}"></script>
<script>
function remove_guest(guest_id) {
$.ajax({

View File

@ -18,14 +18,15 @@ from django.views import View
from django.views.decorators.cache import cache_page
from django.views.generic import DetailView, TemplateView, UpdateView
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 permission.backends import PermissionBackend
from permission.views import ProtectQuerysetMixin, ProtectedCreateView
from .forms import ActivityForm, GuestForm
from .models import Activity, Entry, Guest
from .tables import ActivityTable, EntryTable, GuestTable
from .models import Activity, Entry, Guest, Opener
from .tables import ActivityTable, EntryTable, GuestTable, OpenerTable
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.
"""
model = Activity
tables = [ActivityTable, ActivityTable]
tables = [
lambda data: ActivityTable(data, prefix="all-"),
lambda data: ActivityTable(data, prefix="upcoming-"),
]
extra_context = {"title": _("Activities")}
def get_queryset(self, **kwargs):
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):
# first table = all activities, second table = upcoming
return [
@ -99,7 +96,7 @@ class ActivityListView(ProtectQuerysetMixin, LoginRequiredMixin, MultiTableMixin
return context
class ActivityDetailView(ProtectQuerysetMixin, LoginRequiredMixin, DetailView):
class ActivityDetailView(ProtectQuerysetMixin, LoginRequiredMixin, MultiTableMixin, DetailView):
"""
Shows details about one activity. Add guest to context
"""
@ -107,15 +104,40 @@ class ActivityDetailView(ProtectQuerysetMixin, LoginRequiredMixin, DetailView):
context_object_name = "activity"
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):
context = super().get_context_data()
table = GuestTable(data=Guest.objects.filter(activity=self.object)
.filter(PermissionBackend.filter_queryset(self.request, Guest, "view")))
context["guests"] = table
tables = context["tables"]
for name, table in zip(["guests", "opener"], tables):
context[name] = table
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
@ -172,12 +194,14 @@ class ActivityInviteView(ProtectQuerysetMixin, ProtectedCreateView):
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
"""
template_name = "activity/activity_entry.html"
table_class = EntryTable
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),
@ -212,13 +236,16 @@ class ActivityEntryView(LoginRequiredMixin, TemplateView):
if "search" in self.request.GET and 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(
Q(first_name__iregex=pattern)
| Q(last_name__iregex=pattern)
| Q(inviter__alias__name__iregex=pattern)
| Q(inviter__alias__normalized_name__iregex=Alias.normalize(pattern))
Q(**{f"first_name{suffix}": pattern})
| Q(**{f"last_name{suffix}": pattern})
| Q(**{f"inviter__alias__name{suffix}": pattern})
| Q(**{f"inviter__alias__normalized_name{suffix}": Alias.normalize(pattern)})
)
else:
guest_qs = guest_qs.none()
@ -250,11 +277,15 @@ class ActivityEntryView(LoginRequiredMixin, TemplateView):
if "search" in self.request.GET and self.request.GET["search"]:
pattern = self.request.GET["search"]
# Check if this is a valid regex. If not, we won't check regex
valid_regex = is_regex(pattern)
suffix = "__iregex" if valid_regex else "__icontains"
note_qs = note_qs.filter(
Q(note__noteuser__user__first_name__iregex=pattern)
| Q(note__noteuser__user__last_name__iregex=pattern)
| Q(name__iregex=pattern)
| Q(normalized_name__iregex=Alias.normalize(pattern))
Q(**{f"note__noteuser__user__first_name{suffix}": pattern})
| Q(**{f"note__noteuser__user__last_name{suffix}": pattern})
| Q(**{f"name{suffix}": pattern})
| Q(**{f"normalized_name{suffix}": Alias.normalize(pattern)})
)
else:
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]
return note_qs
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)
def get_table_data(self):
activity = Activity.objects.filter(PermissionBackend.filter_queryset(self.request, Activity, "view"))\
.distinct().get(pk=self.kwargs["pk"])
context["activity"] = activity
matched = []
@ -287,8 +312,17 @@ class ActivityEntryView(LoginRequiredMixin, TemplateView):
note.activity = activity
matched.append(note)
table = EntryTable(data=matched)
context["table"] = table
return matched
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)
@ -330,8 +364,8 @@ X-WR-CALNAME:Kfet Calendar
NAME:Kfet Calendar
CALSCALE:GREGORIAN
BEGIN:VTIMEZONE
TZID:Europe/Berlin
X-LIC-LOCATION:Europe/Berlin
TZID:Europe/Paris
X-LIC-LOCATION:Europe/Paris
BEGIN:DAYLIGHT
TZOFFSETFROM:+0100
TZOFFSETTO:+0200
@ -353,10 +387,10 @@ END:VTIMEZONE
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()}
SUMMARY;CHARSET=UTF-8:{self.multilines(activity.name, 75, 22)}
DTSTART;TZID=Europe/Berlin:{"{:%Y%m%dT%H%M%S}".format(activity.date_start)}
DTEND;TZID=Europe/Berlin:{"{:%Y%m%dT%H%M%S}".format(activity.date_end)}
DTSTART:{"{:%Y%m%dT%H%M%S}Z".format(activity.date_start)}
DTEND:{"{:%Y%m%dT%H%M%S}Z".format(activity.date_end)}
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}
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.test import TestCase
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 note.models import NoteClub, NoteUser, Alias, Note
from permission.models import PermissionMask, Permission, Role
from phonenumbers import PhoneNumber
from rest_framework.filters import SearchFilter, OrderingFilter
from .viewsets import ContentTypeViewSet, UserViewSet
@ -87,7 +88,7 @@ class TestAPI(TestCase):
resp = self.client.get(url + f"?ordering=-{field}")
self.assertEqual(resp.status_code, 200)
if SearchFilter in backends:
if RegexSafeSearchFilter in backends:
# Basic search
for field in viewset.search_fields:
obj = self.fix_note_object(obj, field)

View File

@ -2,7 +2,8 @@
# SPDX-License-Identifier: GPL-3.0-or-later
from django.conf import settings
from django.conf.urls import url, include
from django.conf.urls import include
from django.urls import re_path
from rest_framework import routers
from .views import UserInformationView
@ -47,7 +48,7 @@ app_name = 'api'
# Wire up our API using automatic URL routing.
# Additionally, we include login URLs for the browsable API.
urlpatterns = [
url('^', include(router.urls)),
url('^me/', UserInformationView.as_view()),
url('^api-auth/', include('rest_framework.urls', namespace='rest_framework')),
re_path('^', include(router.urls)),
re_path('^me/', UserInformationView.as_view()),
re_path('^api-auth/', include('rest_framework.urls', namespace='rest_framework')),
]

View File

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

View File

@ -2,7 +2,8 @@
# SPDX-License-Identifier: GPL-3.0-or-later
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 .serializers import ProfileSerializer, ClubSerializer, MembershipSerializer
@ -17,7 +18,7 @@ class ProfileViewSet(ReadProtectedModelViewSet):
"""
queryset = Profile.objects.order_by('id')
serializer_class = ProfileSerializer
filter_backends = [DjangoFilterBackend, SearchFilter]
filter_backends = [DjangoFilterBackend, RegexSafeSearchFilter]
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",
'department', 'promotion', 'address', 'paid', 'ml_events_registration', 'ml_sport_registration',
@ -34,7 +35,7 @@ class ClubViewSet(ReadProtectedModelViewSet):
"""
queryset = Club.objects.order_by('id')
serializer_class = ClubSerializer
filter_backends = [DjangoFilterBackend, SearchFilter]
filter_backends = [DjangoFilterBackend, RegexSafeSearchFilter]
filterset_fields = ['name', 'email', 'note__alias__name', 'note__alias__normalized_name', 'parent_club',
'parent_club__name', 'require_memberships', 'membership_fee_paid', 'membership_fee_unpaid',
'membership_duration', 'membership_start', 'membership_end', ]
@ -49,7 +50,7 @@ class MembershipViewSet(ReadProtectedModelViewSet):
"""
queryset = Membership.objects.order_by('id')
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',
'user__username', 'user__last_name', 'user__first_name', 'user__email',
'user__note__alias__name', 'user__note__alias__normalized_name',

View File

@ -3,7 +3,7 @@
import io
from PIL import Image, ImageSequence
from bootstrap_datepicker_plus.widgets import DatePickerInput
from django import forms
from django.conf import settings
from django.contrib.auth.forms import AuthenticationForm
@ -13,8 +13,9 @@ from django.forms import CheckboxSelectMultiple
from django.utils import timezone
from django.utils.translation import gettext_lazy as _
from note.models import NoteSpecial, Alias
from note_kfet.inputs import Autocomplete, AmountInput, DatePickerInput
from note_kfet.inputs import Autocomplete, AmountInput
from permission.models import PermissionMask, Role
from PIL import Image, ImageSequence
from .models import Profile, Club, Membership
@ -32,7 +33,7 @@ class UserForm(forms.ModelForm):
# Django usernames can only contain letters, numbers, @, ., +, - and _.
# We want to allow users to have uncommon and unpractical usernames:
# That is their problem, and we have normalized aliases for us.
return super()._get_validation_exclusions() + ["username"]
return super()._get_validation_exclusions() | {"username"}
class Meta:
model = User

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()
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:
self.membership_start = datetime.date(self.membership_start.year + 1,
self.membership_start.month, self.membership_start.day)

View File

@ -42,12 +42,12 @@ class UserTable(tables.Table):
"""
alias = tables.Column()
section = tables.Column(accessor='profile__section')
section = tables.Column(accessor='profile__section', orderable=False)
# Override the column to let replace the URL
email = tables.EmailColumn(linkify=lambda record: "mailto:{}".format(record.email))
balance = tables.Column(accessor='note__balance', verbose_name=_("Balance"))
balance = tables.Column(accessor='note__balance', verbose_name=_("Balance"), orderable=False)
def render_email(self, record, value):
# Replace the email by a dash if the user can't see the profile detail

View File

@ -11,7 +11,7 @@ SPDX-License-Identifier: GPL-3.0-or-later
{{ title }}
</h3>
<div class="card-body">
<input id="searchbar" type="text" class="form-control" placeholder="Nom/prénom/note">
<input id="searchbar" type="text" class="form-control" placeholder="Nom/prénom/note...">
<div class="form-check">
<label class="form-check-label" for="only_active">
<input type="checkbox" class="checkboxinput form-check-input" id="only_active"

View File

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

View File

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

View File

@ -2,12 +2,13 @@
# SPDX-License-Identifier: GPL-3.0-or-later
from datetime import datetime
from bootstrap_datepicker_plus.widgets import DateTimePickerInput
from django import forms
from django.contrib.contenttypes.models import ContentType
from django.forms import CheckboxSelectMultiple
from django.utils.timezone import make_aware
from django.utils.translation import gettext_lazy as _
from note_kfet.inputs import Autocomplete, AmountInput, DateTimePickerInput
from note_kfet.inputs import Autocomplete, AmountInput
from .models import TransactionTemplate, NoteClub, Alias

View File

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

View File

@ -260,11 +260,13 @@ class ButtonTable(tables.Table):
text=_('edit'),
accessor='pk',
verbose_name=_("Edit"),
orderable=False,
)
hideshow = tables.Column(
verbose_name=_("Hide/Show"),
accessor="pk",
orderable=False,
attrs={
'td': {
'class': 'col-sm-1',
@ -276,7 +278,8 @@ class ButtonTable(tables.Table):
delete_col = tables.TemplateColumn(template_code=DELETE_TEMPLATE,
extra_context={"delete_trans": _('delete')},
attrs={'td': {'class': 'col-sm-1'}},
verbose_name=_("Delete"), )
verbose_name=_("Delete"),
orderable=False, )
def render_amount(self, value):
return pretty_money(value)

View File

@ -9,7 +9,7 @@ SPDX-License-Identifier: GPL-3.0-or-later
name="{{ widget.name }}"
{# Other attributes are loaded #}
{% for name, value in widget.attrs.items %}
{% ifnotequal value False %}{{ name }}{% ifnotequal value True %}="{{ value|stringformat:'s' }}"{% endifnotequal %}{% endifnotequal %}
{% if value is not False %}{{ name }}{% if value is not True %}="{{ value|stringformat:'s' }}"{% endif %}{% endif %}
{% endfor %}>
<div class="input-group-append">
<span class="input-group-text"></span>

View File

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

View File

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

View File

@ -3111,6 +3111,199 @@
"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",
"pk": 1,
@ -3152,7 +3345,15 @@
191,
195,
196,
198
198,
199,
200,
201,
202,
203,
204,
205,
206
]
}
},
@ -3414,7 +3615,11 @@
46,
148,
149,
182
182,
207,
208,
209,
210
]
}
},

View File

@ -135,18 +135,18 @@ class Permission(models.Model):
# A json encoded Q object with the following grammar
# query -> [] | {} (the empty query representing all objects)
# query -> ["AND", query, …] AND multiple queries
# | ["OR", query, …] OR multiple queries
# query -> ["AND", query, ...] AND multiple queries
# | ["OR", query, ...] OR multiple queries
# | ["NOT", query] Opposite of query
# query -> {key: value, …} A list of fields and values of a Q object
# query -> {key: value, ...} A list of fields and values of a Q object
# key -> string A field name
# value -> int | string | bool | null Literal values
# | [parameter, …] A parameter. See compute_param for more details.
# | [parameter, ...] A parameter. See compute_param for more details.
# | {"F": oper} An F object
# oper -> [string, …] A parameter. See compute_param for more details.
# | ["ADD", oper, …] Sum multiple F objects or literal
# oper -> [string, ...] A parameter. See compute_param for more details.
# | ["ADD", oper, ...] Sum multiple F objects or literal
# | ["SUB", oper, oper] Substract two F objects or literal
# | ["MUL", oper, …] Multiply F objects or literals
# | ["MUL", oper, ...] Multiply F objects or literals
# | int | string | bool | null Literal values
# | ["F", string] A field
#

View File

@ -35,6 +35,8 @@ class PermissionScopes(BaseScopes):
class PermissionOAuth2Validator(OAuth2Validator):
oidc_claim_scope = None # fix breaking change of django-oauth-toolkit 2.0.0
def validate_scopes(self, client_id, scopes, client, request, *args, **kwargs):
"""
User can request as many scope as he wants, including invalid scopes,

View File

@ -12,6 +12,7 @@ from django.forms import HiddenInput
from django.http import Http404
from django.utils.translation import gettext_lazy as _
from django.views.generic import UpdateView, TemplateView, CreateView
from django_tables2 import MultiTableMixin
from member.models import Membership
from .backends import PermissionBackend
@ -35,11 +36,9 @@ class ProtectQuerysetMixin:
try:
return super().get_object(queryset)
except Http404 as e:
try:
super().get_object(self.get_queryset(filter_permissions=False))
raise PermissionDenied()
except Http404:
if self.get_queryset(filter_permissions=False).count() == self.get_queryset().count():
raise e
raise PermissionDenied()
def get_form(self, form_class=None):
form = super().get_form(form_class)
@ -107,10 +106,31 @@ class ProtectedCreateView(LoginRequiredMixin, CreateView):
return super().dispatch(request, *args, **kwargs)
class RightsView(TemplateView):
class RightsView(MultiTableMixin, TemplateView):
template_name = "permission/all_rights.html"
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):
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()]
if self.request.user.is_authenticated:
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()
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-")
tables = context["tables"]
for name, table in zip(["special_memberships_table", "superusers"], tables):
context[name] = table
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.edit import FormMixin
from django_tables2 import SingleTableView
from api.viewsets import is_regex
from member.forms import ProfileForm
from member.models import Membership, Club
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"]:
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(
Q(first_name__iregex=pattern)
| Q(last_name__iregex=pattern)
| Q(profile__section__iregex=pattern)
| Q(username__iregex="^" + pattern)
Q(**{f"first_name{suffix}": pattern})
| Q(**{f"last_name{suffix}": pattern})
| Q(**{f"profile__section{suffix}": pattern})
| Q(**{f"username{suffix_username}": prefix + pattern})
)
return qs

View File

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

View File

@ -37,6 +37,7 @@ class InvoiceTable(tables.Table):
args=[A('id')],
verbose_name=_("delete"),
text=_("Delete"),
orderable=False,
attrs={
'th': {
'id': 'delete-membership-header'
@ -70,6 +71,7 @@ class RemittanceTable(tables.Table):
verbose_name=_("View"),
args=[A("pk")],
text=_("View"),
orderable=False,
attrs={
'a': {'class': 'btn btn-primary'}
}, )
@ -97,6 +99,7 @@ class SpecialTransactionTable(tables.Table):
verbose_name=_("Remittance"),
args=[A("specialtransactionproxy__pk")],
text=_("Add"),
orderable=False,
attrs={
'a': {'class': 'btn btn-primary'}
}, )
@ -105,6 +108,7 @@ class SpecialTransactionTable(tables.Table):
verbose_name=_("Remittance"),
args=[A("specialtransactionproxy__pk")],
text=_("Remove"),
orderable=False,
attrs={
'a': {'class': 'btn btn-primary btn-danger'}
}, )
@ -130,10 +134,12 @@ class SogeCreditTable(tables.Table):
amount = tables.Column(
verbose_name=_("Amount"),
orderable=False,
)
valid = tables.Column(
verbose_name=_("Valid"),
orderable=False,
)
def render_amount(self, value):

View File

@ -109,7 +109,7 @@
\renewcommand{\headrulewidth}{0pt}
\cfoot{
\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.base import View, TemplateView
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_kfet.settings.base import BASE_DIR
from permission.backends import PermissionBackend
@ -251,21 +252,26 @@ class RemittanceCreateView(ProtectQuerysetMixin, ProtectedCreateView):
def get_context_data(self, **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())
return context
class RemittanceListView(LoginRequiredMixin, TemplateView):
class RemittanceListView(LoginRequiredMixin, MultiTableMixin, TemplateView):
"""
List existing Remittances
"""
template_name = "treasury/remittance_list.html"
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):
# Check that the 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."))
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):
context = super().get_context_data(**kwargs)
opened_remittances = RemittanceTable(
data=Remittance.objects.filter(closed=False).filter(
PermissionBackend.filter_queryset(self.request, Remittance, "view")).all(),
prefix="opened-remittances-",
)
opened_remittances.paginate(page=self.request.GET.get("opened-remittances-page", 1), per_page=10)
context["opened_remittances"] = opened_remittances
closed_remittances = RemittanceTable(
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
tables = context["tables"]
names = [
"opened_remittances",
"closed_remittances",
"special_transactions_no_remittance",
"special_transactions_with_remittance",
]
for name, table in zip(names, tables):
context[name] = table
return context
class RemittanceUpdateView(ProtectQuerysetMixin, LoginRequiredMixin, UpdateView):
class RemittanceUpdateView(ProtectQuerysetMixin, LoginRequiredMixin, SingleTableMixin, UpdateView):
"""
Update Remittance
"""
@ -325,19 +319,18 @@ class RemittanceUpdateView(ProtectQuerysetMixin, LoginRequiredMixin, UpdateView)
form_class = RemittanceForm
extra_context = {"title": _("Update a remittance")}
table_class = SpecialTransactionTable
context_table_name = "special_transactions"
def get_success_url(self):
return reverse_lazy('treasury:remittance_list')
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
def get_table_data(self):
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(
PermissionBackend.filter_queryset(self.request, Remittance, "view")).all()
context["special_transactions"] = SpecialTransactionTable(
data=data,
exclude=('remittance_add', 'remittance_remove', ) if self.object.closed else ('remittance_add', ))
return context
def get_table_kwargs(self):
return {"exclude": ('remittance_add', 'remittance_remove', ) if self.object.closed else ('remittance_add', )}
class LinkTransactionToRemittanceView(ProtectQuerysetMixin, LoginRequiredMixin, UpdateView):
@ -411,11 +404,16 @@ class SogeCreditListView(LoginRequiredMixin, ProtectQuerysetMixin, SingleTableVi
if "search" in self.request.GET:
pattern = self.request.GET["search"]
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(
Q(user__first_name__iregex=pattern)
| Q(user__last_name__iregex=pattern)
| Q(user__note__alias__name__iregex="^" + pattern)
| Q(user__note__alias__normalized_name__iregex="^" + Alias.normalize(pattern))
Q(**{f"user__first_name{suffix}": pattern})
| Q(**{f"user__last_name{suffix}": pattern})
| Q(**{f"user__note__alias__name{suffix_alias}": prefix + 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"]:

View File

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

View File

@ -1,13 +1,14 @@
# Copyright (C) 2018-2024 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later
from bootstrap_datepicker_plus.widgets import DatePickerInput
from django import forms
from django.contrib.auth.models import User
from django.db.models import Q
from django.forms import CheckboxSelectMultiple
from django.utils.translation import gettext_lazy as _
from note.models import NoteSpecial, NoteUser
from note_kfet.inputs import AmountInput, DatePickerInput, Autocomplete, ColorWidget
from note_kfet.inputs import AmountInput, Autocomplete, ColorWidget
from ..models import WEIClub, WEIRegistration, Bus, BusTeam, WEIMembership, WEIRole

View File

@ -82,7 +82,7 @@ WORDS = {
5: "La quoi ?"
}],
"kokarde": ["Qu'est-ce que le mot Kokarde t'évoque ?", {
1: "Vraiment pas mon truc les soirées",
1: "Vraiment pas mon truc les soirées...",
2: "Bof, je viens pour manger et je repars aussitôt",
3: "Je kiffe, good vibes",
4: "Perso, je ne m'arrêterai pas de danser sur la piste !",
@ -117,15 +117,15 @@ WORDS = {
5: "Je pourrais en faire à n'importe qui. Pourquoi ne pas créer le club Câl[ENS] ?"
}],
"vomi": ["Quel est ton rapport au vomi ?", {
1: "C'est compliqué",
1: "C'est compliqué...",
2: "Jamais je ne vomis mais je nettoie quand mes potes vomissent",
3: "Jamais je ne vomis et jamais je ne nettoie celui de quelqu'un d'autre",
4: "Je vomis quelquefois, ça arrive, faites pas cette tête, mais je fins toujours par nettoyer !",
5: "Je vomis à chaque soirée et ce n'est jamais moi qui nettoie"
}],
"kfet": ["Qu'est ce que la Kfet t'évoque ?", {
1: "La Kfet, quel lieu de dépravé⋅es sérieux",
2: "C'est un endroit à l'hygiène plus que douteuse",
1: "La Kfet, quel lieu de dépravé⋅es sérieux...",
2: "C'est un endroit à l'hygiène plus que douteuse...",
3: "Téma les prix des boissons et des snacks, c'est aberrant !",
4: "En vrai, c'est cool, petit billard, petit canapé, chill !",
5: "Banger, j'y reste jusqu'à la fin de mes jours"
@ -147,7 +147,7 @@ WORDS = {
"scolarite": ["Comment tu vois ton cursus à l'ENS ?", {
1: "La tranquillité et le travail",
2: "On va s'amuser tout en bossant",
3: "Ça va profiter et réviser au dernier moment pour les exams",
3: "Ça va profiter et réviser au dernier moment pour les exams...",
4: "Nous festoierons sans songer aux conséquences",
5: "Je ne vois qu'une seule issue : la débauche"
}]

View File

@ -439,7 +439,7 @@ class TestWEIRegistration(TestCase):
emergency_contact_phone='+33123456789',
))
self.assertEqual(response.status_code, 200)
self.assertTrue("This user can&#39;t be in her/his first year since he/she has already participated to a WEI."
self.assertTrue("This user can&#x27;t be in her/his first year since he/she has already participated to a WEI."
in str(response.context["form"].errors))
# Check that if the WEI is started, we can't register anyone
@ -635,7 +635,7 @@ class TestWEIRegistration(TestCase):
))
self.assertEqual(response.status_code, 200)
self.assertFalse(response.context["form"].is_valid())
self.assertTrue("This team doesn&#39;t belong to the given bus." in str(response.context["form"].errors))
self.assertTrue("This team doesn&#x27;t belong to the given bus." in str(response.context["form"].errors))
response = self.client.post(reverse("wei:validate_registration", kwargs=dict(pk=self.registration.pk)), dict(
roles=[WEIRole.objects.get(name="GC WEI").id],

View File

@ -22,7 +22,8 @@ from django.views import View
from django.views.generic import DetailView, UpdateView, RedirectView, TemplateView
from django.utils.translation import gettext_lazy as _
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 note.models import Transaction, NoteClub, Alias, SpecialTransaction, NoteSpecial
from note.tables import HistoryTable
@ -100,7 +101,7 @@ class WEICreateView(ProtectQuerysetMixin, ProtectedCreateView):
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
"""
@ -108,34 +109,40 @@ class WEIDetailView(ProtectQuerysetMixin, LoginRequiredMixin, DetailView):
context_object_name = "club"
extra_context = {"title": _("WEI Detail")}
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
club = context["club"]
tables = [
lambda data: HistoryTable(data, prefix="history-"),
lambda data: WEIMembershipTable(data, prefix="membership-"),
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)) \
.filter(PermissionBackend.filter_queryset(self.request, Transaction, "view")) \
.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=club,
date_end__gte=date.today(),
).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(
PermissionBackend.filter_queryset(self.request, WEIRegistration, "view")).filter(
membership=None,
wei=club
)
pre_registrations_table = WEIRegistrationTable(data=pre_registrations, prefix="pre-registration-")
pre_registrations_table.paginate(per_page=20, page=self.request.GET.get('pre-registration-page', 1))
context['pre_registrations'] = pre_registrations_table
buses = Bus.objects.filter(PermissionBackend.filter_queryset(self.request, Bus, "view")) \
.filter(wei=self.object).annotate(count=Count("memberships")).order_by("name")
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)
if my_registration.exists():
@ -144,11 +151,6 @@ class WEIDetailView(ProtectQuerysetMixin, LoginRequiredMixin, DetailView):
my_registration = None
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()
if random_user is None:
@ -219,13 +221,18 @@ class WEIMembershipsView(ProtectQuerysetMixin, LoginRequiredMixin, SingleTableVi
if not pattern:
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(
Q(user__first_name__iregex=pattern)
| Q(user__last_name__iregex=pattern)
| Q(user__note__alias__name__iregex="^" + pattern)
| Q(user__note__alias__normalized_name__iregex="^" + Alias.normalize(pattern))
| Q(bus__name__iregex=pattern)
| Q(team__name__iregex=pattern)
Q(**{f"user__first_name{suffix}": pattern})
| Q(**{f"user__last_name{suffix}": pattern})
| Q(**{f"user__note__alias__name{suffix_alias}": prefix + pattern})
| Q(**{f"user__note__alias__normalized_name{suffix_alias}": prefix + Alias.normalize(pattern)})
| Q(**{f"bus__name{suffix}": pattern})
| Q(**{f"team__name{suffix}": pattern})
)
return qs
@ -255,11 +262,16 @@ class WEIRegistrationsView(ProtectQuerysetMixin, LoginRequiredMixin, SingleTable
pattern = self.request.GET.get("search", "")
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(
Q(user__first_name__iregex=pattern)
| Q(user__last_name__iregex=pattern)
| Q(user__note__alias__name__iregex="^" + pattern)
| Q(user__note__alias__normalized_name__iregex="^" + Alias.normalize(pattern))
Q(**{f"user__first_name{suffix}": pattern})
| Q(**{f"user__last_name{suffix}": pattern})
| Q(**{f"user__note__alias__name{suffix_alias}": prefix + pattern})
| Q(**{f"user__note__alias__normalized_name{suffix_alias}": prefix + Alias.normalize(pattern)})
)
return qs

View File

@ -8,7 +8,7 @@ peuvent être diffusées via des calendriers ou la mailing list d'événements.
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é
~~~~~~~~~~~~~~~~
@ -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``
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
~~~~~~
@ -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
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.
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

@ -32,7 +32,7 @@ Applications indispensables
* `Note <note>`_ :
Les notes associées à des utilisateur⋅rices ou des clubs.
* `Activity <activity>`_ :
La gestion des activités (créations, gestion, entrées,)
La gestion des activités (créations, gestion, entrées, ...)
* `Permission <permission>`_ :
Backend de droits, limites les pouvoirs des utilisateur⋅rices
* `API <../api>`_ :
@ -64,9 +64,9 @@ Applications facultatives
* ``cas-server``
Serveur central d'authentification, permet d'utiliser son compte de la NoteKfet2020 pour se connecter à d'autre application ayant intégrer un client.
* `Scripts <https://gitlab.crans.org/bde/nk20-scripts>`_
Ensemble de commande `./manage.py` pour la gestion de la note: import de données, verification d'intégrité, etc
Ensemble de commande `./manage.py` pour la gestion de la note: import de données, verification d'intégrité, etc...
* `Treasury <treasury>`_ :
Interface de gestion pour les trésorièr⋅es, émission de factures, remises de chèque, statistiques ...
Interface de gestion pour les trésorièr⋅es, émission de factures, remises de chèque, statistiques...
* `WEI <wei>`_ :
Interface de gestion du WEI.

View File

@ -43,7 +43,7 @@ l'utilisateur⋅rice, utiles pour l'adhésion au BDE :
* ``address`` : ``CharField``, adresse physique de l'utilisateur⋅rice
* ``paid`` : ``BooleanField``, indique si l'utilisateur⋅rice normalien⋅ne est rémunéré⋅e ou non (utile pour différencier les montants d'adhésion aux clubs)
* ``phone_number`` : ``CharField``, numéro de téléphone de l'utilisateur⋅rice
* ``section`` : ``CharField``, section de l'ENS à laquelle appartient l'utilisateur⋅rice (exemple : 1A0,)
* ``section`` : ``CharField``, section de l'ENS à laquelle appartient l'utilisateur⋅rice (exemple : 1A0, ...)
Clubs
~~~~~
@ -101,7 +101,7 @@ Adhésions
La Note Kfet offre la possibilité aux clubs de gérer l'adhésion de leurs membres. En plus de réguler les cotisations
des adhérent⋅es, des permissions sont octroyées sur la note en fonction des rôles au sein des clubs. Un rôle est une
fonction occupée au sein d'un club (Trésorièr⋅e de club, président⋅e de club, GC Kfet, Res[pot], respo info,).
fonction occupée au sein d'un club (Trésorièr⋅e de club, président⋅e de club, GC Kfet, Res[pot], respo info, ...).
Une adhésion attribue à un⋅e adhérent⋅e ses rôles. Les rôles fournissent les permissions. Par exemple, læ trésorièr⋅e d'un
club a le droit de faire des transferts de et vers la note du club, tant que la source reste au-dessus de -50 €.
Une adhésion est considérée comme valide si la date du jour est comprise (au sens large) entre les dates de début et

View File

@ -49,7 +49,7 @@ Une fois l'inscription validée, détail de ce qu'il se passe :
lui octroyant un faible nombre de permissions de base, telles que la visualisation de son compte.
* On adhère la personne au club Kfet si cela est demandé, l'adhésion commence aujourd'hui. Iel dispose d'un unique rôle :
« Adhérent⋅e Kfet » , lui octroyant un nombre un peu plus conséquent de permissions basiques, telles que la possibilité de
faire des transactions, d'accéder aux activités, au WEI,
faire des transactions, d'accéder aux activités, au WEI, ...
* Si læ nouvelleau membre a indiqué avoir ouvert un compte à la société générale, alors les transactions sont invalidées,
la note n'est pas débitée (commence alors à 0 €).

View File

@ -177,11 +177,13 @@ Contributeur⋅rices
Liste des contributeur⋅rices majeur⋅es, par ordre alphabétique :
* Pierre-André « PAC » COMBY
* Emmy « ÿnérant » D'ANELLO
* Benjamin « esum » GRAILLOT
* Alexandre « erdnaxe » IOOSS
* Nicolas « nicomarg » MARGULIES
* bleizi
* erdnaxe
* esum
* korenst1
* nicomarg
* PAC
* ÿnérant
Hébergement

View File

@ -3431,8 +3431,8 @@ msgid "FAQ (FR)"
msgstr "FAQ (FR)"
#: note_kfet/templates/base_search.html:15
msgid "Search by attribute such as name"
msgstr "Suche nach Attributen wie Name"
msgid "Search by attribute such as name..."
msgstr "Suche nach Attributen wie Name..."
#: note_kfet/templates/base_search.html:23
msgid "There is no results."

View File

@ -3381,8 +3381,8 @@ msgid "FAQ (FR)"
msgstr "FAQ (FR)"
#: note_kfet/templates/base_search.html:15
msgid "Search by attribute such as name"
msgstr "Buscar con atributo, como el nombre"
msgid "Search by attribute such as name..."
msgstr "Buscar con atributo, como el nombre..."
#: note_kfet/templates/base_search.html:23
msgid "There is no results."

View File

@ -20,6 +20,7 @@ msgstr ""
#: apps/activity/apps.py:10 apps/activity/models.py:127
#: apps/activity/models.py:167
#: apps/activity/models.py:323
msgid "activity"
msgstr "activité"
@ -238,6 +239,16 @@ msgstr "invité·e·s"
msgid "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
msgid "The activity is currently open."
msgstr "Cette activité est actuellement ouverte."
@ -1660,8 +1671,8 @@ msgstr "Consommer"
#: apps/note/templates/note/conso_form.html:43
#: apps/note/templates/note/transaction_form.html:69
#: apps/note/templates/note/transaction_form.html:96
msgid "Name or alias"
msgstr "Pseudo ou alias"
msgid "Name or alias..."
msgstr "Pseudo ou alias..."
#: apps/note/templates/note/conso_form.html:53
msgid "Select consumptions"
@ -1766,8 +1777,8 @@ msgid "Current price"
msgstr "Prix actuel"
#: apps/note/templates/note/transactiontemplate_list.html:13
msgid "Name of the button"
msgstr "Nom du bouton"
msgid "Name of the button..."
msgstr "Nom du bouton..."
#: apps/note/templates/note/transactiontemplate_list.html:15
msgid "New button"
@ -3119,8 +3130,8 @@ msgstr ""
"coût d'adhésion."
#: apps/wei/templates/wei/weimembership_list.html:27
msgid "View unvalidated registrations"
msgstr "Voir les inscriptions non validées"
msgid "View unvalidated registrations..."
msgstr "Voir les inscriptions non validées..."
#: apps/wei/templates/wei/weiregistration_confirm_delete.html:16
msgid "This registration is already validated and can't be deleted."
@ -3140,8 +3151,8 @@ msgid "There is no pre-registration found with this pattern."
msgstr "Il n'y a pas de pré-inscription en attente avec cette entrée."
#: apps/wei/templates/wei/weiregistration_list.html:27
msgid "View validated memberships…"
msgstr "Voir les adhésions validées"
msgid "View validated membershipis..."
msgstr "Voir les adhésions validées..."
#: apps/wei/views.py:58
msgid "Search WEI"
@ -3402,8 +3413,8 @@ msgid "Charte Info (FR)"
msgstr "Charte Info (FR)"
#: note_kfet/templates/base_search.html:15
msgid "Search by attribute such as name"
msgstr "Chercher par un attribut tel que le nom"
msgid "Search by attribute such as name..."
msgstr "Chercher par un attribut tel que le nom..."
#: note_kfet/templates/base_search.html:23
msgid "There is no results."

View File

@ -17,6 +17,14 @@ msgstr ""
"Content-Transfer-Encoding: 8bit\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
msgid "Alias successfully added"
msgstr "Alias ajouté avec succès"

View File

@ -25,8 +25,8 @@ admin_site.register(Site, SiteAdmin)
# Add external apps model
if "oauth2_provider" in settings.INSTALLED_APPS:
from oauth2_provider.admin import Application, ApplicationAdmin, Grant, \
GrantAdmin, AccessToken, AccessTokenAdmin, RefreshToken, RefreshTokenAdmin
from oauth2_provider.admin import ApplicationAdmin, GrantAdmin, AccessTokenAdmin, RefreshTokenAdmin
from oauth2_provider.models import Application, Grant, AccessToken, RefreshToken
admin_site.register(Application, ApplicationAdmin)
admin_site.register(Grant, GrantAdmin)
admin_site.register(AccessToken, AccessTokenAdmin)

View File

@ -68,264 +68,3 @@ class ColorWidget(Widget):
def value_from_datadict(self, data, files, name):
val = super().value_from_datadict(data, files, name)
return int(val[1:], 16)
"""
The remaining of this file comes from the project `django-bootstrap-datepicker-plus` available on Github:
https://github.com/monim67/django-bootstrap-datepicker-plus
This is distributed under Apache License 2.0.
This adds datetime pickers with bootstrap.
"""
"""Contains Base Date-Picker input class for widgets of this package."""
class DatePickerDictionary:
"""Keeps track of all date-picker input classes."""
_i = 0
items = dict()
@classmethod
def generate_id(cls):
"""Return a unique ID for each date-picker input class."""
cls._i += 1
return 'dp_%s' % cls._i
class BasePickerInput(DateTimeBaseInput):
"""Base Date-Picker input class for widgets of this package."""
template_name = 'bootstrap_datepicker_plus/date-picker.html'
picker_type = 'DATE'
format = '%Y-%m-%d'
config = {}
_default_config = {
'id': None,
'picker_type': None,
'linked_to': None,
'options': {} # final merged options
}
options = {} # options extended by user
options_param = {} # options passed as parameter
_default_options = {
'showClose': True,
'showClear': True,
'showTodayButton': True,
"locale": "fr",
}
# source: https://github.com/tutorcruncher/django-bootstrap3-datetimepicker
# file: /blob/31fbb09/bootstrap3_datetime/widgets.py#L33
format_map = (
('DDD', r'%j'),
('DD', r'%d'),
('MMMM', r'%B'),
('MMM', r'%b'),
('MM', r'%m'),
('YYYY', r'%Y'),
('YY', r'%y'),
('HH', r'%H'),
('hh', r'%I'),
('mm', r'%M'),
('ss', r'%S'),
('a', r'%p'),
('ZZ', r'%z'),
)
class Media:
"""JS/CSS resources needed to render the date-picker calendar."""
js = (
'https://cdnjs.cloudflare.com/ajax/libs/moment.js/2.9.0/'
'moment-with-locales.min.js',
'https://cdnjs.cloudflare.com/ajax/libs/bootstrap-datetimepicker/'
'4.17.47/js/bootstrap-datetimepicker.min.js',
'bootstrap_datepicker_plus/js/datepicker-widget.js'
)
css = {'all': (
'https://cdnjs.cloudflare.com/ajax/libs/bootstrap-datetimepicker/'
'4.17.47/css/bootstrap-datetimepicker.css',
'bootstrap_datepicker_plus/css/datepicker-widget.css'
), }
@classmethod
def format_py2js(cls, datetime_format):
"""Convert python datetime format to moment datetime format."""
for js_format, py_format in cls.format_map:
datetime_format = datetime_format.replace(py_format, js_format)
return datetime_format
@classmethod
def format_js2py(cls, datetime_format):
"""Convert moment datetime format to python datetime format."""
for js_format, py_format in cls.format_map:
datetime_format = datetime_format.replace(js_format, py_format)
return datetime_format
def __init__(self, attrs=None, format=None, options=None):
"""Initialize the Date-picker widget."""
self.format_param = format
self.options_param = options if options else {}
self.config = self._default_config.copy()
self.config['id'] = DatePickerDictionary.generate_id()
self.config['picker_type'] = self.picker_type
self.config['options'] = self._calculate_options()
attrs = attrs if attrs else {}
if 'class' not in attrs:
attrs['class'] = 'form-control'
super().__init__(attrs, self._calculate_format())
def _calculate_options(self):
"""Calculate and Return the options."""
_options = self._default_options.copy()
_options.update(self.options)
if self.options_param:
_options.update(self.options_param)
return _options
def _calculate_format(self):
"""Calculate and Return the datetime format."""
_format = self.format_param if self.format_param else self.format
if self.config['options'].get('format'):
_format = self.format_js2py(self.config['options'].get('format'))
else:
self.config['options']['format'] = self.format_py2js(_format)
return _format
def get_context(self, name, value, attrs):
"""Return widget context dictionary."""
context = super().get_context(
name, value, attrs)
context['widget']['attrs']['dp_config'] = json_dumps(self.config)
return context
def start_of(self, event_id):
"""
Set Date-Picker as the start-date of a date-range.
Args:
- event_id (string): User-defined unique id for linking two fields
"""
DatePickerDictionary.items[str(event_id)] = self
return self
def end_of(self, event_id, import_options=True):
"""
Set Date-Picker as the end-date of a date-range.
Args:
- event_id (string): User-defined unique id for linking two fields
- import_options (bool): inherit options from start-date input,
default: TRUE
"""
event_id = str(event_id)
if event_id in DatePickerDictionary.items:
linked_picker = DatePickerDictionary.items[event_id]
self.config['linked_to'] = linked_picker.config['id']
if import_options:
backup_moment_format = self.config['options']['format']
self.config['options'].update(linked_picker.config['options'])
self.config['options'].update(self.options_param)
if self.format_param or 'format' in self.options_param:
self.config['options']['format'] = backup_moment_format
else:
self.format = linked_picker.format
# Setting useCurrent is necessary, see following issue
# https://github.com/Eonasdan/bootstrap-datetimepicker/issues/1075
self.config['options']['useCurrent'] = False
self._link_to(linked_picker)
else:
raise KeyError(
'start-date not specified for event_id "%s"' % event_id)
return self
def _link_to(self, linked_picker):
"""
Executed when two date-inputs are linked together.
This method for sub-classes to override to customize the linking.
"""
pass
class DatePickerInput(BasePickerInput):
"""
Widget to display a Date-Picker Calendar on a DateField property.
Args:
- attrs (dict): HTML attributes of rendered HTML input
- format (string): Python DateTime format eg. "%Y-%m-%d"
- options (dict): Options to customize the widget, see README
"""
picker_type = 'DATE'
format = '%Y-%m-%d'
format_key = 'DATE_INPUT_FORMATS'
class TimePickerInput(BasePickerInput):
"""
Widget to display a Time-Picker Calendar on a TimeField property.
Args:
- attrs (dict): HTML attributes of rendered HTML input
- format (string): Python DateTime format eg. "%Y-%m-%d"
- options (dict): Options to customize the widget, see README
"""
picker_type = 'TIME'
format = '%H:%M'
format_key = 'TIME_INPUT_FORMATS'
template_name = 'bootstrap_datepicker_plus/time_picker.html'
class DateTimePickerInput(BasePickerInput):
"""
Widget to display a DateTime-Picker Calendar on a DateTimeField property.
Args:
- attrs (dict): HTML attributes of rendered HTML input
- format (string): Python DateTime format eg. "%Y-%m-%d"
- options (dict): Options to customize the widget, see README
"""
picker_type = 'DATETIME'
format = '%Y-%m-%d %H:%M'
format_key = 'DATETIME_INPUT_FORMATS'
class MonthPickerInput(BasePickerInput):
"""
Widget to display a Month-Picker Calendar on a DateField property.
Args:
- attrs (dict): HTML attributes of rendered HTML input
- format (string): Python DateTime format eg. "%Y-%m-%d"
- options (dict): Options to customize the widget, see README
"""
picker_type = 'MONTH'
format = '01/%m/%Y'
format_key = 'DATE_INPUT_FORMATS'
class YearPickerInput(BasePickerInput):
"""
Widget to display a Year-Picker Calendar on a DateField property.
Args:
- attrs (dict): HTML attributes of rendered HTML input
- format (string): Python DateTime format eg. "%Y-%m-%d"
- options (dict): Options to customize the widget, see README
"""
picker_type = 'YEAR'
format = '01/01/%Y'
format_key = 'DATE_INPUT_FORMATS'
def _link_to(self, linked_picker):
"""Customize the options when linked with other date-time input"""
yformat = self.config['options']['format'].replace('-01-01', '-12-31')
self.config['options']['format'] = yformat

View File

@ -40,8 +40,9 @@ INSTALLED_APPS = [
# External apps
'bootstrap_datepicker_plus',
'colorfield',
'crispy_bootstrap4',
'crispy_forms',
'django_htcpcp_tea',
# 'django_htcpcp_tea',
'django_tables2',
'mailer',
'phonenumber_field',
@ -90,12 +91,14 @@ MIDDLEWARE = [
'django.middleware.clickjacking.XFrameOptionsMiddleware',
'django.middleware.locale.LocaleMiddleware',
'django.contrib.sites.middleware.CurrentSiteMiddleware',
'django_htcpcp_tea.middleware.HTCPCPTeaMiddleware',
'note_kfet.middlewares.SessionMiddleware',
'note_kfet.middlewares.LoginByIPMiddleware',
'note_kfet.middlewares.TurbolinksMiddleware',
'note_kfet.middlewares.ClacksMiddleware',
]
if "django_htcpcp_tea" in INSTALLED_APPS:
MIDDLEWARE.append('django_htcpcp_tea.middleware.HTCPCPTeaMiddleware')
ROOT_URLCONF = 'note_kfet.urls'
@ -261,6 +264,7 @@ OAUTH2_PROVIDER = {
'SCOPES_BACKEND_CLASS': 'permission.scopes.PermissionScopes',
'OAUTH2_VALIDATOR_CLASS': "permission.scopes.PermissionOAuth2Validator",
'REFRESH_TOKEN_EXPIRE_SECONDS': timedelta(days=14),
'PKCE_REQUIRED': False, # PKCE (fix a breaking change of django-oauth-toolkit 2.0.0)
}
# Take control on how widget templates are sourced
@ -274,6 +278,7 @@ LOGIN_REDIRECT_URL = '/'
SESSION_COOKIE_AGE = 60 * 60 * 3
# Use Crispy Bootstrap4 theme
CRISPY_ALLOWED_TEMPLATE_PACKS = 'bootstrap4'
CRISPY_TEMPLATE_PACK = 'bootstrap4'
# Use Django Table2 Bootstrap4 theme
@ -295,3 +300,6 @@ PHONENUMBER_DEFAULT_REGION = 'FR'
# We add custom information to CAS, in order to give a normalized name to other services
CAS_AUTH_CLASS = 'member.auth.CustomAuthUser'
# Default field for primary key
DEFAULT_AUTO_FIELD = "django.db.models.AutoField"

View File

@ -8,7 +8,7 @@ SPDX-License-Identifier: GPL-3.0-or-later
{% if widget.value != None and widget.value != "" %}value="{{ widget.value }}"{% endif %}
name="{{ widget.name }}_name" autocomplete="off"
{% for name, value in widget.attrs.items %}
{% ifnotequal value False %}{{ name }}{% ifnotequal value True %}="{{ value|stringformat:'s' }}"{% endifnotequal %}{% endifnotequal %}
{% if value is not False %}{{ name }}{% if value is not True %}="{{ value|stringformat:'s' }}"{% endif %}{% endif %}
{% endfor %}
aria-describedby="{{widget.attrs.id}}_tooltip">
{% if widget.resetable %}

View File

@ -12,7 +12,7 @@ SPDX-License-Identifier: GPL-3.0-or-later
</h3>
<div class="card-body">
<input id="searchbar" type="text" class="form-control"
placeholder="{% trans "Search by attribute such as name" %}">
placeholder="{% trans "Search by attribute such as name..." %}">
</div>
<div id="dynamic-table">
{% if table.data %}

View File

@ -30,9 +30,6 @@ urlpatterns = [
path('accounts/', include('django.contrib.auth.urls')),
path('api/', include('api.urls')),
path('permission/', include('permission.urls')),
# Make coffee
path('coffee/', include('django_htcpcp_tea.urls')),
]
# During development, serve static and media files
@ -57,6 +54,11 @@ if "debug_toolbar" in settings.INSTALLED_APPS:
path('__debug__/', include(debug_toolbar.urls)),
] + urlpatterns
if "django_htcpcp_tea" in settings.INSTALLED_APPS:
# Make coffee
urlpatterns.append(
path('coffee/', include('django_htcpcp_tea.urls'))
)
handler400 = bad_request
handler403 = permission_denied

View File

@ -1,19 +1,20 @@
beautifulsoup4~=4.7.1
Django~=2.2.15
django-bootstrap-datepicker-plus~=3.0.5
django-cas-server~=1.2.0
django-colorfield~=0.3.2
django-crispy-forms~=1.7.2
django-extensions>=2.1.4
django-filter~=2.1
django-htcpcp-tea~=0.3.1
django-mailer~=2.0.1
django-oauth-toolkit~=1.3.3
django-phonenumber-field~=5.0.0
django-polymorphic>=2.0.3,<3.0.0
djangorestframework>=3.9.0,<3.13.0
django-rest-polymorphic~=0.1.9
django-tables2~=2.3.1
python-memcached~=1.59
phonenumbers~=8.9.10
Pillow>=5.4.1
beautifulsoup4~=4.12.3
crispy-bootstrap4~=2023.1
Django~=4.2.9
django-bootstrap-datepicker-plus~=5.0.5
#django-cas-server~=2.0.0
django-colorfield~=0.11.0
django-crispy-forms~=2.1.0
django-extensions>=3.2.3
django-filter~=23.5
#django-htcpcp-tea~=0.8.1
django-mailer~=2.3.1
django-oauth-toolkit~=2.3.0
django-phonenumber-field~=7.3.0
django-polymorphic~=3.1.0
djangorestframework~=3.14.0
django-rest-polymorphic~=0.1.10
django-tables2~=2.7.0
python-memcached~=1.62
phonenumbers~=8.13.28
Pillow>=10.2.0

16
tox.ini
View File

@ -1,13 +1,13 @@
[tox]
envlist =
# Debian Buster Python
py37-django22
# Ubuntu 20.04 Python
py38-django22
# Debian Bullseye Python
py39-django22
py39-django42
# Ubuntu 22.04 Python
py310-django42
# Debian Bookworm Python
py311-django42
linters
skipsdist = True
@ -51,4 +51,4 @@ max-complexity = 15
max-line-length = 160
import-order-style = google
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