mirror of
https://gitlab.crans.org/bde/nk20
synced 2025-07-01 05:21:15 +02:00
Compare commits
52 Commits
e479e1e3a4
...
sheets
Author | SHA1 | Date | |
---|---|---|---|
51d60d064c
|
|||
45334e4e02
|
|||
5174c84b33
|
|||
51e5e3669e
|
|||
44994a3ae7
|
|||
ba017c38c0
|
|||
761fc170eb
|
|||
ac23d7eb54
|
|||
40e7415062
|
|||
319405d2b1
|
|||
633ab88b04
|
|||
e29b42eecc
|
|||
dc69faaf1d
|
|||
442a5c5e36
|
|||
7ab0fec3bc
|
|||
bd4fb23351 | |||
ee22e9b3b6 | |||
19ae616fb4 | |||
b7657ec362 | |||
4d03d9460d | |||
3633f66a87 | |||
d43fbe7ac6 | |||
df5f9b5f1e | |||
4161248bff
|
|||
58136f3c48
|
|||
d9b4e0a9a9
|
|||
8563a8d235
|
|||
5f69232560 | |||
d3273e9ee2
|
|||
4e30f805a7 | |||
546e422e64
|
|||
9048a416df
|
|||
8578bd743c
|
|||
45a10dad00
|
|||
18a1282773
|
|||
132afc3d15
|
|||
6bf16a181a
|
|||
e20df82346
|
|||
1eb72044c2 | |||
f88eae924c
|
|||
4b6e3ba546
|
|||
bf0fe3479f | |||
45ba4f9537
|
|||
b204805ce2
|
|||
2f28e34cec
|
|||
9c8ea2cd41
|
|||
41289857b2 | |||
28a8792c9f
|
|||
58cafad032
|
|||
7848cd9cc2
|
|||
d18ccfac23
|
|||
ca2b9f061c |
@ -1,8 +1,8 @@
|
||||
# NoteKfet 2020
|
||||
|
||||
[](https://www.gnu.org/licenses/gpl-3.0.txt)
|
||||
[](https://gitlab.crans.org/bde/nk20/commits/master)
|
||||
[](https://gitlab.crans.org/bde/nk20/commits/master)
|
||||
[](https://gitlab.crans.org/bde/nk20/commits/main)
|
||||
[](https://gitlab.crans.org/bde/nk20/commits/main)
|
||||
|
||||
## Table des matières
|
||||
|
||||
|
@ -7,7 +7,7 @@
|
||||
prompt: "Password of the database (leave it blank to skip database init)"
|
||||
private: yes
|
||||
vars:
|
||||
mirror: mirror.crans.org
|
||||
mirror: eclats.crans.org
|
||||
roles:
|
||||
- 1-apt-basic
|
||||
- 2-nk20
|
||||
|
@ -1,7 +1,7 @@
|
||||
---
|
||||
note:
|
||||
server_name: note.crans.org
|
||||
git_branch: master
|
||||
git_branch: main
|
||||
serve_static: true
|
||||
cron_enabled: true
|
||||
email: notekfet2020@lists.crans.org
|
||||
|
@ -1,14 +1,15 @@
|
||||
---
|
||||
- name: Add buster-backports to apt sources
|
||||
- name: Add buster-backports to apt sources if needed
|
||||
apt_repository:
|
||||
repo: deb http://{{ mirror }}/debian buster-backports main
|
||||
state: present
|
||||
when: ansible_facts['distribution'] == "Debian"
|
||||
when:
|
||||
- ansible_distribution == "Debian"
|
||||
- ansible_distribution_major_version | int == 10
|
||||
|
||||
- name: Install note_kfet APT dependencies
|
||||
apt:
|
||||
update_cache: true
|
||||
default_release: "{{ 'buster-backports' if ansible_facts['distribution'] == 'Debian' }}"
|
||||
install_recommends: false
|
||||
name:
|
||||
# Common tools
|
||||
|
@ -7,8 +7,11 @@ from django.contrib.auth.models import User
|
||||
from django.utils import timezone
|
||||
from rest_framework import serializers
|
||||
from member.api.serializers import ProfileSerializer, MembershipSerializer
|
||||
from member.models import Membership
|
||||
from note.api.serializers import NoteSerializer
|
||||
from note.models import Alias
|
||||
from note_kfet.middlewares import get_current_request
|
||||
from permission.backends import PermissionBackend
|
||||
|
||||
|
||||
class UserSerializer(serializers.ModelSerializer):
|
||||
@ -45,18 +48,30 @@ class OAuthSerializer(serializers.ModelSerializer):
|
||||
"""
|
||||
normalized_name = serializers.SerializerMethodField()
|
||||
|
||||
profile = ProfileSerializer()
|
||||
profile = serializers.SerializerMethodField()
|
||||
|
||||
note = NoteSerializer()
|
||||
note = serializers.SerializerMethodField()
|
||||
|
||||
memberships = serializers.SerializerMethodField()
|
||||
|
||||
def get_normalized_name(self, obj):
|
||||
return Alias.normalize(obj.username)
|
||||
|
||||
def get_profile(self, obj):
|
||||
# Display the profile of the user only if we have rights to see it.
|
||||
return ProfileSerializer().to_representation(obj.profile) \
|
||||
if PermissionBackend.check_perm(get_current_request(), 'member.view_profile', obj.profile) else None
|
||||
|
||||
def get_note(self, obj):
|
||||
# Display the note of the user only if we have rights to see it.
|
||||
return NoteSerializer().to_representation(obj.note) \
|
||||
if PermissionBackend.check_perm(get_current_request(), 'note.view_note', obj.note) else None
|
||||
|
||||
def get_memberships(self, obj):
|
||||
# Display only memberships that we are allowed to see.
|
||||
return serializers.ListSerializer(child=MembershipSerializer()).to_representation(
|
||||
obj.memberships.filter(date_start__lte=timezone.now(), date_end__gte=timezone.now()))
|
||||
obj.memberships.filter(date_start__lte=timezone.now(), date_end__gte=timezone.now())
|
||||
.filter(PermissionBackend.filter_queryset(get_current_request(), Membership, 'view')))
|
||||
|
||||
class Meta:
|
||||
model = User
|
||||
|
@ -26,6 +26,10 @@ if "note" in settings.INSTALLED_APPS:
|
||||
from note.api.urls import register_note_urls
|
||||
register_note_urls(router, 'note')
|
||||
|
||||
if "sheets" in settings.INSTALLED_APPS:
|
||||
from sheets.api.urls import register_sheets_urls
|
||||
register_sheets_urls(router, 'sheets')
|
||||
|
||||
if "treasury" in settings.INSTALLED_APPS:
|
||||
from treasury.api.urls import register_treasury_urls
|
||||
register_treasury_urls(router, 'treasury')
|
||||
|
18
apps/member/migrations/0009_auto_20220818_1301.py
Normal file
18
apps/member/migrations/0009_auto_20220818_1301.py
Normal file
@ -0,0 +1,18 @@
|
||||
# Generated by Django 2.2.27 on 2022-08-18 11:01
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('member', '0008_auto_20211005_1544'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='profile',
|
||||
name='promotion',
|
||||
field=models.PositiveSmallIntegerField(default=2022, help_text='Year of entry to the school (None if not ENS student)', null=True, verbose_name='promotion'),
|
||||
),
|
||||
]
|
@ -258,16 +258,18 @@ class Club(models.Model):
|
||||
This function is called each time the club detail view is displayed.
|
||||
Update the year of the membership dates.
|
||||
"""
|
||||
if not self.membership_start:
|
||||
if not self.membership_start or not self.membership_end:
|
||||
return
|
||||
|
||||
today = datetime.date.today()
|
||||
|
||||
if (today - self.membership_start).days >= 365:
|
||||
self.membership_start = datetime.date(self.membership_start.year + 1,
|
||||
self.membership_start.month, self.membership_start.day)
|
||||
self.membership_end = datetime.date(self.membership_end.year + 1,
|
||||
self.membership_end.month, self.membership_end.day)
|
||||
if self.membership_start:
|
||||
self.membership_start = datetime.date(self.membership_start.year + 1,
|
||||
self.membership_start.month, self.membership_start.day)
|
||||
if self.membership_end:
|
||||
self.membership_end = datetime.date(self.membership_end.year + 1,
|
||||
self.membership_end.month, self.membership_end.day)
|
||||
self._force_save = True
|
||||
self.save(force_update=True)
|
||||
|
||||
|
53
apps/member/static/member/js/trust.js
Normal file
53
apps/member/static/member/js/trust.js
Normal file
@ -0,0 +1,53 @@
|
||||
/**
|
||||
* On form submit, create a new friendship
|
||||
*/
|
||||
function create_trust (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('trusted') + '/',
|
||||
function (trusted_alias) {
|
||||
if ((trusted_alias.note == formData.get('trusting')))
|
||||
{
|
||||
addMsg(gettext("You can't add yourself as a friend"), "danger")
|
||||
return
|
||||
}
|
||||
$.post('/api/note/trust/', {
|
||||
csrfmiddlewaretoken: formData.get('csrfmiddlewaretoken'),
|
||||
trusting: formData.get('trusting'),
|
||||
trusted: trusted_alias.note
|
||||
}).done(function () {
|
||||
// Reload table
|
||||
$('#trust_table').load(location.pathname + ' #trust_table')
|
||||
addMsg(gettext('Friendship successfully added'), 'success')
|
||||
}).fail(function (xhr, _textStatus, _error) {
|
||||
errMsg(xhr.responseJSON)
|
||||
})
|
||||
}).fail(function (xhr, _textStatus, _error) {
|
||||
errMsg(xhr.responseJSON)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* On click of "delete", delete the alias
|
||||
* @param button_id:Integer Alias id to remove
|
||||
*/
|
||||
function delete_button (button_id) {
|
||||
$.ajax({
|
||||
url: '/api/note/trust/' + button_id + '/',
|
||||
method: 'DELETE',
|
||||
headers: { 'X-CSRFTOKEN': CSRF_TOKEN }
|
||||
}).done(function () {
|
||||
addMsg(gettext('Friendship successfully deleted'), 'success')
|
||||
$('#trust_table').load(location.pathname + ' #trust_table')
|
||||
}).fail(function (xhr, _textStatus, _error) {
|
||||
errMsg(xhr.responseJSON)
|
||||
})
|
||||
}
|
||||
|
||||
$(document).ready(function () {
|
||||
// Attach event
|
||||
document.getElementById('form_trust').addEventListener('submit', create_trust)
|
||||
})
|
@ -120,7 +120,7 @@ class MembershipTable(tables.Table):
|
||||
club=record.club,
|
||||
user=record.user,
|
||||
date_start__gte=record.club.membership_start,
|
||||
date_end__lte=record.club.membership_end,
|
||||
date_end__lte=record.club.membership_end or date(9999, 12, 31),
|
||||
).exists(): # If the renew is not yet performed
|
||||
empty_membership = Membership(
|
||||
club=record.club,
|
||||
|
@ -25,6 +25,14 @@
|
||||
</a>
|
||||
</dd>
|
||||
|
||||
<dt class="col-xl-6">{% trans 'friendships'|capfirst %}</dt>
|
||||
<dd class="col-xl-6">
|
||||
<a class="badge badge-secondary" href="{% url 'member:user_trust' user_object.pk %}">
|
||||
<i class="fa fa-edit"></i>
|
||||
{% trans 'Manage friendships' %} ({{ user_object.note.trusting.all|length }})
|
||||
</a>
|
||||
</dd>
|
||||
|
||||
{% if "member.view_profile"|has_perm:user_object.profile %}
|
||||
<dt class="col-xl-6">{% trans 'section'|capfirst %}</dt>
|
||||
<dd class="col-xl-6">{{ user_object.profile.section }}</dd>
|
||||
|
41
apps/member/templates/member/profile_trust.html
Normal file
41
apps/member/templates/member/profile_trust.html
Normal file
@ -0,0 +1,41 @@
|
||||
{% extends "member/base.html" %}
|
||||
{% comment %}
|
||||
SPDX-License-Identifier: GPL-3.0-or-later
|
||||
{% endcomment %}
|
||||
{% load static django_tables2 i18n %}
|
||||
|
||||
{% block profile_content %}
|
||||
<div class="card bg-light mb-3">
|
||||
<h3 class="card-header text-center">
|
||||
{% trans "Note friendships" %}
|
||||
</h3>
|
||||
<div class="card-body">
|
||||
{% if can_create %}
|
||||
<form class="input-group" method="POST" id="form_trust">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="trusting" value="{{ object.note.pk }}">
|
||||
{%include "autocomplete_model.html" %}
|
||||
<div class="input-group-append">
|
||||
<input type="submit" class="btn btn-success" value="{% trans "Add" %}">
|
||||
</div>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% render_table trusting %}
|
||||
</div>
|
||||
|
||||
<div class="alert alert-warning card">
|
||||
{% blocktrans trimmed %}
|
||||
Adding someone as a friend enables them to initiate transactions coming
|
||||
from your account (while keeping your balance positive). This is
|
||||
designed to simplify using note kfet transfers to transfer money between
|
||||
users. The intent is that one person can make all transfers for a group of
|
||||
friends without needing additional rights among them.
|
||||
{% endblocktrans %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extrajavascript %}
|
||||
<script src="{% static "member/js/trust.js" %}"></script>
|
||||
<script src="{% static "js/autocomplete_model.js" %}"></script>
|
||||
{% endblock%}
|
@ -23,5 +23,6 @@ urlpatterns = [
|
||||
path('user/<int:pk>/update/', views.UserUpdateView.as_view(), name="user_update_profile"),
|
||||
path('user/<int:pk>/update_pic/', views.ProfilePictureUpdateView.as_view(), name="user_update_pic"),
|
||||
path('user/<int:pk>/aliases/', views.ProfileAliasView.as_view(), name="user_alias"),
|
||||
path('user/<int:pk>/trust', views.ProfileTrustView.as_view(), name="user_trust"),
|
||||
path('manage-auth-token/', views.ManageAuthTokens.as_view(), name='auth_token'),
|
||||
]
|
||||
|
@ -8,6 +8,7 @@ from django.contrib.auth import logout
|
||||
from django.contrib.auth.mixins import LoginRequiredMixin
|
||||
from django.contrib.auth.models import User
|
||||
from django.contrib.auth.views import LoginView
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.db import transaction
|
||||
from django.db.models import Q, F
|
||||
from django.shortcuts import redirect
|
||||
@ -18,9 +19,9 @@ from django.views.generic import DetailView, UpdateView, TemplateView
|
||||
from django.views.generic.edit import FormMixin
|
||||
from django_tables2.views import SingleTableView
|
||||
from rest_framework.authtoken.models import Token
|
||||
from note.models import Alias, NoteUser
|
||||
from note.models import Alias, NoteClub, NoteUser, Trust
|
||||
from note.models.transactions import Transaction, SpecialTransaction
|
||||
from note.tables import HistoryTable, AliasTable
|
||||
from note.tables import HistoryTable, AliasTable, TrustTable
|
||||
from note_kfet.middlewares import _set_current_request
|
||||
from permission.backends import PermissionBackend
|
||||
from permission.models import Role
|
||||
@ -174,7 +175,7 @@ class UserDetailView(ProtectQuerysetMixin, LoginRequiredMixin, DetailView):
|
||||
modified_note = NoteUser.objects.get(pk=user.note.pk)
|
||||
# Don't log these tests
|
||||
modified_note._no_signal = True
|
||||
modified_note.is_active = True
|
||||
modified_note.is_active = False
|
||||
modified_note.inactivity_reason = 'manual'
|
||||
context["can_lock_note"] = user.note.is_active and PermissionBackend\
|
||||
.check_perm(self.request, "note.change_noteuser_is_active", modified_note)
|
||||
@ -183,14 +184,14 @@ class UserDetailView(ProtectQuerysetMixin, LoginRequiredMixin, DetailView):
|
||||
modified_note._force_save = True
|
||||
modified_note.save()
|
||||
context["can_force_lock"] = user.note.is_active and PermissionBackend\
|
||||
.check_perm(self.request, "note.change_note_is_active", modified_note)
|
||||
.check_perm(self.request, "note.change_noteuser_is_active", modified_note)
|
||||
old_note._force_save = True
|
||||
old_note._no_signal = True
|
||||
old_note.save()
|
||||
modified_note.refresh_from_db()
|
||||
modified_note.is_active = True
|
||||
context["can_unlock_note"] = not user.note.is_active and PermissionBackend\
|
||||
.check_perm(self.request, "note.change_note_is_active", modified_note)
|
||||
.check_perm(self.request, "note.change_noteuser_is_active", modified_note)
|
||||
|
||||
return context
|
||||
|
||||
@ -243,6 +244,39 @@ class UserListView(ProtectQuerysetMixin, LoginRequiredMixin, SingleTableView):
|
||||
return context
|
||||
|
||||
|
||||
class ProfileTrustView(ProtectQuerysetMixin, LoginRequiredMixin, DetailView):
|
||||
"""
|
||||
View and manage user trust relationships
|
||||
"""
|
||||
model = User
|
||||
template_name = 'member/profile_trust.html'
|
||||
context_object_name = 'user_object'
|
||||
extra_context = {"title": _("Note friendships")}
|
||||
|
||||
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["can_create"] = PermissionBackend.check_perm(self.request, "note.add_trust", Trust(
|
||||
trusting=context["object"].note,
|
||||
trusted=context["object"].note
|
||||
))
|
||||
context["widget"] = {
|
||||
"name": "trusted",
|
||||
"attrs": {
|
||||
"model_pk": ContentType.objects.get_for_model(Alias).pk,
|
||||
"class": "autocomplete form-control",
|
||||
"id": "trusted",
|
||||
"resetable": True,
|
||||
"api_url": "/api/note/alias/?note__polymorphic_ctype__model=noteuser",
|
||||
"name_field": "name",
|
||||
"placeholder": ""
|
||||
}
|
||||
}
|
||||
return context
|
||||
|
||||
|
||||
class ProfileAliasView(ProtectQuerysetMixin, LoginRequiredMixin, DetailView):
|
||||
"""
|
||||
View and manage user aliases.
|
||||
@ -256,7 +290,8 @@ class ProfileAliasView(ProtectQuerysetMixin, LoginRequiredMixin, DetailView):
|
||||
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())
|
||||
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="",
|
||||
@ -403,9 +438,12 @@ class ClubDetailView(ProtectQuerysetMixin, LoginRequiredMixin, DetailView):
|
||||
"""
|
||||
context = super().get_context_data(**kwargs)
|
||||
|
||||
club = context["club"]
|
||||
club = self.object
|
||||
context["note"] = club.note
|
||||
|
||||
if PermissionBackend.check_perm(self.request, "member.change_club_membership_start", club):
|
||||
club.update_membership_dates()
|
||||
|
||||
# managers list
|
||||
managers = Membership.objects.filter(club=self.object, roles__name="Bureau de club",
|
||||
date_start__lte=date.today(), date_end__gte=date.today())\
|
||||
@ -443,6 +481,29 @@ class ClubDetailView(ProtectQuerysetMixin, LoginRequiredMixin, DetailView):
|
||||
context["can_add_members"] = PermissionBackend()\
|
||||
.has_perm(self.request.user, "member.add_membership", empty_membership)
|
||||
|
||||
# Check permissions to see if the authenticated user can lock/unlock the note
|
||||
with transaction.atomic():
|
||||
modified_note = NoteClub.objects.get(pk=club.note.pk)
|
||||
# Don't log these tests
|
||||
modified_note._no_signal = True
|
||||
modified_note.is_active = False
|
||||
modified_note.inactivity_reason = 'manual'
|
||||
context["can_lock_note"] = club.note.is_active and PermissionBackend \
|
||||
.check_perm(self.request, "note.change_noteclub_is_active", modified_note)
|
||||
old_note = NoteClub.objects.select_for_update().get(pk=club.note.pk)
|
||||
modified_note.inactivity_reason = 'forced'
|
||||
modified_note._force_save = True
|
||||
modified_note.save()
|
||||
context["can_force_lock"] = club.note.is_active and PermissionBackend \
|
||||
.check_perm(self.request, "note.change_noteclub_is_active", modified_note)
|
||||
old_note._force_save = True
|
||||
old_note._no_signal = True
|
||||
old_note.save()
|
||||
modified_note.refresh_from_db()
|
||||
modified_note.is_active = True
|
||||
context["can_unlock_note"] = not club.note.is_active and PermissionBackend \
|
||||
.check_perm(self.request, "note.change_noteclub_is_active", modified_note)
|
||||
|
||||
return context
|
||||
|
||||
|
||||
|
@ -12,7 +12,7 @@ from note_kfet.middlewares import get_current_request
|
||||
from permission.backends import PermissionBackend
|
||||
from rest_framework.utils import model_meta
|
||||
|
||||
from ..models.notes import Note, NoteClub, NoteSpecial, NoteUser, Alias
|
||||
from ..models.notes import Note, NoteClub, NoteSpecial, NoteUser, Alias, Trust
|
||||
from ..models.transactions import TransactionTemplate, Transaction, MembershipTransaction, TemplateCategory, \
|
||||
RecurrentTransaction, SpecialTransaction
|
||||
|
||||
@ -77,6 +77,22 @@ class NoteUserSerializer(serializers.ModelSerializer):
|
||||
return str(obj)
|
||||
|
||||
|
||||
class TrustSerializer(serializers.ModelSerializer):
|
||||
"""
|
||||
REST API Serializer for Trusts.
|
||||
The djangorestframework plugin will analyse the model `Trust` and parse all fields in the API.
|
||||
"""
|
||||
|
||||
class Meta:
|
||||
model = Trust
|
||||
fields = '__all__'
|
||||
|
||||
def validate(self, attrs):
|
||||
instance = Trust(**attrs)
|
||||
instance.clean()
|
||||
return attrs
|
||||
|
||||
|
||||
class AliasSerializer(serializers.ModelSerializer):
|
||||
"""
|
||||
REST API Serializer for Aliases.
|
||||
|
@ -2,7 +2,8 @@
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
from .views import NotePolymorphicViewSet, AliasViewSet, ConsumerViewSet, \
|
||||
TemplateCategoryViewSet, TransactionViewSet, TransactionTemplateViewSet
|
||||
TemplateCategoryViewSet, TransactionViewSet, TransactionTemplateViewSet, \
|
||||
TrustViewSet
|
||||
|
||||
|
||||
def register_note_urls(router, path):
|
||||
@ -11,6 +12,7 @@ def register_note_urls(router, path):
|
||||
"""
|
||||
router.register(path + '/note', NotePolymorphicViewSet)
|
||||
router.register(path + '/alias', AliasViewSet)
|
||||
router.register(path + '/trust', TrustViewSet)
|
||||
router.register(path + '/consumer', ConsumerViewSet)
|
||||
|
||||
router.register(path + '/transaction/category', TemplateCategoryViewSet)
|
||||
|
@ -14,8 +14,9 @@ from api.viewsets import ReadProtectedModelViewSet, ReadOnlyProtectedModelViewSe
|
||||
from permission.backends import PermissionBackend
|
||||
|
||||
from .serializers import NotePolymorphicSerializer, AliasSerializer, ConsumerSerializer,\
|
||||
TemplateCategorySerializer, TransactionTemplateSerializer, TransactionPolymorphicSerializer
|
||||
from ..models.notes import Note, Alias, NoteUser, NoteClub, NoteSpecial
|
||||
TemplateCategorySerializer, TransactionTemplateSerializer, TransactionPolymorphicSerializer, \
|
||||
TrustSerializer
|
||||
from ..models.notes import Note, Alias, NoteUser, NoteClub, NoteSpecial, Trust
|
||||
from ..models.transactions import TransactionTemplate, Transaction, TemplateCategory
|
||||
|
||||
|
||||
@ -56,11 +57,41 @@ class NotePolymorphicViewSet(ReadProtectedModelViewSet):
|
||||
return queryset.order_by("id")
|
||||
|
||||
|
||||
class TrustViewSet(ReadProtectedModelViewSet):
|
||||
"""
|
||||
REST Trust View set.
|
||||
The djangorestframework plugin will get all `Trust` objects, serialize it to JSON with the given serializer,
|
||||
then render it on /api/note/trust/
|
||||
"""
|
||||
queryset = Trust.objects
|
||||
serializer_class = TrustSerializer
|
||||
filter_backends = [SearchFilter, 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']
|
||||
ordering_fields = ['trusting', 'trusted', ]
|
||||
|
||||
def get_serializer_class(self):
|
||||
serializer_class = self.serializer_class
|
||||
if self.request.method in ['PUT', 'PATCH']:
|
||||
# trust relationship can't change people involved
|
||||
serializer_class.Meta.read_only_fields = ('trusting', 'trusting',)
|
||||
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)
|
||||
|
||||
|
||||
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/aliases/
|
||||
then render it on /api/note/aliases/
|
||||
"""
|
||||
queryset = Alias.objects
|
||||
serializer_class = AliasSerializer
|
||||
|
27
apps/note/migrations/0006_trust.py
Normal file
27
apps/note/migrations/0006_trust.py
Normal file
@ -0,0 +1,27 @@
|
||||
# Generated by Django 2.2.24 on 2021-09-05 19:16
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('note', '0005_auto_20210313_1235'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Trust',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('trusted', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='trusted', to='note.Note', verbose_name='trusted')),
|
||||
('trusting', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='trusting', to='note.Note', verbose_name='trusting')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'frienship',
|
||||
'verbose_name_plural': 'friendships',
|
||||
'unique_together': {('trusting', 'trusted')},
|
||||
},
|
||||
),
|
||||
]
|
@ -1,13 +1,13 @@
|
||||
# Copyright (C) 2018-2021 by BDE ENS Paris-Saclay
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
from .notes import Alias, Note, NoteClub, NoteSpecial, NoteUser
|
||||
from .notes import Alias, Note, NoteClub, NoteSpecial, NoteUser, Trust
|
||||
from .transactions import MembershipTransaction, Transaction, \
|
||||
TemplateCategory, TransactionTemplate, RecurrentTransaction, SpecialTransaction
|
||||
|
||||
__all__ = [
|
||||
# Notes
|
||||
'Alias', 'Note', 'NoteClub', 'NoteSpecial', 'NoteUser',
|
||||
'Alias', 'Trust', 'Note', 'NoteClub', 'NoteSpecial', 'NoteUser',
|
||||
# Transactions
|
||||
'MembershipTransaction', 'Transaction', 'TemplateCategory', 'TransactionTemplate',
|
||||
'RecurrentTransaction', 'SpecialTransaction',
|
||||
|
@ -217,6 +217,38 @@ class NoteSpecial(Note):
|
||||
return self.special_type
|
||||
|
||||
|
||||
class Trust(models.Model):
|
||||
"""
|
||||
A one-sided trust relationship bertween two users
|
||||
|
||||
If another user considers you as your friend, you can transfer money from
|
||||
them
|
||||
"""
|
||||
|
||||
trusting = models.ForeignKey(
|
||||
Note,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='trusting',
|
||||
verbose_name=_('trusting')
|
||||
)
|
||||
|
||||
trusted = models.ForeignKey(
|
||||
Note,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='trusted',
|
||||
verbose_name=_('trusted')
|
||||
)
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("frienship")
|
||||
verbose_name_plural = _("friendships")
|
||||
unique_together = ("trusting", "trusted")
|
||||
|
||||
def __str__(self):
|
||||
return _("Friendship between {trusting} and {trusted}").format(
|
||||
trusting=str(self.trusting), trusted=str(self.trusted))
|
||||
|
||||
|
||||
class Alias(models.Model):
|
||||
"""
|
||||
points toward a :model:`note.NoteUser` or :model;`note.NoteClub` instance.
|
||||
|
@ -10,7 +10,7 @@ from django.utils.translation import gettext_lazy as _
|
||||
from note_kfet.middlewares import get_current_request
|
||||
from permission.backends import PermissionBackend
|
||||
|
||||
from .models.notes import Alias
|
||||
from .models.notes import Alias, Trust
|
||||
from .models.transactions import Transaction, TransactionTemplate
|
||||
from .templatetags.pretty_money import pretty_money
|
||||
|
||||
@ -148,6 +148,31 @@ DELETE_TEMPLATE = """
|
||||
"""
|
||||
|
||||
|
||||
class TrustTable(tables.Table):
|
||||
class Meta:
|
||||
attrs = {
|
||||
'class': 'table table condensed table-striped',
|
||||
'id': "trust_table"
|
||||
}
|
||||
model = Trust
|
||||
fields = ("trusted",)
|
||||
template_name = 'django_tables2/bootstrap4.html'
|
||||
|
||||
show_header = False
|
||||
trusted = 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(), "note.delete_trust", record)
|
||||
else '')}},
|
||||
verbose_name=_("Delete"),)
|
||||
|
||||
|
||||
class AliasTable(tables.Table):
|
||||
class Meta:
|
||||
attrs = {
|
||||
@ -198,14 +223,15 @@ class ButtonTable(tables.Table):
|
||||
)
|
||||
|
||||
hideshow = tables.Column(
|
||||
verbose_name= _("Hide/Show"),
|
||||
accessor="pk",
|
||||
attrs= {
|
||||
'td': {
|
||||
'class': 'col-sm-1',
|
||||
'id': lambda record: "hideshow_" + str(record.pk),
|
||||
}
|
||||
})
|
||||
verbose_name=_("Hide/Show"),
|
||||
accessor="pk",
|
||||
attrs={
|
||||
'td': {
|
||||
'class': 'col-sm-1',
|
||||
'id': lambda record: "hideshow_" + str(record.pk),
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
delete_col = tables.TemplateColumn(template_code=DELETE_TEMPLATE,
|
||||
extra_context={"delete_trans": _('delete')},
|
||||
@ -215,6 +241,9 @@ class ButtonTable(tables.Table):
|
||||
def render_amount(self, value):
|
||||
return pretty_money(value)
|
||||
|
||||
def order_category(self, queryset, is_descending):
|
||||
return queryset.order_by(f"{'-' if is_descending else ''}category__name"), True
|
||||
|
||||
def render_hideshow(self, record):
|
||||
val = '<button id="'
|
||||
val += str(record.pk)
|
||||
|
17
apps/note/templates/sheets/order.html
Normal file
17
apps/note/templates/sheets/order.html
Normal file
@ -0,0 +1,17 @@
|
||||
{% extends "base.html" %}
|
||||
{% comment %}
|
||||
SPDX-License-Identifier: GPL-3.0-or-later
|
||||
{% endcomment %}
|
||||
{% load crispy_forms_tags %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block content %}
|
||||
<div class="card bg-light mb-3">
|
||||
<h3 class="card-header text-center">
|
||||
{{ title }}
|
||||
</h3>
|
||||
<div class="card-body">
|
||||
{% crispy form %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
88
apps/note/templates/sheets/waiting_list.html
Normal file
88
apps/note/templates/sheets/waiting_list.html
Normal file
@ -0,0 +1,88 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% load i18n %}
|
||||
|
||||
{% block content %}
|
||||
<div class="card">
|
||||
<div class="card-header text-center">
|
||||
<h1>{{ food.name }}</h1>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row">
|
||||
<div class="card col-xl-6">
|
||||
<div class="card-header text-center">
|
||||
<h2>{% trans "queued"|capfirst %}{% if queue %} ({{ queue|length }}){% endif %}</h2>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<ul>
|
||||
{% for ordered_food in queue %}
|
||||
<li>
|
||||
{{ ordered_food.order.note }}
|
||||
{% if ordered_food.priority %}
|
||||
<span class="badge badge-secondary">{{ ordered_food.priority }}</span>
|
||||
{% endif %}
|
||||
</li>
|
||||
{% empty %}
|
||||
<div class="alert alert-warning">
|
||||
{% trans "There is no queued order." %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card col-xl-6">
|
||||
<div class="card-header text-center">
|
||||
<h2>{% trans "ready"|capfirst %}</h2>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<ul>
|
||||
{% for ordered_food in ready %}
|
||||
<li>{{ ordered_food.order.note }}</li>
|
||||
{% empty %}
|
||||
<div class="alert alert-warning">
|
||||
{% trans "There is no ready order." %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr>
|
||||
|
||||
<h3>{% trans "Other waiting lists:" %}</h3>
|
||||
<ul>
|
||||
{% for other_food in food.sheet.food_set.all %}
|
||||
{% if other_food != food %}
|
||||
<li>
|
||||
<a href="{% url 'sheets:waiting_list' pk=other_food.pk %}">{{ other_food }}</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
<div class="card-footer text-center">
|
||||
<a href="{% url 'sheets:queued_list' pk=food.pk %}" class="btn btn-primary">
|
||||
{% trans "Queued orders" %}
|
||||
</a>
|
||||
<a href="{% url 'sheets:ready_list' pk=food.pk %}" class="btn btn-primary">
|
||||
{% trans "Ready orders" %}
|
||||
</a>
|
||||
<a href="{% url 'sheets:sheet_detail' pk=food.sheet_id %}" class="btn btn-secondary">
|
||||
{% trans "Back to note sheet detail" %}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extrajavascript %}
|
||||
<script>
|
||||
function reload() {
|
||||
reloadWithTurbolinks()
|
||||
timeout = setTimeout(reload, 15000)
|
||||
}
|
||||
|
||||
if (timeout === undefined)
|
||||
var timeout = setTimeout(reload, 15000)
|
||||
</script>
|
||||
{% endblock %}
|
152
apps/note/templates/sheets/waiting_list_detail.html
Normal file
152
apps/note/templates/sheets/waiting_list_detail.html
Normal file
@ -0,0 +1,152 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% load i18n %}
|
||||
|
||||
{% block content %}
|
||||
<div class="card">
|
||||
<div class="card-header text-center">
|
||||
<h1>{{ title }}</h1>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% for of in orders %}
|
||||
<div class="card mb-4">
|
||||
<div class="card-header text-center">
|
||||
<h3>{{ of.order.note }}</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<dl class="row">
|
||||
<dt class="col-xl-3">{% trans 'date'|capfirst %}</dt>
|
||||
<dd class="col-xl-9">{{ of.order.date }}</dd>
|
||||
|
||||
{% if of.number > 1 %}
|
||||
<dt class="col-xl-3">{% trans 'order number'|capfirst %}</dt>
|
||||
<dd class="col-xl-9">{{ of.number }}</dd>
|
||||
{% endif %}
|
||||
|
||||
{% if of.priority %}
|
||||
<dt class="col-xl-3">{% trans 'priority request'|capfirst %}</dt>
|
||||
<dd class="col-xl-9">{{ of.priority }}</dd>
|
||||
{% endif %}
|
||||
|
||||
{% if of.remark %}
|
||||
<dt class="col-xl-3">{% trans 'remark'|capfirst %}</dt>
|
||||
<dd class="col-xl-9">{{ of.remark }}</dd>
|
||||
{% endif %}
|
||||
|
||||
{% if of.options.count %}
|
||||
<dt class="col-xl-3">{% trans 'options'|capfirst %}</dt>
|
||||
<dd class="col-xl-9">{{ of.options.all|join:', ' }}</dd>
|
||||
{% endif %}
|
||||
</dl>
|
||||
</div>
|
||||
<div class="card-footer text-center">
|
||||
{% if list_type != 'READY' %}
|
||||
<a href="#" class="btn btn-success" onclick="setOrderStatus({{ of.pk }}, 'READY')">
|
||||
{% trans "Mark as ready" %}
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if list_type != 'SERVED' %}
|
||||
<a href="#" class="btn btn-primary" onclick="setOrderStatus({{ of.pk }}, 'SERVED')">
|
||||
{% trans "Mark as served" %}
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if list_type != 'QUEUED' %}
|
||||
<a href="#" class="btn btn-warning" onclick="setOrderStatus({{ of.pk }}, 'QUEUED')">
|
||||
{% trans "Re-queue" %}
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if list_type != 'CANCELED' %}
|
||||
<a href="#" class="btn btn-danger" onclick="setOrderStatus({{ of.pk }}, 'CANCELED')">
|
||||
{% trans "Cancel" %}
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% empty %}
|
||||
<div class="alert alert-warning">
|
||||
{% trans "There is no queued order." %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card mt-5">
|
||||
<div class="card-body">
|
||||
<h3>{% trans "Other waiting lists:" %}</h3>
|
||||
<ul>
|
||||
{% for other_food in food.sheet.food_set.all %}
|
||||
{% if other_food != food %}
|
||||
<li>
|
||||
{% if list_type == 'QUEUED' %}
|
||||
<a href="{% url 'sheets:queued_list' pk=other_food.pk %}">{{ other_food }}</a>
|
||||
{% else %}
|
||||
<a href="{% url 'sheets:ready_list' pk=other_food.pk %}">{{ other_food }}</a>
|
||||
{% endif %}
|
||||
</li>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
<div class="card-footer text-center">
|
||||
{% if list_type != 'QUEUED' %}
|
||||
<a href="{% url 'sheets:queued_list' pk=food.pk %}" class="btn btn-primary">
|
||||
{% trans "Queued orders" %}
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if list_type != 'READY' %}
|
||||
<a href="{% url 'sheets:ready_list' pk=food.pk %}" class="btn btn-success">
|
||||
{% trans "Ready orders" %}
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if list_type != 'SERVED' %}
|
||||
<a href="{% url 'sheets:served_list' pk=food.pk %}" class="btn btn-secondary">
|
||||
{% trans "Served orders" %}
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if list_type != 'CANCELED' %}
|
||||
<a href="{% url 'sheets:canceled_list' pk=food.pk %}" class="btn btn-danger">
|
||||
{% trans "Canceled orders" %}
|
||||
</a>
|
||||
{% endif %}
|
||||
<a href="{% url 'sheets:waiting_list' pk=food.pk %}" class="btn btn-primary">
|
||||
{% trans "Waiting list" %}
|
||||
</a>
|
||||
<a href="{% url 'sheets:sheet_detail' pk=food.sheet_id %}" class="btn btn-secondary">
|
||||
{% trans "Back to note sheet detail" %}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extrajavascript %}
|
||||
<script>
|
||||
function reload() {
|
||||
reloadWithTurbolinks()
|
||||
timeout = setTimeout(reload, 15000)
|
||||
}
|
||||
|
||||
if (timeout === undefined)
|
||||
var timeout = setTimeout(reload, 15000)
|
||||
|
||||
function setOrderStatus(ordered_food_id, status) {
|
||||
fetch('/api/sheets/orderedfood/' + ordered_food_id + '/', {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({
|
||||
status: status,
|
||||
served_date: status === 'QUEUED' ? null : new Date().toISOString(),
|
||||
}),
|
||||
headers: {
|
||||
'Content-Type': "application/json; charset=UTF-8",
|
||||
'X-CSRFTOKEN': "{{ csrf_token }}"
|
||||
}
|
||||
}).then(response => response.json()).then(response => {
|
||||
if ('detail' in response)
|
||||
addMsg("{% trans "An error occurred" %}" + " : " + response['detail'], "danger")
|
||||
else {
|
||||
clearTimeout(timeout)
|
||||
reload()
|
||||
}
|
||||
})
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
@ -90,9 +90,9 @@ class TransactionTemplateListView(ProtectQuerysetMixin, LoginRequiredMixin, Sing
|
||||
if "search" in self.request.GET:
|
||||
pattern = self.request.GET["search"]
|
||||
qs = qs.filter(
|
||||
Q(name__iregex="^" + pattern)
|
||||
| Q(destination__club__name__iregex="^" + pattern)
|
||||
| Q(category__name__iregex="^" + pattern)
|
||||
Q(name__iregex=pattern)
|
||||
| Q(destination__club__name__iregex=pattern)
|
||||
| Q(category__name__iregex=pattern)
|
||||
| Q(description__iregex=pattern)
|
||||
)
|
||||
|
||||
|
@ -977,7 +977,7 @@
|
||||
],
|
||||
"query": "[\"OR\", {\"source\": [\"club\", \"note\"]}, {\"destination\": [\"club\", \"note\"]}]",
|
||||
"type": "view",
|
||||
"mask": 1,
|
||||
"mask": 2,
|
||||
"field": "",
|
||||
"permanent": false,
|
||||
"description": "Voir les transactions d'un club"
|
||||
@ -2511,7 +2511,7 @@
|
||||
"note",
|
||||
"noteuser"
|
||||
],
|
||||
"query": "[\"AND\", {\"user\": [\"user\"]}, [\"OR\", {\"inactivity_reason\": \"manual\"}, {\"inactivity_reason\": null}]]",
|
||||
"query": "[\"AND\", {\"user\": [\"user\"]}, [\"OR\", {\"inactivity_reason\": \"manual\"}, {\"is_active\": true}]]",
|
||||
"type": "change",
|
||||
"mask": 1,
|
||||
"field": "is_active",
|
||||
@ -2527,7 +2527,7 @@
|
||||
"note",
|
||||
"noteuser"
|
||||
],
|
||||
"query": "[\"AND\", {\"user\": [\"user\"]}, [\"OR\", {\"inactivity_reason\": \"manual\"}, {\"inactivity_reason\": null}]]",
|
||||
"query": "[\"AND\", {\"user\": [\"user\"]}, [\"OR\", {\"inactivity_reason\": \"manual\"}, {\"is_active\": true}]]",
|
||||
"type": "change",
|
||||
"mask": 1,
|
||||
"field": "inactivity_reason",
|
||||
@ -2871,6 +2871,214 @@
|
||||
"description": "Changer l'image de n'importe quelle note"
|
||||
}
|
||||
},
|
||||
{
|
||||
"model": "permission.permission",
|
||||
"pk": 184,
|
||||
"fields": {
|
||||
"model": [
|
||||
"note",
|
||||
"noteclub"
|
||||
],
|
||||
"query": "[\"AND\", {\"club\": [\"club\"]}, [\"OR\", {\"inactivity_reason\": \"manual\"}, {\"is_active\": true}]]",
|
||||
"type": "change",
|
||||
"mask": 3,
|
||||
"field": "is_active",
|
||||
"permanent": true,
|
||||
"description": "(Dé)bloquer la note de son club manuellement"
|
||||
}
|
||||
},
|
||||
{
|
||||
"model": "permission.permission",
|
||||
"pk": 185,
|
||||
"fields": {
|
||||
"model": [
|
||||
"note",
|
||||
"noteclub"
|
||||
],
|
||||
"query": "[\"AND\", {\"club\": [\"club\"]}, [\"OR\", {\"inactivity_reason\": \"manual\"}, {\"is_active\": true}]]",
|
||||
"type": "change",
|
||||
"mask": 3,
|
||||
"field": "inactivity_reason",
|
||||
"permanent": true,
|
||||
"description": "(Dé)bloquer la note de son club et indiquer que cela a été fait manuellement"
|
||||
}
|
||||
},
|
||||
{
|
||||
"model": "permission.permission",
|
||||
"pk": 186,
|
||||
"fields": {
|
||||
"model": [
|
||||
"oauth2_provider",
|
||||
"application"
|
||||
],
|
||||
"query": "{\"user\": [\"user\"]}",
|
||||
"type": "view",
|
||||
"mask": 1,
|
||||
"field": "",
|
||||
"permanent": true,
|
||||
"description": "Voir ses applications OAuth2"
|
||||
}
|
||||
},
|
||||
{
|
||||
"model": "permission.permission",
|
||||
"pk": 187,
|
||||
"fields": {
|
||||
"model": [
|
||||
"oauth2_provider",
|
||||
"application"
|
||||
],
|
||||
"query": "{\"user\": [\"user\"]}",
|
||||
"type": "add",
|
||||
"mask": 1,
|
||||
"field": "",
|
||||
"permanent": true,
|
||||
"description": "Créer une application OAuth2"
|
||||
}
|
||||
},
|
||||
{
|
||||
"model": "permission.permission",
|
||||
"pk": 188,
|
||||
"fields": {
|
||||
"model": [
|
||||
"oauth2_provider",
|
||||
"application"
|
||||
],
|
||||
"query": "{\"user\": [\"user\"]}",
|
||||
"type": "change",
|
||||
"mask": 1,
|
||||
"field": "",
|
||||
"permanent": true,
|
||||
"description": "Modifier une application OAuth2"
|
||||
}
|
||||
},
|
||||
{
|
||||
"model": "permission.permission",
|
||||
"pk": 189,
|
||||
"fields": {
|
||||
"model": [
|
||||
"oauth2_provider",
|
||||
"application"
|
||||
],
|
||||
"query": "{\"user\": [\"user\"]}",
|
||||
"type": "delete",
|
||||
"mask": 1,
|
||||
"field": "",
|
||||
"permanent": true,
|
||||
"description": "Supprimer une application OAuth2"
|
||||
}
|
||||
},
|
||||
{
|
||||
"model": "permission.permission",
|
||||
"pk": 190,
|
||||
"fields": {
|
||||
"model": [
|
||||
"note",
|
||||
"trust"
|
||||
],
|
||||
"query": "{\"trusting\": [\"user\", \"note\"]}",
|
||||
"type": "delete",
|
||||
"mask": 1,
|
||||
"field": "",
|
||||
"permanent": false,
|
||||
"description": "Supprimer une amitié à sa note"
|
||||
}
|
||||
},
|
||||
{
|
||||
"model": "permission.permission",
|
||||
"pk": 191,
|
||||
"fields": {
|
||||
"model": [
|
||||
"note",
|
||||
"trust"
|
||||
],
|
||||
"query": "{\"trusting\": [\"user\", \"note\"]}",
|
||||
"type": "add",
|
||||
"mask": 1,
|
||||
"field": "",
|
||||
"permanent": false,
|
||||
"description": "Ajouter une amitié à sa note"
|
||||
}
|
||||
},
|
||||
{
|
||||
"model": "permission.permission",
|
||||
"pk": 192,
|
||||
"fields": {
|
||||
"model": [
|
||||
"note",
|
||||
"trust"
|
||||
],
|
||||
"query": "{\"trusting__is_active\": true}",
|
||||
"type": "add",
|
||||
"mask": 1,
|
||||
"field": "",
|
||||
"permanent": false,
|
||||
"description": "Ajouter une amitié à une note non bloquée"
|
||||
}
|
||||
},
|
||||
{
|
||||
"model": "permission.permission",
|
||||
"pk": 193,
|
||||
"fields": {
|
||||
"model": [
|
||||
"note",
|
||||
"trust"
|
||||
],
|
||||
"query": "{\"trusting__is_active\": true}",
|
||||
"type": "delete",
|
||||
"mask": 3,
|
||||
"field": "",
|
||||
"permanent": false,
|
||||
"description": "Supprimer une amitié à une note non bloquée"
|
||||
}
|
||||
},
|
||||
{
|
||||
"model": "permission.permission",
|
||||
"pk": 194,
|
||||
"fields": {
|
||||
"model": [
|
||||
"note",
|
||||
"trust"
|
||||
],
|
||||
"query": "{}",
|
||||
"type": "view",
|
||||
"mask": 3,
|
||||
"field": "",
|
||||
"permanent": false,
|
||||
"description": "Voir toutes les amitiés, y compris celles des non adhérents"
|
||||
}
|
||||
},
|
||||
{
|
||||
"model": "permission.permission",
|
||||
"pk": 195,
|
||||
"fields": {
|
||||
"model": [
|
||||
"note",
|
||||
"trust"
|
||||
],
|
||||
"query": "{\"trusting__noteuser__user\": [\"user\"]}",
|
||||
"type": "view",
|
||||
"mask": 1,
|
||||
"field": "",
|
||||
"permanent": true,
|
||||
"description": "Voir ses propres amitiés, pour toujours"
|
||||
}
|
||||
},
|
||||
{
|
||||
"model": "permission.permission",
|
||||
"pk": 196,
|
||||
"fields": {
|
||||
"model": [
|
||||
"note",
|
||||
"transaction"
|
||||
],
|
||||
"query": "[\"AND\", {\"source__trusting__trusted\": [\"user\", \"note\"]}, [\"OR\", {\"source__balance__gte\": {\"F\": [\"MUL\", [\"F\", \"amount\"], [\"F\", \"quantity\"]]}}, {\"valid\": false}]]",
|
||||
"type": "add",
|
||||
"mask": 1,
|
||||
"field": "",
|
||||
"permanent": false,
|
||||
"description": "Transférer de l'argent depuis une note amie en restant positif"
|
||||
}
|
||||
},
|
||||
{
|
||||
"model": "permission.role",
|
||||
"pk": 1,
|
||||
@ -2901,7 +3109,15 @@
|
||||
126,
|
||||
161,
|
||||
162,
|
||||
165
|
||||
165,
|
||||
186,
|
||||
187,
|
||||
188,
|
||||
189,
|
||||
190,
|
||||
191,
|
||||
195,
|
||||
196
|
||||
]
|
||||
}
|
||||
},
|
||||
@ -2942,7 +3158,9 @@
|
||||
158,
|
||||
159,
|
||||
160,
|
||||
179
|
||||
179,
|
||||
189,
|
||||
190
|
||||
]
|
||||
}
|
||||
},
|
||||
@ -3010,7 +3228,9 @@
|
||||
166,
|
||||
167,
|
||||
168,
|
||||
182
|
||||
182,
|
||||
184,
|
||||
185
|
||||
]
|
||||
}
|
||||
},
|
||||
@ -3090,7 +3310,10 @@
|
||||
176,
|
||||
177,
|
||||
178,
|
||||
183
|
||||
188,
|
||||
183,
|
||||
186,
|
||||
187
|
||||
]
|
||||
}
|
||||
},
|
||||
@ -3278,7 +3501,20 @@
|
||||
180,
|
||||
181,
|
||||
182,
|
||||
183
|
||||
183,
|
||||
184,
|
||||
185,
|
||||
186,
|
||||
187,
|
||||
188,
|
||||
189,
|
||||
190,
|
||||
191,
|
||||
192,
|
||||
193,
|
||||
194,
|
||||
195,
|
||||
196
|
||||
]
|
||||
}
|
||||
},
|
||||
|
@ -1,6 +1,6 @@
|
||||
# Copyright (C) 2018-2021 by BDE ENS Paris-Saclay
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
from oauth2_provider.oauth2_validators import OAuth2Validator
|
||||
from oauth2_provider.scopes import BaseScopes
|
||||
from member.models import Club
|
||||
from note_kfet.middlewares import get_current_request
|
||||
@ -32,3 +32,26 @@ class PermissionScopes(BaseScopes):
|
||||
return []
|
||||
return [f"{p.id}_{p.membership.club.id}"
|
||||
for p in PermissionBackend.get_raw_permissions(get_current_request(), 'view')]
|
||||
|
||||
|
||||
class PermissionOAuth2Validator(OAuth2Validator):
|
||||
def validate_scopes(self, client_id, scopes, client, request, *args, **kwargs):
|
||||
"""
|
||||
User can request as many scope as he wants, including invalid scopes,
|
||||
but it will have only the permissions he has.
|
||||
|
||||
This allows clients to request more permission to get finally a
|
||||
subset of permissions.
|
||||
"""
|
||||
|
||||
valid_scopes = set()
|
||||
|
||||
for t in Permission.PERMISSION_TYPES:
|
||||
for p in PermissionBackend.get_raw_permissions(get_current_request(), t[0]):
|
||||
scope = f"{p.id}_{p.membership.club.id}"
|
||||
if scope in scopes:
|
||||
valid_scopes.add(scope)
|
||||
|
||||
request.scopes = valid_scopes
|
||||
|
||||
return valid_scopes
|
||||
|
@ -11,25 +11,25 @@
|
||||
<div class="accordion" id="accordionApps">
|
||||
{% for app, app_scopes in scopes.items %}
|
||||
<div class="card">
|
||||
<div class="card-header" id="app-{{ app.name.lower }}-title">
|
||||
<div class="card-header" id="app-{{ app.name|slugify }}-title">
|
||||
<a class="text-decoration-none collapsed" href="#" data-toggle="collapse"
|
||||
data-target="#app-{{ app.name.lower }}" aria-expanded="false"
|
||||
aria-controls="app-{{ app.name.lower }}">
|
||||
data-target="#app-{{ app.name|slugify }}" aria-expanded="false"
|
||||
aria-controls="app-{{ app.name|slugify }}">
|
||||
{{ app.name }}
|
||||
</a>
|
||||
</div>
|
||||
<div class="collapse" id="app-{{ app.name.lower }}" aria-labelledby="app-{{ app.name.lower }}" data-target="#accordionApps">
|
||||
<div class="collapse" id="app-{{ app.name|slugify }}" aria-labelledby="app-{{ app.name|slugify }}" data-target="#accordionApps">
|
||||
<div class="card-body">
|
||||
{% for scope_id, scope_desc in app_scopes.items %}
|
||||
<div class="form-group">
|
||||
<label class="form-check-label" for="scope-{{ app.name.lower }}-{{ scope_id }}">
|
||||
<input type="checkbox" id="scope-{{ app.name.lower }}-{{ scope_id }}"
|
||||
name="scope-{{ app.name.lower }}" class="checkboxinput form-check-input" value="{{ scope_id }}">
|
||||
<label class="form-check-label" for="scope-{{ app.name|slugify }}-{{ scope_id }}">
|
||||
<input type="checkbox" id="scope-{{ app.name|slugify }}-{{ scope_id }}"
|
||||
name="scope-{{ app.name|slugify }}" class="checkboxinput form-check-input" value="{{ scope_id }}">
|
||||
{{ scope_desc }}
|
||||
</label>
|
||||
</div>
|
||||
{% endfor %}
|
||||
<p id="url-{{ app.name.lower }}">
|
||||
<p id="url-{{ app.name|slugify }}">
|
||||
<a href="{% url 'oauth2_provider:authorize' %}?client_id={{ app.client_id }}&response_type=code" target="_blank">
|
||||
{{ request.scheme }}://{{ request.get_host }}{% url 'oauth2_provider:authorize' %}?client_id={{ app.client_id }}&response_type=code
|
||||
</a>
|
||||
@ -51,11 +51,10 @@
|
||||
{% block extrajavascript %}
|
||||
<script>
|
||||
{% for app in scopes.keys %}
|
||||
let elements = document.getElementsByName("scope-{{ app.name.lower }}");
|
||||
for (let element of elements) {
|
||||
for (let element of document.getElementsByName("scope-{{ app.name|slugify }}")) {
|
||||
element.onchange = function (event) {
|
||||
let scope = ""
|
||||
for (let element of elements) {
|
||||
for (let element of document.getElementsByName("scope-{{ app.name|slugify }}")) {
|
||||
if (element.checked) {
|
||||
scope += element.value + " "
|
||||
}
|
||||
@ -63,7 +62,7 @@
|
||||
|
||||
scope = scope.substr(0, scope.length - 1)
|
||||
|
||||
document.getElementById("url-{{ app.name.lower }}").innerHTML = 'Scopes : ' + scope
|
||||
document.getElementById("url-{{ app.name|slugify }}").innerHTML = 'Scopes : ' + scope
|
||||
+ '<br><a href="{% url 'oauth2_provider:authorize' %}?client_id={{ app.client_id }}&response_type=code&scope='+ scope.replaceAll(' ', '%20')
|
||||
+ '" target="_blank">{{ request.scheme }}://{{ request.get_host }}{% url 'oauth2_provider:authorize' %}?client_id={{ app.client_id }}&response_type=code&scope='
|
||||
+ scope.replaceAll(' ', '%20') + '</a>'
|
||||
|
Submodule apps/scripts updated: 7a022b9407...86bc2d2698
4
apps/sheets/__init__.py
Normal file
4
apps/sheets/__init__.py
Normal file
@ -0,0 +1,4 @@
|
||||
# Copyright (C) 2018-2022 by BDE ENS Paris-Saclay
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
default_app_config = 'sheets.apps.SheetsConfig'
|
46
apps/sheets/admin.py
Normal file
46
apps/sheets/admin.py
Normal file
@ -0,0 +1,46 @@
|
||||
# Copyright (C) 2018-2022 by BDE ENS Paris-Saclay
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
from django.contrib import admin
|
||||
from note_kfet.admin import admin_site
|
||||
from sheets.models import Sheet, Food, FoodOption, Meal, Order, OrderedMeal, OrderedFood, SheetOrderTransaction
|
||||
|
||||
|
||||
@admin.register(Sheet, site=admin_site)
|
||||
class SheetAdmin(admin.ModelAdmin):
|
||||
pass
|
||||
|
||||
|
||||
@admin.register(Food, site=admin_site)
|
||||
class FoodAdmin(admin.ModelAdmin):
|
||||
pass
|
||||
|
||||
|
||||
@admin.register(FoodOption, site=admin_site)
|
||||
class FoodOptionAdmin(admin.ModelAdmin):
|
||||
pass
|
||||
|
||||
|
||||
@admin.register(Meal, site=admin_site)
|
||||
class MealAdmin(admin.ModelAdmin):
|
||||
pass
|
||||
|
||||
|
||||
@admin.register(Order, site=admin_site)
|
||||
class OrderAdmin(admin.ModelAdmin):
|
||||
pass
|
||||
|
||||
|
||||
@admin.register(OrderedMeal, site=admin_site)
|
||||
class OrderedMealAdmin(admin.ModelAdmin):
|
||||
pass
|
||||
|
||||
|
||||
@admin.register(OrderedFood, site=admin_site)
|
||||
class OrderedFoodAdmin(admin.ModelAdmin):
|
||||
pass
|
||||
|
||||
|
||||
@admin.register(SheetOrderTransaction, site=admin_site)
|
||||
class SheetOrderTransactionAdmin(admin.ModelAdmin):
|
||||
pass
|
0
apps/sheets/api/__init__.py
Normal file
0
apps/sheets/api/__init__.py
Normal file
55
apps/sheets/api/serializers.py
Normal file
55
apps/sheets/api/serializers.py
Normal file
@ -0,0 +1,55 @@
|
||||
# Copyright (C) 2018-2021 by BDE ENS Paris-Saclay
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
from rest_framework import serializers
|
||||
|
||||
|
||||
from ..models import Sheet, Food, FoodOption, Meal, Order, OrderedMeal, OrderedFood, SheetOrderTransaction
|
||||
|
||||
|
||||
class SheetSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = Sheet
|
||||
fields = '__all__'
|
||||
|
||||
|
||||
class FoodSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = Food
|
||||
fields = '__all__'
|
||||
|
||||
|
||||
class FoodOptionSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = FoodOption
|
||||
fields = '__all__'
|
||||
|
||||
|
||||
class MealSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = Meal
|
||||
fields = '__all__'
|
||||
|
||||
|
||||
class OrderSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = Order
|
||||
fields = '__all__'
|
||||
|
||||
|
||||
class OrderedMealSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = OrderedMeal
|
||||
fields = '__all__'
|
||||
|
||||
|
||||
class OrderedFoodSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = OrderedFood
|
||||
fields = '__all__'
|
||||
|
||||
|
||||
class SheetOrderTransactionSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = SheetOrderTransaction
|
||||
fields = '__all__'
|
19
apps/sheets/api/urls.py
Normal file
19
apps/sheets/api/urls.py
Normal file
@ -0,0 +1,19 @@
|
||||
# Copyright (C) 2018-2021 by BDE ENS Paris-Saclay
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
from sheets.api.views import SheetViewSet, FoodViewSet, FoodOptionViewSet, MealViewSet, OrderViewSet, \
|
||||
OrderedMealViewSet, OrderedFoodViewSet, SheetOrderTransactionViewSet
|
||||
|
||||
|
||||
def register_sheets_urls(router, path):
|
||||
"""
|
||||
Configure router for Sheets REST API.
|
||||
"""
|
||||
router.register(path + '/sheet', SheetViewSet)
|
||||
router.register(path + '/food', FoodViewSet)
|
||||
router.register(path + '/foodoption', FoodOptionViewSet)
|
||||
router.register(path + '/meal', MealViewSet)
|
||||
router.register(path + '/order', OrderViewSet)
|
||||
router.register(path + '/orderedmeal', OrderedMealViewSet)
|
||||
router.register(path + '/orderedfood', OrderedFoodViewSet)
|
||||
router.register(path + '/sheetordertransaction', SheetOrderTransactionViewSet)
|
78
apps/sheets/api/views.py
Normal file
78
apps/sheets/api/views.py
Normal file
@ -0,0 +1,78 @@
|
||||
# Copyright (C) 2018-2021 by BDE ENS Paris-Saclay
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
from api.viewsets import ReadProtectedModelViewSet
|
||||
from django_filters.rest_framework import DjangoFilterBackend
|
||||
from rest_framework.filters import SearchFilter, OrderingFilter
|
||||
|
||||
from .serializers import SheetSerializer, FoodSerializer, FoodOptionSerializer, MealSerializer, OrderSerializer, \
|
||||
OrderedMealSerializer, OrderedFoodSerializer, SheetOrderTransactionSerializer
|
||||
from ..models import Sheet, Food, FoodOption, Meal, Order, OrderedMeal, OrderedFood, SheetOrderTransaction
|
||||
|
||||
|
||||
class SheetViewSet(ReadProtectedModelViewSet):
|
||||
queryset = Sheet.objects.order_by('id')
|
||||
serializer_class = SheetSerializer
|
||||
filter_backends = [DjangoFilterBackend, SearchFilter]
|
||||
filterset_fields = ['name', 'date', ]
|
||||
search_fields = ['$name', ]
|
||||
|
||||
|
||||
class FoodViewSet(ReadProtectedModelViewSet):
|
||||
queryset = Food.objects.order_by('id')
|
||||
serializer_class = FoodSerializer
|
||||
filter_backends = [DjangoFilterBackend, SearchFilter]
|
||||
filterset_fields = ['name', 'sheet', 'price', 'club', 'available', ]
|
||||
search_fields = ['$name', ]
|
||||
|
||||
|
||||
class FoodOptionViewSet(ReadProtectedModelViewSet):
|
||||
queryset = FoodOption.objects.order_by('id')
|
||||
serializer_class = FoodOptionSerializer
|
||||
filter_backends = [DjangoFilterBackend, SearchFilter]
|
||||
filterset_fields = ['name', 'food', 'extra_cost', 'available', ]
|
||||
search_fields = ['$name', '$food__name', ]
|
||||
|
||||
|
||||
class MealViewSet(ReadProtectedModelViewSet):
|
||||
queryset = Meal.objects.order_by('id')
|
||||
serializer_class = MealSerializer
|
||||
filter_backends = [DjangoFilterBackend, SearchFilter]
|
||||
filterset_fields = ['name', 'content', 'price', 'available', ]
|
||||
search_fields = ['$name', ]
|
||||
|
||||
|
||||
class OrderViewSet(ReadProtectedModelViewSet):
|
||||
queryset = Order.objects.order_by('id')
|
||||
serializer_class = OrderSerializer
|
||||
filter_backends = [DjangoFilterBackend, SearchFilter]
|
||||
filterset_fields = ['sheet', 'note', 'date', 'gift', ]
|
||||
search_fields = ['$sheet__name', '$note__alias__name', '$note__alias__normalized_name', ]
|
||||
|
||||
|
||||
class OrderedMealViewSet(ReadProtectedModelViewSet):
|
||||
queryset = OrderedMeal.objects.order_by('id')
|
||||
serializer_class = OrderedMealSerializer
|
||||
filter_backends = [DjangoFilterBackend]
|
||||
filterset_fields = ['order', 'meal', ]
|
||||
|
||||
|
||||
class OrderedFoodViewSet(ReadProtectedModelViewSet):
|
||||
queryset = OrderedFood.objects.order_by('id')
|
||||
serializer_class = OrderedFoodSerializer
|
||||
filter_backends = [DjangoFilterBackend]
|
||||
filterset_fields = ['order', 'meal', 'food', 'options', 'number', 'status', 'served_date', ]
|
||||
|
||||
|
||||
class SheetOrderTransactionViewSet(ReadProtectedModelViewSet):
|
||||
queryset = SheetOrderTransaction.objects.order_by('-created_at')
|
||||
serializer_class = SheetOrderTransactionSerializer
|
||||
filter_backends = [DjangoFilterBackend, SearchFilter, 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',
|
||||
'created_at', 'valid', 'invalidity_reason', 'ordered_food', ]
|
||||
search_fields = ['$reason', '$source_alias', '$source__alias__name', '$source__alias__normalized_name',
|
||||
'$destination_alias', '$destination__alias__name', '$destination__alias__normalized_name',
|
||||
'$invalidity_reason', ]
|
||||
ordering_fields = ['created_at', 'amount', ]
|
10
apps/sheets/apps.py
Normal file
10
apps/sheets/apps.py
Normal file
@ -0,0 +1,10 @@
|
||||
# Copyright (C) 2018-2022 by BDE ENS Paris-Saclay
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
from django.apps import AppConfig
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
|
||||
class SheetsConfig(AppConfig):
|
||||
name = 'sheets'
|
||||
verbose_name = _('note sheets')
|
67
apps/sheets/forms.py
Normal file
67
apps/sheets/forms.py
Normal file
@ -0,0 +1,67 @@
|
||||
# Copyright (C) 2018-2022 by BDE ENS Paris-Saclay
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
from crispy_forms.helper import FormHelper
|
||||
from django import forms
|
||||
|
||||
from member.models import Club
|
||||
from note_kfet.inputs import AmountInput, Autocomplete, DateTimePickerInput
|
||||
|
||||
from .models import Food, FoodOption, Meal, Sheet
|
||||
|
||||
|
||||
class SheetForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = Sheet
|
||||
fields = '__all__'
|
||||
widgets = {
|
||||
'date': DateTimePickerInput(),
|
||||
}
|
||||
|
||||
|
||||
class FoodForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = Food
|
||||
exclude = ('sheet', )
|
||||
widgets = {
|
||||
'price': AmountInput(),
|
||||
'club': Autocomplete(
|
||||
model=Club,
|
||||
attrs={"api_url": "/api/members/club/"},
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
class FoodOptionForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = FoodOption
|
||||
fields = '__all__'
|
||||
widgets = {
|
||||
'extra_cost': AmountInput(),
|
||||
}
|
||||
|
||||
|
||||
FoodOptionsFormSet = forms.inlineformset_factory(
|
||||
Food,
|
||||
FoodOption,
|
||||
form=FoodOptionForm,
|
||||
extra=0,
|
||||
)
|
||||
|
||||
|
||||
class FoodOptionFormSetHelper(FormHelper):
|
||||
def __init__(self, form=None):
|
||||
super().__init__(form)
|
||||
self.form_tag = False
|
||||
self.form_method = 'POST'
|
||||
self.form_class = 'form-inline'
|
||||
self.template = 'bootstrap4/table_inline_formset.html'
|
||||
|
||||
|
||||
class MealForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = Meal
|
||||
exclude = ('sheet', )
|
||||
widgets = {
|
||||
'content': forms.CheckboxSelectMultiple(),
|
||||
'price': AmountInput(),
|
||||
}
|
157
apps/sheets/migrations/0001_initial.py
Normal file
157
apps/sheets/migrations/0001_initial.py
Normal file
@ -0,0 +1,157 @@
|
||||
# Generated by Django 2.2.27 on 2022-08-18 11:01
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
import django.utils.timezone
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
('member', '0009_auto_20220818_1301'),
|
||||
('note', '0006_trust'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Food',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(max_length=255, verbose_name='food')),
|
||||
('price', models.IntegerField(verbose_name='price')),
|
||||
('available', models.BooleanField(default=True, help_text="If set to false, this option won't be offered (in case of out of stock)", verbose_name='available')),
|
||||
('club', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='member.Club', verbose_name='destination club')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'food',
|
||||
'verbose_name_plural': 'food',
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='FoodOption',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(max_length=255, verbose_name='name')),
|
||||
('extra_cost', models.IntegerField(default=0, verbose_name='extra cost')),
|
||||
('available', models.BooleanField(default=True, help_text="If set to false, this option won't be offered (in case of out of stock)", verbose_name='available')),
|
||||
('food', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='sheets.Food', verbose_name='food')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'food option',
|
||||
'verbose_name_plural': 'food options',
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Meal',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(max_length=255, verbose_name='name')),
|
||||
('price', models.IntegerField(verbose_name='price')),
|
||||
('available', models.BooleanField(default=True, help_text="If set to false, this option won't be offered (in case of out of stock)", verbose_name='available')),
|
||||
('content', models.ManyToManyField(to='sheets.Food', verbose_name='content')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'meal',
|
||||
'verbose_name_plural': 'meals',
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Order',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('date', models.DateTimeField(auto_now_add=True, verbose_name='date')),
|
||||
('gift', models.IntegerField(verbose_name='gift')),
|
||||
('note', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='note.Note', verbose_name='note')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'order',
|
||||
'verbose_name_plural': 'orders',
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='OrderedFood',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('remark', models.TextField(blank=True, default='', verbose_name='remark')),
|
||||
('priority', models.CharField(blank=True, default='', max_length=64, verbose_name='priority request')),
|
||||
('number', models.IntegerField(help_text='How many times the user ordered this.', verbose_name='number')),
|
||||
('status', models.CharField(choices=[('QUEUED', 'queued'), ('READY', 'ready'), ('SERVED', 'served'), ('CANCELED', 'canceled')], max_length=8, verbose_name='status')),
|
||||
('served_date', models.DateTimeField(default=None, null=True, verbose_name='served date')),
|
||||
('food', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='sheets.Food', verbose_name='food')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'ordered food',
|
||||
'verbose_name_plural': 'ordered food',
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Sheet',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(max_length=255, verbose_name='name')),
|
||||
('date', models.DateTimeField(default=django.utils.timezone.now, verbose_name='start date')),
|
||||
('description', models.TextField(verbose_name='description')),
|
||||
('visible', models.BooleanField(default=False, help_text='the note sheet will be private until this field is checked.', verbose_name='visible')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'note sheet',
|
||||
'verbose_name_plural': 'note sheets',
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='SheetOrderTransaction',
|
||||
fields=[
|
||||
('transaction_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='note.Transaction')),
|
||||
('ordered_food', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='sheets.OrderedFood', verbose_name='ordered food')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'sheet order transaction',
|
||||
'verbose_name_plural': 'sheet order transactions',
|
||||
},
|
||||
bases=('note.transaction',),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='OrderedMeal',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('meal', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='sheets.Meal', verbose_name='meal')),
|
||||
('order', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='sheets.Order', verbose_name='order')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'ordered meal',
|
||||
'verbose_name_plural': 'ordered meals',
|
||||
},
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='orderedfood',
|
||||
name='meal',
|
||||
field=models.ForeignKey(default=None, null=True, on_delete=django.db.models.deletion.SET_NULL, to='sheets.OrderedMeal', verbose_name='ordered meal'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='orderedfood',
|
||||
name='options',
|
||||
field=models.ManyToManyField(blank=True, to='sheets.FoodOption', verbose_name='options'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='orderedfood',
|
||||
name='order',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='sheets.Order', verbose_name='order'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='order',
|
||||
name='sheet',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='sheets.Sheet', verbose_name='note sheet'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='meal',
|
||||
name='sheet',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='sheets.Sheet', verbose_name='note sheet'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='food',
|
||||
name='sheet',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='sheets.Sheet', verbose_name='note sheet'),
|
||||
),
|
||||
]
|
34
apps/sheets/migrations/0002_auto_20220818_1713.py
Normal file
34
apps/sheets/migrations/0002_auto_20220818_1713.py
Normal file
@ -0,0 +1,34 @@
|
||||
# Generated by Django 2.2.27 on 2022-08-18 15:13
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('sheets', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name='order',
|
||||
name='gift',
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='orderedfood',
|
||||
name='gift',
|
||||
field=models.IntegerField(default=0, verbose_name='gift'),
|
||||
preserve_default=False,
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='orderedmeal',
|
||||
name='gift',
|
||||
field=models.IntegerField(default=0, verbose_name='gift'),
|
||||
preserve_default=False,
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='orderedfood',
|
||||
name='status',
|
||||
field=models.CharField(choices=[('QUEUED', 'queued'), ('READY', 'ready'), ('SERVED', 'served'), ('CANCELED', 'canceled')], default='QUEUED', max_length=8, verbose_name='status'),
|
||||
),
|
||||
]
|
0
apps/sheets/migrations/__init__.py
Normal file
0
apps/sheets/migrations/__init__.py
Normal file
289
apps/sheets/models.py
Normal file
289
apps/sheets/models.py
Normal file
@ -0,0 +1,289 @@
|
||||
# Copyright (C) 2018-2021 by BDE ENS Paris-Saclay
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
from django.db import models
|
||||
from django.urls import reverse_lazy
|
||||
from django.utils import timezone
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from member.models import Club
|
||||
from note.models import Note, Transaction
|
||||
|
||||
|
||||
class Sheet(models.Model):
|
||||
name = models.CharField(
|
||||
max_length=255,
|
||||
verbose_name=_("name"),
|
||||
)
|
||||
|
||||
date = models.DateTimeField(
|
||||
verbose_name=_("start date"),
|
||||
default=timezone.now,
|
||||
)
|
||||
|
||||
description = models.TextField(
|
||||
verbose_name=_("description"),
|
||||
)
|
||||
|
||||
visible = models.BooleanField(
|
||||
default=False,
|
||||
verbose_name=_("visible"),
|
||||
help_text=_("the note sheet will be private until this field is checked."),
|
||||
)
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse_lazy('sheets:sheet_detail', args=(self.pk,))
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("note sheet")
|
||||
verbose_name_plural = _("note sheets")
|
||||
|
||||
|
||||
class Food(models.Model):
|
||||
name = models.CharField(
|
||||
max_length=255,
|
||||
verbose_name=_("food"),
|
||||
)
|
||||
|
||||
sheet = models.ForeignKey(
|
||||
Sheet,
|
||||
on_delete=models.CASCADE,
|
||||
verbose_name=_("note sheet"),
|
||||
)
|
||||
|
||||
price = models.IntegerField(
|
||||
verbose_name=_("price"),
|
||||
)
|
||||
|
||||
club = models.ForeignKey(
|
||||
Club,
|
||||
on_delete=models.PROTECT,
|
||||
verbose_name=_("destination club"),
|
||||
)
|
||||
|
||||
available = models.BooleanField(
|
||||
default=True,
|
||||
verbose_name=_("available"),
|
||||
help_text=_("If set to false, this option won't be offered (in case of out of stock)"),
|
||||
)
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("food")
|
||||
verbose_name_plural = _("food")
|
||||
|
||||
|
||||
class FoodOption(models.Model):
|
||||
name = models.CharField(
|
||||
max_length=255,
|
||||
verbose_name=_("name"),
|
||||
)
|
||||
|
||||
food = models.ForeignKey(
|
||||
Food,
|
||||
on_delete=models.CASCADE,
|
||||
verbose_name=_("food"),
|
||||
)
|
||||
|
||||
extra_cost = models.IntegerField(
|
||||
default=0,
|
||||
verbose_name=_("extra cost"),
|
||||
)
|
||||
|
||||
available = models.BooleanField(
|
||||
default=True,
|
||||
verbose_name=_("available"),
|
||||
help_text=_("If set to false, this option won't be offered (in case of out of stock)"),
|
||||
)
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("food option")
|
||||
verbose_name_plural = _("food options")
|
||||
|
||||
|
||||
class Meal(models.Model):
|
||||
sheet = models.ForeignKey(
|
||||
Sheet,
|
||||
on_delete=models.CASCADE,
|
||||
verbose_name=_("note sheet"),
|
||||
)
|
||||
|
||||
name = models.CharField(
|
||||
max_length=255,
|
||||
verbose_name=_("name"),
|
||||
)
|
||||
|
||||
content = models.ManyToManyField(
|
||||
Food,
|
||||
verbose_name=_("content"),
|
||||
)
|
||||
|
||||
price = models.IntegerField(
|
||||
verbose_name=_("price"),
|
||||
)
|
||||
|
||||
available = models.BooleanField(
|
||||
default=True,
|
||||
verbose_name=_("available"),
|
||||
help_text=_("If set to false, this option won't be offered (in case of out of stock)"),
|
||||
)
|
||||
|
||||
def __str__(self):
|
||||
return _("meal").capitalize() + " " + self.name
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("meal")
|
||||
verbose_name_plural = _("meals")
|
||||
|
||||
|
||||
class Order(models.Model):
|
||||
sheet = models.ForeignKey(
|
||||
Sheet,
|
||||
on_delete=models.PROTECT,
|
||||
verbose_name=_("note sheet"),
|
||||
)
|
||||
|
||||
note = models.ForeignKey(
|
||||
Note,
|
||||
on_delete=models.PROTECT,
|
||||
verbose_name=_("note"),
|
||||
)
|
||||
|
||||
date = models.DateTimeField(
|
||||
verbose_name=_("date"),
|
||||
auto_now_add=True,
|
||||
)
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("order")
|
||||
verbose_name_plural = _("orders")
|
||||
|
||||
|
||||
class OrderedMeal(models.Model):
|
||||
order = models.ForeignKey(
|
||||
Order,
|
||||
on_delete=models.PROTECT,
|
||||
verbose_name=_("order"),
|
||||
)
|
||||
|
||||
meal = models.ForeignKey(
|
||||
Meal,
|
||||
on_delete=models.PROTECT,
|
||||
verbose_name=_("meal"),
|
||||
)
|
||||
|
||||
gift = models.IntegerField(
|
||||
verbose_name=_("gift"),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("ordered meal")
|
||||
verbose_name_plural = _("ordered meals")
|
||||
|
||||
|
||||
class OrderedFood(models.Model):
|
||||
order = models.ForeignKey(
|
||||
Order,
|
||||
on_delete=models.PROTECT,
|
||||
verbose_name=_("order"),
|
||||
)
|
||||
|
||||
meal = models.ForeignKey(
|
||||
OrderedMeal,
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
default=None,
|
||||
verbose_name=_("ordered meal"),
|
||||
)
|
||||
|
||||
food = models.ForeignKey(
|
||||
Food,
|
||||
on_delete=models.PROTECT,
|
||||
verbose_name=_("food"),
|
||||
)
|
||||
|
||||
options = models.ManyToManyField(
|
||||
FoodOption,
|
||||
blank=True,
|
||||
verbose_name=_("options"),
|
||||
)
|
||||
|
||||
remark = models.TextField(
|
||||
blank=True,
|
||||
default="",
|
||||
verbose_name=_("remark"),
|
||||
)
|
||||
|
||||
priority = models.CharField(
|
||||
max_length=64,
|
||||
blank=True,
|
||||
default="",
|
||||
verbose_name=_("priority request"),
|
||||
)
|
||||
|
||||
gift = models.IntegerField(
|
||||
verbose_name=_("gift"),
|
||||
)
|
||||
|
||||
number = models.IntegerField(
|
||||
verbose_name=_("number"),
|
||||
help_text=_("How many times the user ordered this."),
|
||||
)
|
||||
|
||||
status = models.CharField(
|
||||
max_length=8,
|
||||
choices=[
|
||||
('QUEUED', _("queued")),
|
||||
('READY', _("ready")),
|
||||
('SERVED', _("served")),
|
||||
('CANCELED', _("canceled")),
|
||||
],
|
||||
default='QUEUED',
|
||||
verbose_name=_("status"),
|
||||
)
|
||||
|
||||
served_date = models.DateTimeField(
|
||||
null=True,
|
||||
default=None,
|
||||
verbose_name=_("served date")
|
||||
)
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("ordered food")
|
||||
verbose_name_plural = _("ordered food")
|
||||
|
||||
|
||||
class SheetOrderTransaction(Transaction):
|
||||
ordered_food = models.ForeignKey(
|
||||
OrderedFood,
|
||||
on_delete=models.PROTECT,
|
||||
verbose_name=_("ordered food"),
|
||||
)
|
||||
|
||||
@property
|
||||
def type(self):
|
||||
return _("note sheet")
|
||||
|
||||
@property
|
||||
def get_price(self):
|
||||
if self.ordered_food.meal:
|
||||
return self.ordered_food.meal.meal.price + self.ordered_food.meal.gift + sum(
|
||||
sum(opt.extra_cost for opt in ordered_food.options.all())
|
||||
for ordered_food in self.ordered_food.meal.orderedfood_set.exclude(status='CANCELED').all())
|
||||
elif self.ordered_food.status == 'CANCELED':
|
||||
return 0
|
||||
else:
|
||||
return self.ordered_food.food.price + self.ordered_food.gift \
|
||||
+ sum(opt.extra_cost for opt in self.ordered_food.options.all())
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("sheet order transaction")
|
||||
verbose_name_plural = _("sheet order transactions")
|
22
apps/sheets/tables.py
Normal file
22
apps/sheets/tables.py
Normal file
@ -0,0 +1,22 @@
|
||||
# Copyright (C) 2018-2022 by BDE ENS Paris-Saclay
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
import django_tables2 as tables
|
||||
from django.urls import reverse_lazy
|
||||
|
||||
from sheets.models import Sheet
|
||||
|
||||
|
||||
class SheetTable(tables.Table):
|
||||
class Meta:
|
||||
attrs = {
|
||||
'class': 'table table-condensed table-striped table-hover'
|
||||
}
|
||||
model = Sheet
|
||||
template_name = 'django_tables2/bootstrap4.html'
|
||||
fields = ('name', 'date', )
|
||||
row_attrs = {
|
||||
'class': 'table-row',
|
||||
'id': lambda record: "row-" + str(record.pk),
|
||||
'data-href': lambda record: reverse_lazy('sheets:sheet_detail', args=(record.pk,))
|
||||
}
|
86
apps/sheets/templates/sheets/food_form.html
Normal file
86
apps/sheets/templates/sheets/food_form.html
Normal file
@ -0,0 +1,86 @@
|
||||
{% extends "base.html" %}
|
||||
{% comment %}
|
||||
SPDX-License-Identifier: GPL-3.0-or-later
|
||||
{% endcomment %}
|
||||
{% load crispy_forms_tags %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block content %}
|
||||
<div class="card bg-light mb-3">
|
||||
<h3 class="card-header text-center">
|
||||
{{ title }}
|
||||
</h3>
|
||||
<div class="card-body">
|
||||
<form method="post">
|
||||
{% csrf_token %}
|
||||
{{ form|crispy }}
|
||||
|
||||
{# The next part concerns the option formset #}
|
||||
{# Generate some hidden fields that manage the number of options, and make easier the parsing #}
|
||||
{{ formset.management_form }}
|
||||
<table class="table table-condensed table-striped">
|
||||
{# Fill initial data #}
|
||||
{% for form in formset %}
|
||||
{% if forloop.first %}
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ form.name.label }}<span class="asteriskField">*</span></th>
|
||||
<th>{{ form.extra_cost.label }}<span class="asteriskField">*</span></th>
|
||||
<th>{{ form.available.label }}<span class="asteriskField">*</span></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="form_body">
|
||||
{% endif %}
|
||||
<tr class="row-formset">
|
||||
<td>{{ form.name }}</td>
|
||||
<td>{{ form.extra_cost }}</td>
|
||||
<td>{{ form.available }}</td>
|
||||
{# These fields are hidden but handled by the formset to link the id and the invoice id #}
|
||||
{{ form.food }}
|
||||
{{ form.id }}
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{# Display buttons to add and remove options #}
|
||||
<div class="card-body">
|
||||
<button type="button" id="add_more" class="btn btn-success">{% trans "Add option" %}</button>
|
||||
</div>
|
||||
|
||||
<button class="btn btn-primary" type="submit">{% trans "Submit" %}</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Hidden div that store an empty product form, to be copied into new forms #}
|
||||
<div id="empty_form" style="display: none;">
|
||||
<table class='no_error'>
|
||||
<tbody id="for_real">
|
||||
<tr class="row-formset">
|
||||
<td>{{ formset.empty_form.name }}</td>
|
||||
<td>{{ formset.empty_form.extra_cost }} </td>
|
||||
<td>{{ formset.empty_form.available }}</td>
|
||||
{{ formset.empty_form.food }}
|
||||
{{ formset.empty_form.id }}
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extrajavascript %}
|
||||
<script>
|
||||
/* script that handles add and remove lines */
|
||||
IDS = {};
|
||||
|
||||
$("#id_foodoption_set-TOTAL_FORMS").val($(".row-formset").length - 1);
|
||||
|
||||
$('#add_more').click(function () {
|
||||
let form_idx = $('#id_foodoption_set-TOTAL_FORMS').val();
|
||||
$('#form_body').append($('#for_real').html().replace(/__prefix__/g, form_idx));
|
||||
$('#id_foodoption_set-TOTAL_FORMS').val(parseInt(form_idx) + 1);
|
||||
$('#id_foodoption_set-' + parseInt(form_idx) + '-id').val(IDS[parseInt(form_idx)]);
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
21
apps/sheets/templates/sheets/meal_form.html
Normal file
21
apps/sheets/templates/sheets/meal_form.html
Normal file
@ -0,0 +1,21 @@
|
||||
{% extends "base.html" %}
|
||||
{% comment %}
|
||||
SPDX-License-Identifier: GPL-3.0-or-later
|
||||
{% endcomment %}
|
||||
{% load crispy_forms_tags %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block content %}
|
||||
<div class="card bg-light mb-3">
|
||||
<h3 class="card-header text-center">
|
||||
{{ title }}
|
||||
</h3>
|
||||
<div class="card-body">
|
||||
<form method="post">
|
||||
{% csrf_token %}
|
||||
{{ form|crispy }}
|
||||
<button class="btn btn-primary" type="submit">{% trans "Submit" %}</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
87
apps/sheets/templates/sheets/sheet_detail.html
Normal file
87
apps/sheets/templates/sheets/sheet_detail.html
Normal file
@ -0,0 +1,87 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% load i18n %}
|
||||
{% load pretty_money %}
|
||||
|
||||
{% block content %}
|
||||
<div class="card">
|
||||
<div class="card-header text-center">
|
||||
<h1>{{ sheet.name }}</h1>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="alert alert-secondary">
|
||||
<div class="row">
|
||||
<div class="col-sm-11">
|
||||
{{ sheet.description }}
|
||||
</div>
|
||||
{% if can_change_sheet %}
|
||||
<div class="col-sm-1">
|
||||
<a class="badge badge-primary" href="{% url 'sheets:sheet_update' pk=sheet.pk %}">
|
||||
<i class="fa fa-edit"></i>
|
||||
{% trans "Edit" %}
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3>{% trans "menu"|capfirst %} :</h3>
|
||||
<ul>
|
||||
{% for meal in sheet.meal_set.all %}
|
||||
<li{% if not meal.available %} class="text-danger" style="text-decoration: line-through !important;" title="{% trans "This product is unavailable." %}"{% endif %}>
|
||||
{{ meal }} ({{ meal.price|pretty_money }})
|
||||
{% if can_change_sheet %}
|
||||
<a href="{% url 'sheets:meal_update' pk=meal.pk %}" class="badge badge-primary">
|
||||
<i class="fa fa-edit"></i>
|
||||
{% trans "Edit" %}
|
||||
</a>
|
||||
{% endif %}
|
||||
</li>
|
||||
{% endfor %}
|
||||
<hr>
|
||||
{% for food in sheet.food_set.all %}
|
||||
<li{% if not food.available %} class="text-danger" style="text-decoration: line-through !important;" title="{% trans "This product is unavailable." %}"{% endif %}>
|
||||
{{ food }} ({{ food.price|pretty_money }})
|
||||
<a href="{% url 'sheets:waiting_list' pk=food.pk %}" class="badge badge-primary">
|
||||
<i class="fa fa-list"></i>
|
||||
{% trans "Waiting list" %}
|
||||
</a>
|
||||
{% if can_change_sheet %}
|
||||
<a href="{% url 'sheets:food_update' pk=food.pk %}" class="badge badge-primary">
|
||||
<i class="fa fa-edit"></i>
|
||||
{% trans "Edit" %}
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if food.foodoption_set.all %}
|
||||
<ul>
|
||||
{% for option in food.foodoption_set.all %}
|
||||
<li{% if not option.available %} class="text-danger" style="text-decoration: line-through !important;" title="{% trans "This product is unavailable." %}"{% endif %}>
|
||||
{{ option }}{% if option.extra_cost %} ({{ option.extra_cost|pretty_money }}){% endif %}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
</li>
|
||||
{% empty %}
|
||||
<div class="alert alert-warning">
|
||||
{% trans "The menu is empty for now." %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
|
||||
<div class="text-center">
|
||||
{% if can_add_food %}
|
||||
<a href="{% url 'sheets:food_create' pk=sheet.pk %}" class="btn btn-primary">{% trans "Add new food" %}</a>
|
||||
{% endif %}
|
||||
{% if can_add_meal %}
|
||||
<a href="{% url 'sheets:meal_create' pk=sheet.pk %}" class="btn btn-primary">{% trans "Add new meal" %}</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-footer text-center">
|
||||
<a href="{% url 'sheets:sheet_order' pk=sheet.pk %}" class="btn btn-success">
|
||||
{% trans "Order now" %}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
21
apps/sheets/templates/sheets/sheet_form.html
Normal file
21
apps/sheets/templates/sheets/sheet_form.html
Normal file
@ -0,0 +1,21 @@
|
||||
{% extends "base.html" %}
|
||||
{% comment %}
|
||||
SPDX-License-Identifier: GPL-3.0-or-later
|
||||
{% endcomment %}
|
||||
{% load crispy_forms_tags %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block content %}
|
||||
<div class="card bg-light mb-3">
|
||||
<h3 class="card-header text-center">
|
||||
{{ title }}
|
||||
</h3>
|
||||
<div class="card-body">
|
||||
<form method="post">
|
||||
{% csrf_token %}
|
||||
{{ form|crispy }}
|
||||
<button class="btn btn-primary" type="submit">{% trans "Submit" %}</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
74
apps/sheets/templates/sheets/sheet_list.html
Normal file
74
apps/sheets/templates/sheets/sheet_list.html
Normal file
@ -0,0 +1,74 @@
|
||||
{% extends "base.html" %}
|
||||
{% comment %}
|
||||
SPDX-License-Identifier: GPL-3.0-or-later
|
||||
{% endcomment %}
|
||||
{% load render_table from django_tables2 %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row justify-content-center mb-4">
|
||||
<div class="col-md-10 text-center">
|
||||
<input class="form-control mx-auto w-25" type="text" onkeyup="search_field_moved()" id="search_field"/>
|
||||
{% if can_create_sheet %}
|
||||
<hr>
|
||||
<a class="btn btn-primary text-center my-4" href="{% url 'sheets:sheet_create' %}">{% trans "Create a sheet" %}</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-md-10">
|
||||
<div class="card card-border shadow">
|
||||
<div class="card-header text-center">
|
||||
<h5> {% trans "Note sheet listing" %}</h5>
|
||||
</div>
|
||||
<div class="card-body px-0 py-0" id="sheets_table">
|
||||
{% render_table table %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
{% block extrajavascript %}
|
||||
<script type="text/javascript">
|
||||
|
||||
function getInfo() {
|
||||
var asked = $("#search_field").val();
|
||||
/* on ne fait la requête que si on a au moins un caractère pour chercher */
|
||||
var sel = $(".table-row");
|
||||
if (asked.length >= 1) {
|
||||
$.getJSON("/api/sheets/sheet/?format=json&search="+asked, function(buttons){
|
||||
let selected_id = buttons.results.map((a => "#row-"+a.id));
|
||||
if (selected_id.length)
|
||||
$(".table-row,"+selected_id.join()).show();
|
||||
$(".table-row").not(selected_id.join()).hide();
|
||||
|
||||
});
|
||||
}else{
|
||||
// show everything
|
||||
$('table tr').show();
|
||||
}
|
||||
}
|
||||
var timer;
|
||||
var timer_on;
|
||||
/* Fontion appelée quand le texte change (délenche le timer) */
|
||||
function search_field_moved(secondfield) {
|
||||
if (timer_on) { // Si le timer a déjà été lancé, on réinitialise le compteur.
|
||||
clearTimeout(timer);
|
||||
timer = setTimeout("getInfo(" + secondfield + ")", 300);
|
||||
}
|
||||
else { // Sinon, on le lance et on enregistre le fait qu'il tourne.
|
||||
timer = setTimeout("getInfo(" + secondfield + ")", 300);
|
||||
timer_on = true;
|
||||
}
|
||||
}
|
||||
|
||||
// clickable row
|
||||
$(document).ready(function($) {
|
||||
$(".table-row").click(function() {
|
||||
window.document.location = $(this).data("href");
|
||||
});
|
||||
});
|
||||
|
||||
</script>
|
||||
{% endblock %}
|
0
apps/sheets/tests/__init__.py
Normal file
0
apps/sheets/tests/__init__.py
Normal file
26
apps/sheets/urls.py
Normal file
26
apps/sheets/urls.py
Normal file
@ -0,0 +1,26 @@
|
||||
# Copyright (C) 2018-2022 by BDE ENS Paris-Saclay
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
from django.urls import path
|
||||
|
||||
from sheets.views import FoodCreateView, FoodUpdateView, MealCreateView, MealUpdateView, OrderView, \
|
||||
SheetCreateView, SheetDetailView, SheetListView, SheetUpdateView, WaitingListDetailView, WaitingListView
|
||||
|
||||
app_name = 'sheets'
|
||||
|
||||
urlpatterns = [
|
||||
path('list/', SheetListView.as_view(), name="sheet_list"),
|
||||
path('create/', SheetCreateView.as_view(), name="sheet_create"),
|
||||
path('update/<int:pk>/', SheetUpdateView.as_view(), name="sheet_update"),
|
||||
path('detail/<int:pk>/', SheetDetailView.as_view(), name="sheet_detail"),
|
||||
path('food/create/<int:pk>/', FoodCreateView.as_view(), name="food_create"),
|
||||
path('food/<int:pk>/update/', FoodUpdateView.as_view(), name="food_update"),
|
||||
path('meal/create/<int:pk>/', MealCreateView.as_view(), name="meal_create"),
|
||||
path('meal/<int:pk>/update/', MealUpdateView.as_view(), name="meal_update"),
|
||||
path('order/<int:pk>/', OrderView.as_view(), name="sheet_order"),
|
||||
path('waiting-list/<int:pk>/', WaitingListView.as_view(), name="waiting_list"),
|
||||
path('waiting-list/<int:pk>/queued/', WaitingListDetailView.as_view(), name="queued_list"),
|
||||
path('waiting-list/<int:pk>/ready/', WaitingListDetailView.as_view(), name="ready_list"),
|
||||
path('waiting-list/<int:pk>/served/', WaitingListDetailView.as_view(), name="served_list"),
|
||||
path('waiting-list/<int:pk>/canceled/', WaitingListDetailView.as_view(), name="canceled_list"),
|
||||
]
|
444
apps/sheets/views.py
Normal file
444
apps/sheets/views.py
Normal file
@ -0,0 +1,444 @@
|
||||
# Copyright (C) 2018-2022 by BDE ENS Paris-Saclay
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
from datetime import timedelta
|
||||
|
||||
from crispy_forms.bootstrap import Accordion, AccordionGroup, FormActions
|
||||
from crispy_forms.helper import FormHelper
|
||||
from crispy_forms.layout import Fieldset, Submit, Row, Field
|
||||
from django import forms
|
||||
from django.contrib.auth.mixins import LoginRequiredMixin
|
||||
from django.db import transaction
|
||||
from django.forms import Form
|
||||
from django.urls import reverse_lazy
|
||||
from django.utils import timezone
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.views.generic import DetailView, UpdateView, FormView
|
||||
from django_tables2 import SingleTableView
|
||||
|
||||
from note.models import Alias, Note
|
||||
from note.templatetags.pretty_money import pretty_money
|
||||
from note_kfet.inputs import AmountInput, Autocomplete
|
||||
from permission.backends import PermissionBackend
|
||||
from permission.views import ProtectQuerysetMixin, ProtectedCreateView
|
||||
|
||||
from .forms import FoodForm, MealForm, SheetForm, FoodOptionsFormSet, FoodOptionFormSetHelper
|
||||
from .models import Sheet, Food, Meal, Order, OrderedMeal, OrderedFood, SheetOrderTransaction
|
||||
from .tables import SheetTable
|
||||
|
||||
|
||||
class SheetListView(ProtectQuerysetMixin, LoginRequiredMixin, SingleTableView):
|
||||
model = Sheet
|
||||
table_class = SheetTable
|
||||
ordering = '-date'
|
||||
extra_context = {"title": _("Search note sheet")}
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
context["can_create_sheet"] = PermissionBackend.check_perm(self.request, "sheets.add_sheet", Sheet(
|
||||
name="Test",
|
||||
date=timezone.now(),
|
||||
description="Test sheet",
|
||||
))
|
||||
return context
|
||||
|
||||
|
||||
class SheetCreateView(ProtectQuerysetMixin, ProtectedCreateView):
|
||||
model = Sheet
|
||||
form_class = SheetForm
|
||||
extra_context = {"title": _("Create note sheet")}
|
||||
|
||||
def get_sample_object(self):
|
||||
return Sheet(
|
||||
name="Test",
|
||||
date=timezone.now(),
|
||||
description="Test",
|
||||
)
|
||||
|
||||
|
||||
class SheetUpdateView(ProtectQuerysetMixin, LoginRequiredMixin, UpdateView):
|
||||
model = Sheet
|
||||
form_class = SheetForm
|
||||
extra_context = {"title": _("Update note sheet")}
|
||||
|
||||
|
||||
class SheetDetailView(ProtectQuerysetMixin, LoginRequiredMixin, DetailView):
|
||||
model = Sheet
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data()
|
||||
|
||||
context['can_change_sheet'] = PermissionBackend.check_perm(self.request, 'sheets.change_sheet', self.object)
|
||||
context['can_add_meal'] = PermissionBackend.check_perm(self.request,
|
||||
'sheets.add_meal',
|
||||
Meal(sheet=self.object, name="Test", price=500))
|
||||
context['can_add_food'] = PermissionBackend.check_perm(self.request,
|
||||
'sheets.add_food',
|
||||
Food(sheet=self.object, name="Test", price=500))
|
||||
|
||||
return context
|
||||
|
||||
|
||||
class FoodCreateView(ProtectQuerysetMixin, ProtectedCreateView):
|
||||
model = Food
|
||||
form_class = FoodForm
|
||||
extra_context = {"title": _("Create new food")}
|
||||
|
||||
def get_sample_object(self):
|
||||
return Food(
|
||||
sheet_id=self.kwargs['pk'],
|
||||
name="Test",
|
||||
price=500,
|
||||
)
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
|
||||
form = context['form']
|
||||
form.helper = FormHelper()
|
||||
# Remove form tag on the generation of the form in the template (already present on the template)
|
||||
form.helper.form_tag = False
|
||||
# The formset handles the set of the products
|
||||
form_set = FoodOptionsFormSet(instance=form.instance)
|
||||
context['formset'] = form_set
|
||||
context['helper'] = FoodOptionFormSetHelper()
|
||||
|
||||
return context
|
||||
|
||||
def form_valid(self, form):
|
||||
form.instance.sheet_id = self.kwargs['pk']
|
||||
|
||||
# For each product, we save it
|
||||
formset = FoodOptionsFormSet(self.request.POST, instance=form.instance)
|
||||
if formset.is_valid():
|
||||
for f in formset:
|
||||
# We don't save the product if the designation is not entered, ie. if the line is empty
|
||||
if f.is_valid() and f.instance.name:
|
||||
f.save()
|
||||
f.instance.save()
|
||||
else:
|
||||
f.instance = None
|
||||
|
||||
return super().form_valid(form)
|
||||
|
||||
def get_success_url(self):
|
||||
return reverse_lazy('sheets:sheet_detail', args=(self.kwargs['pk'],))
|
||||
|
||||
|
||||
class FoodUpdateView(ProtectQuerysetMixin, LoginRequiredMixin, UpdateView):
|
||||
model = Food
|
||||
form_class = FoodForm
|
||||
extra_context = {"title": _("Update food")}
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
|
||||
form = context['form']
|
||||
form.helper = FormHelper()
|
||||
# Remove form tag on the generation of the form in the template (already present on the template)
|
||||
form.helper.form_tag = False
|
||||
# The formset handles the set of the products
|
||||
form_set = FoodOptionsFormSet(instance=form.instance)
|
||||
context['formset'] = form_set
|
||||
context['helper'] = FoodOptionFormSetHelper()
|
||||
|
||||
return context
|
||||
|
||||
def form_valid(self, form):
|
||||
# For each product, we save it
|
||||
formset = FoodOptionsFormSet(self.request.POST, instance=form.instance)
|
||||
if formset.is_valid():
|
||||
for f in formset:
|
||||
# We don't save the product if the designation is not entered, ie. if the line is empty
|
||||
if f.is_valid() and f.instance.name:
|
||||
f.save()
|
||||
f.instance.save()
|
||||
else:
|
||||
f.instance = None
|
||||
|
||||
return super().form_valid(form)
|
||||
|
||||
def get_success_url(self):
|
||||
return reverse_lazy('sheets:sheet_detail', args=(self.object.sheet_id,))
|
||||
|
||||
|
||||
class MealCreateView(ProtectQuerysetMixin, ProtectedCreateView):
|
||||
model = Meal
|
||||
form_class = MealForm
|
||||
extra_context = {"title": _("Create new meal")}
|
||||
|
||||
def get_sample_object(self):
|
||||
return Meal(
|
||||
sheet_id=self.kwargs['pk'],
|
||||
name="Test",
|
||||
price=500,
|
||||
)
|
||||
|
||||
def get_form(self, form_class=None):
|
||||
form = super().get_form(form_class)
|
||||
form.fields['content'].queryset = form.fields['content'].queryset.filter(sheet_id=self.kwargs['pk'])
|
||||
return form
|
||||
|
||||
def form_valid(self, form):
|
||||
form.instance.sheet_id = self.kwargs['pk']
|
||||
return super().form_valid(form)
|
||||
|
||||
def get_success_url(self):
|
||||
return reverse_lazy('sheets:sheet_detail', args=(self.object.sheet_id,))
|
||||
|
||||
|
||||
class MealUpdateView(ProtectQuerysetMixin, LoginRequiredMixin, UpdateView):
|
||||
model = Meal
|
||||
form_class = MealForm
|
||||
extra_context = {"title": _("Update meal")}
|
||||
|
||||
def get_form(self, form_class=None):
|
||||
form = super().get_form(form_class)
|
||||
form.fields['content'].queryset = form.fields['content'].queryset.filter(sheet=self.object.sheet)
|
||||
return form
|
||||
|
||||
def get_success_url(self):
|
||||
return reverse_lazy('sheets:sheet_detail', args=(self.object.sheet_id,))
|
||||
|
||||
|
||||
class OrderView(LoginRequiredMixin, FormView, DetailView):
|
||||
model = Sheet
|
||||
template_name = 'sheets/order.html'
|
||||
extra_context = {'title': _("Order now")}
|
||||
|
||||
def get_form(self, form_class=None):
|
||||
form = Form()
|
||||
form.helper = FormHelper()
|
||||
layout_fields = []
|
||||
|
||||
self.object = self.get_object()
|
||||
|
||||
form.fields['note'] = forms.ModelChoiceField(
|
||||
queryset=Note.objects.filter(PermissionBackend.filter_queryset(self.request, Note, 'note.view_note')),
|
||||
label=_("Orderer"),
|
||||
initial=self.request.user.note,
|
||||
widget=Autocomplete(
|
||||
model=Note,
|
||||
attrs={
|
||||
"api_url": "/api/note/note/",
|
||||
'placeholder': _("Who orders")
|
||||
},
|
||||
),
|
||||
)
|
||||
layout_fields.append(Field('note', css_class='is-valid'))
|
||||
|
||||
for meal in self.object.meal_set.filter(available=True).all():
|
||||
form.fields[f'meal_{meal.id}_quantity'] = forms.IntegerField(
|
||||
label=_("Quantity"),
|
||||
initial=0,
|
||||
)
|
||||
form.fields[f'meal_{meal.id}_gift'] = forms.IntegerField(
|
||||
label=_("gift").capitalize(),
|
||||
initial=0,
|
||||
widget=AmountInput(),
|
||||
help_text=_("Be careful: this gift will be multiplied for each order."),
|
||||
)
|
||||
form.fields[f'meal_{meal.id}_remark'] = forms.CharField(
|
||||
max_length=255,
|
||||
required=False,
|
||||
label=_("remark").capitalize(),
|
||||
help_text=_("Allergies,…"),
|
||||
)
|
||||
form.fields[f'meal_{meal.id}_priority'] = forms.CharField(
|
||||
max_length=64,
|
||||
required=False,
|
||||
label=_("priority request").capitalize(),
|
||||
help_text=_("Lesson at 13h30,…"),
|
||||
)
|
||||
|
||||
ag = AccordionGroup(f"{meal} ({pretty_money(meal.price)})",
|
||||
Row(Field(f'meal_{meal.id}_quantity', wrapper_class='col-sm-9'),
|
||||
Field(f'meal_{meal.id}_gift', wrapper_class='col-sm-3')),
|
||||
Row(Field(f'meal_{meal.id}_remark', wrapper_class='col-sm-9'),
|
||||
Field(f'meal_{meal.id}_priority', wrapper_class='col-sm-3')))
|
||||
|
||||
for food in meal.content.filter(available=True).all():
|
||||
if food.foodoption_set.count():
|
||||
options_fieldset = Fieldset(_("Options for ") + str(food))
|
||||
options_row = Row(css_class='ml-0')
|
||||
for option in food.foodoption_set.filter(available=True).all():
|
||||
form.fields[f'meal_{meal.id}_food_{food.id}_option_{option.id}'] = forms.BooleanField(
|
||||
label=f"{option}{f' ({pretty_money(option.extra_cost)})' if option.extra_cost else ''}",
|
||||
required=False,
|
||||
)
|
||||
options_row.fields.append(
|
||||
Field(f'meal_{meal.id}_food_{food.id}_option_{option.id}', wrapper_class='col-sm-12'))
|
||||
options_fieldset.fields.append(options_row)
|
||||
ag.fields.append(options_fieldset)
|
||||
|
||||
layout_fields.append(ag)
|
||||
|
||||
for food in self.object.food_set.filter(available=True).all():
|
||||
form.fields[f'food_{food.id}_quantity'] = forms.IntegerField(
|
||||
label=_("quantity").capitalize(),
|
||||
initial=0,
|
||||
)
|
||||
form.fields[f'food_{food.id}_gift'] = forms.IntegerField(
|
||||
label=_("gift").capitalize(),
|
||||
initial=0,
|
||||
widget=AmountInput(),
|
||||
help_text=_("Be careful: this gift will be multiplied for each order."),
|
||||
)
|
||||
form.fields[f'food_{food.id}_remark'] = forms.CharField(
|
||||
max_length=255,
|
||||
required=False,
|
||||
label=_("remark").capitalize(),
|
||||
help_text=_("Allergies,…"),
|
||||
)
|
||||
form.fields[f'food_{food.id}_priority'] = forms.CharField(
|
||||
max_length=255,
|
||||
required=False,
|
||||
label=_("priority request").capitalize(),
|
||||
help_text=_("Lesson at 13h30,…"),
|
||||
)
|
||||
|
||||
ag = AccordionGroup(f"{food} ({pretty_money(food.price)})",
|
||||
Row(Field(f'food_{food.id}_quantity', wrapper_class='col-sm-9'),
|
||||
Field(f'food_{food.id}_gift', wrapper_class='col-sm-3')),
|
||||
Row(Field(f'food_{food.id}_remark', wrapper_class='col-sm-9'),
|
||||
Field(f'food_{food.id}_priority', wrapper_class='col-sm-3')))
|
||||
|
||||
if food.foodoption_set.count():
|
||||
options_fieldset = Fieldset(_("Options"))
|
||||
options_row = Row(css_class='ml-0')
|
||||
for option in food.foodoption_set.all():
|
||||
form.fields[f'food_{food.id}_option_{option.id}'] = forms.BooleanField(
|
||||
label=f"{option}{f' ({pretty_money(option.extra_cost)})' if option.extra_cost else ''}",
|
||||
required=False,
|
||||
)
|
||||
options_row.fields.append(Field(f'food_{food.id}_option_{option.id}', wrapper_class='col-sm-12'))
|
||||
options_fieldset.fields.append(options_row)
|
||||
ag.fields.append(options_fieldset)
|
||||
|
||||
layout_fields.append(ag)
|
||||
|
||||
layout_fields.append(FormActions(Submit('submit', _("Order now"))))
|
||||
|
||||
form.helper.layout = Accordion(*layout_fields)
|
||||
|
||||
if self.request.method in ['PUT', 'POST']:
|
||||
form.data = self.request.POST
|
||||
form.files = self.request.FILES
|
||||
form.is_bound = not form.data or not form.files
|
||||
|
||||
return form
|
||||
|
||||
def form_valid(self, form):
|
||||
data = form.cleaned_data
|
||||
sheet = self.get_object()
|
||||
|
||||
with transaction.atomic():
|
||||
order = Order.objects.create(sheet_id=self.kwargs['pk'], note=data['note'])
|
||||
|
||||
total_quantity = 0
|
||||
|
||||
for meal in sheet.meal_set.filter(available=True).all():
|
||||
quantity = data[f'meal_{meal.id}_quantity']
|
||||
if not quantity:
|
||||
continue
|
||||
|
||||
total_quantity += quantity
|
||||
gift = data[f'meal_{meal.id}_gift']
|
||||
remark = data[f'meal_{meal.id}_remark'] or ''
|
||||
priority = data[f'meal_{meal.id}_priority'] or ''
|
||||
ordered_meal = OrderedMeal.objects.create(order=order, meal=meal, gift=gift)
|
||||
|
||||
for ignored in range(quantity):
|
||||
for food in meal.content.filter(available=True).all():
|
||||
n = OrderedFood.objects.filter(order__sheet_id=self.kwargs['pk'],
|
||||
order__note=order.note,
|
||||
order__date__gte=timezone.now() - timedelta(hours=6),
|
||||
food=food).exclude(status='CANCELED').count()
|
||||
of = OrderedFood.objects.create(order=order, meal=ordered_meal, food=food,
|
||||
remark=remark, priority=priority, number=n + 1, gift=0)
|
||||
|
||||
for option in food.foodoption_set.filter(available=True).all():
|
||||
if data[f'meal_{meal.id}_food_{food.id}_option_{option.id}']:
|
||||
of.options.add(option)
|
||||
of.save()
|
||||
|
||||
first_food = ordered_meal.orderedfood_set.first()
|
||||
tr = SheetOrderTransaction(source_id=order.note_id, destination=first_food.food.club.note,
|
||||
source_alias=str(order.note), destination_alias=first_food.food.club.name,
|
||||
quantity=quantity, ordered_food=first_food,
|
||||
reason=f"{meal.name} - {sheet.name}")
|
||||
tr.amount = tr.get_price / tr.quantity
|
||||
tr.save()
|
||||
|
||||
for food in sheet.food_set.filter(available=True).all():
|
||||
quantity = data[f'food_{food.id}_quantity']
|
||||
if not quantity:
|
||||
continue
|
||||
|
||||
total_quantity += quantity
|
||||
gift = data[f'food_{meal.id}_gift']
|
||||
remark = data[f'food_{meal.id}_remark'] or ''
|
||||
priority = data[f'food_{meal.id}_priority'] or ''
|
||||
|
||||
for ignored in range(quantity):
|
||||
n = OrderedFood.objects.filter(order__sheet_id=self.kwargs['pk'],
|
||||
order__note=order.note,
|
||||
order__date__gte=timezone.now() - timedelta(hours=6),
|
||||
food=food).exclude(state='CANCELED').count()
|
||||
of = OrderedFood.objects.create(order=order, food=food, gift=gift,
|
||||
remark=remark, priority=priority, number=n + 1)
|
||||
|
||||
for option in food.foodoption_set.filter(available=True).all():
|
||||
if data[f'meal_{meal.id}_food_{food.id}_option_{option.id}']:
|
||||
of.options.add(option)
|
||||
of.options.save()
|
||||
|
||||
tr = SheetOrderTransaction(source_id=order.note_id, destination_id=first_food.club.note,
|
||||
source_alias=str(order.note), destination_alias=first_food.club.name,
|
||||
quantity=quantity, ordered_food=of,
|
||||
reason=f"{food.name} - {sheet.name}")
|
||||
tr.amount = tr.get_price / tr.quantity
|
||||
tr.save()
|
||||
|
||||
if total_quantity == 0:
|
||||
form.add_error(None, _("You didn't select anything."))
|
||||
transaction.rollback()
|
||||
return self.form_invalid(form)
|
||||
|
||||
return super().form_valid(form)
|
||||
|
||||
def get_success_url(self):
|
||||
return reverse_lazy('sheets:sheet_detail', args=(self.kwargs['pk'],))
|
||||
|
||||
|
||||
class WaitingListView(ProtectQuerysetMixin, DetailView):
|
||||
model = Food
|
||||
template_name = 'sheets/waiting_list.html'
|
||||
extra_context = {'title': _("Waiting list")}
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
content = super().get_context_data(**kwargs)
|
||||
|
||||
content['queue'] = OrderedFood.objects.filter(food_id=self.kwargs['pk'], status='QUEUED')\
|
||||
.order_by('-priority', 'number', 'order__date').all()
|
||||
content['ready'] = OrderedFood.objects.filter(food_id=self.kwargs['pk'], status='READY')\
|
||||
.order_by('served_date').all()
|
||||
|
||||
return content
|
||||
|
||||
|
||||
class WaitingListDetailView(ProtectQuerysetMixin, DetailView):
|
||||
model = Food
|
||||
template_name = 'sheets/waiting_list_detail.html'
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
|
||||
list_type = 'CANCELED' if 'canceled' in self.request.path else \
|
||||
'SERVED' if 'served' in self.request.path else \
|
||||
'READY' if 'ready' in self.request.path else 'QUEUED'
|
||||
context['list_type'] = list_type
|
||||
context['orders'] = OrderedFood.objects.filter(food_id=self.kwargs['pk'], status=list_type)\
|
||||
.order_by('served_date', '-priority', 'number', 'order__date').all()
|
||||
context['title'] = self.object.name + " - " + _(list_type.lower()).capitalize()
|
||||
|
||||
return context
|
@ -2,11 +2,11 @@
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
from .base import WEISurvey, WEISurveyInformation, WEISurveyAlgorithm
|
||||
from .wei2021 import WEISurvey2021
|
||||
from .wei2022 import WEISurvey2022
|
||||
|
||||
|
||||
__all__ = [
|
||||
'WEISurvey', 'WEISurveyInformation', 'WEISurveyAlgorithm', 'CurrentSurvey',
|
||||
]
|
||||
|
||||
CurrentSurvey = WEISurvey2021
|
||||
CurrentSurvey = WEISurvey2022
|
||||
|
293
apps/wei/forms/surveys/wei2022.py
Normal file
293
apps/wei/forms/surveys/wei2022.py
Normal file
@ -0,0 +1,293 @@
|
||||
# Copyright (C) 2018-2022 by BDE ENS Paris-Saclay
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
import time
|
||||
from functools import lru_cache
|
||||
from random import Random
|
||||
|
||||
from django import forms
|
||||
from django.db import transaction
|
||||
from django.db.models import Q
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from .base import WEISurvey, WEISurveyInformation, WEISurveyAlgorithm, WEIBusInformation
|
||||
from ...models import WEIMembership
|
||||
|
||||
WORDS = [
|
||||
'13 organisé', '3ième mi temps', 'Années 2000', 'Apéro', 'BBQ', 'BP', 'Beauf', 'Binge drinking', 'Bon enfant',
|
||||
'Cartouche', 'Catacombes', 'Chansons paillardes', 'Chansons populaires', 'Chanteur', 'Chartreuse', 'Chill',
|
||||
'Core', 'DJ', 'Dancefloor', 'Danse', 'David Guetta', 'Disco', 'Eau de vie', 'Électro', 'Escalade', 'Familial',
|
||||
'Fanfare', 'Fracassage', 'Féria', 'Hard rock', 'Hoeggarden', 'House', 'Huit-six', 'IPA', 'Inclusif', 'Inferno',
|
||||
'Introverti', 'Jager bomb', 'Jazz', 'Jeux d\'alcool', 'Jeux de rôles', 'Jeux vidéo', 'Jul', 'Jus de fruit',
|
||||
'Karaoké', 'LGBTQI+', 'Lady Gaga', 'Loup garou', 'Morning beer', 'Métal', 'Nuit blanche', 'Ovalie', 'Psychedelic',
|
||||
'Pétanque', 'Rave', 'Reggae', 'Rhum', 'Ricard', 'Rock', 'Rosé', 'Rétro', 'Séducteur', 'Techno', 'Thérapie taxi',
|
||||
'Théâtre', 'Trap', 'Turn up', 'Underground', 'Volley', 'Wati B', 'Zinédine Zidane',
|
||||
]
|
||||
|
||||
|
||||
class WEISurveyForm2022(forms.Form):
|
||||
"""
|
||||
Survey form for the year 2022.
|
||||
Members choose 20 words, from which we calculate the best associated bus.
|
||||
"""
|
||||
|
||||
word = forms.ChoiceField(
|
||||
label=_("Choose a word:"),
|
||||
widget=forms.RadioSelect(),
|
||||
)
|
||||
|
||||
def set_registration(self, registration):
|
||||
"""
|
||||
Filter the bus selector with the buses of the current WEI.
|
||||
"""
|
||||
information = WEISurveyInformation2022(registration)
|
||||
if not information.seed:
|
||||
information.seed = int(1000 * time.time())
|
||||
information.save(registration)
|
||||
registration._force_save = True
|
||||
registration.save()
|
||||
|
||||
if self.data:
|
||||
self.fields["word"].choices = [(w, w) for w in WORDS]
|
||||
if self.is_valid():
|
||||
return
|
||||
|
||||
rng = Random((information.step + 1) * information.seed)
|
||||
|
||||
words = None
|
||||
|
||||
buses = WEISurveyAlgorithm2022.get_buses()
|
||||
informations = {bus: WEIBusInformation2022(bus) for bus in buses}
|
||||
scores = sum((list(informations[bus].scores.values()) for bus in buses), [])
|
||||
average_score = sum(scores) / len(scores)
|
||||
|
||||
preferred_words = {bus: [word for word in WORDS
|
||||
if informations[bus].scores[word] >= average_score]
|
||||
for bus in buses}
|
||||
while words is None or len(set(words)) != len(words):
|
||||
# Ensure that there is no the same word 2 times
|
||||
words = [rng.choice(words) for _ignored2, words in preferred_words.items()]
|
||||
rng.shuffle(words)
|
||||
words = [(w, w) for w in words]
|
||||
self.fields["word"].choices = words
|
||||
|
||||
|
||||
class WEIBusInformation2022(WEIBusInformation):
|
||||
"""
|
||||
For each word, the bus has a score
|
||||
"""
|
||||
scores: dict
|
||||
|
||||
def __init__(self, bus):
|
||||
self.scores = {}
|
||||
for word in WORDS:
|
||||
self.scores[word] = 0.0
|
||||
super().__init__(bus)
|
||||
|
||||
|
||||
class WEISurveyInformation2022(WEISurveyInformation):
|
||||
"""
|
||||
We store the id of the selected bus. We store only the name, but is not used in the selection:
|
||||
that's only for humans that try to read data.
|
||||
"""
|
||||
# Random seed that is stored at the first time to ensure that words are generated only once
|
||||
seed = 0
|
||||
step = 0
|
||||
|
||||
def __init__(self, registration):
|
||||
for i in range(1, 21):
|
||||
setattr(self, "word" + str(i), None)
|
||||
super().__init__(registration)
|
||||
|
||||
|
||||
class WEISurvey2022(WEISurvey):
|
||||
"""
|
||||
Survey for the year 2022.
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
def get_year(cls):
|
||||
return 2022
|
||||
|
||||
@classmethod
|
||||
def get_survey_information_class(cls):
|
||||
return WEISurveyInformation2022
|
||||
|
||||
def get_form_class(self):
|
||||
return WEISurveyForm2022
|
||||
|
||||
def update_form(self, form):
|
||||
"""
|
||||
Filter the bus selector with the buses of the WEI.
|
||||
"""
|
||||
form.set_registration(self.registration)
|
||||
|
||||
@transaction.atomic
|
||||
def form_valid(self, form):
|
||||
word = form.cleaned_data["word"]
|
||||
self.information.step += 1
|
||||
setattr(self.information, "word" + str(self.information.step), word)
|
||||
self.save()
|
||||
|
||||
@classmethod
|
||||
def get_algorithm_class(cls):
|
||||
return WEISurveyAlgorithm2022
|
||||
|
||||
def is_complete(self) -> bool:
|
||||
"""
|
||||
The survey is complete once the bus is chosen.
|
||||
"""
|
||||
return self.information.step == 20
|
||||
|
||||
@classmethod
|
||||
@lru_cache()
|
||||
def word_mean(cls, word):
|
||||
"""
|
||||
Calculate the mid-score given by all buses.
|
||||
"""
|
||||
buses = cls.get_algorithm_class().get_buses()
|
||||
return sum([cls.get_algorithm_class().get_bus_information(bus).scores[word] for bus in buses]) / buses.count()
|
||||
|
||||
@lru_cache()
|
||||
def score(self, bus):
|
||||
if not self.is_complete():
|
||||
raise ValueError("Survey is not ended, can't calculate score")
|
||||
|
||||
bus_info = self.get_algorithm_class().get_bus_information(bus)
|
||||
# Score is the given score by the bus subtracted to the mid-score of the buses.
|
||||
s = sum(bus_info.scores[getattr(self.information, 'word' + str(i))]
|
||||
- self.word_mean(getattr(self.information, 'word' + str(i))) for i in range(1, 21)) / 20
|
||||
return s
|
||||
|
||||
@lru_cache()
|
||||
def scores_per_bus(self):
|
||||
return {bus: self.score(bus) for bus in self.get_algorithm_class().get_buses()}
|
||||
|
||||
@lru_cache()
|
||||
def ordered_buses(self):
|
||||
values = list(self.scores_per_bus().items())
|
||||
values.sort(key=lambda item: -item[1])
|
||||
return values
|
||||
|
||||
@classmethod
|
||||
def clear_cache(cls):
|
||||
cls.word_mean.cache_clear()
|
||||
return super().clear_cache()
|
||||
|
||||
|
||||
class WEISurveyAlgorithm2022(WEISurveyAlgorithm):
|
||||
"""
|
||||
The algorithm class for the year 2022.
|
||||
We use Gale-Shapley algorithm to attribute 1y students into buses.
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
def get_survey_class(cls):
|
||||
return WEISurvey2022
|
||||
|
||||
@classmethod
|
||||
def get_bus_information_class(cls):
|
||||
return WEIBusInformation2022
|
||||
|
||||
def run_algorithm(self, display_tqdm=False):
|
||||
"""
|
||||
Gale-Shapley algorithm implementation.
|
||||
We modify it to allow buses to have multiple "weddings".
|
||||
"""
|
||||
surveys = list(self.get_survey_class()(r) for r in self.get_registrations()) # All surveys
|
||||
surveys = [s for s in surveys if s.is_complete()] # Don't consider invalid surveys
|
||||
# Don't manage hardcoded people
|
||||
surveys = [s for s in surveys if not hasattr(s.information, 'hardcoded') or not s.information.hardcoded]
|
||||
|
||||
# Reset previous algorithm run
|
||||
for survey in surveys:
|
||||
survey.free()
|
||||
survey.save()
|
||||
|
||||
non_men = [s for s in surveys if s.registration.gender != 'male']
|
||||
men = [s for s in surveys if s.registration.gender == 'male']
|
||||
|
||||
quotas = {}
|
||||
registrations = self.get_registrations()
|
||||
non_men_total = registrations.filter(~Q(gender='male')).count()
|
||||
for bus in self.get_buses():
|
||||
free_seats = bus.size - WEIMembership.objects.filter(bus=bus, registration__first_year=False).count()
|
||||
# Remove hardcoded people
|
||||
free_seats -= WEIMembership.objects.filter(bus=bus, registration__first_year=True,
|
||||
registration__information_json__icontains="hardcoded").count()
|
||||
quotas[bus] = 4 + int(non_men_total / registrations.count() * free_seats)
|
||||
|
||||
tqdm_obj = None
|
||||
if display_tqdm:
|
||||
from tqdm import tqdm
|
||||
tqdm_obj = tqdm(total=len(non_men), desc="Non-hommes")
|
||||
|
||||
# Repartition for non men people first
|
||||
self.make_repartition(non_men, quotas, tqdm_obj=tqdm_obj)
|
||||
|
||||
quotas = {}
|
||||
for bus in self.get_buses():
|
||||
free_seats = bus.size - WEIMembership.objects.filter(bus=bus, registration__first_year=False).count()
|
||||
free_seats -= sum(1 for s in non_men if s.information.selected_bus_pk == bus.pk)
|
||||
# Remove hardcoded people
|
||||
free_seats -= WEIMembership.objects.filter(bus=bus, registration__first_year=True,
|
||||
registration__information_json__icontains="hardcoded").count()
|
||||
quotas[bus] = free_seats
|
||||
|
||||
if display_tqdm:
|
||||
tqdm_obj.close()
|
||||
|
||||
from tqdm import tqdm
|
||||
tqdm_obj = tqdm(total=len(men), desc="Hommes")
|
||||
|
||||
self.make_repartition(men, quotas, tqdm_obj=tqdm_obj)
|
||||
|
||||
if display_tqdm:
|
||||
tqdm_obj.close()
|
||||
|
||||
# Clear cache information after running algorithm
|
||||
WEISurvey2022.clear_cache()
|
||||
|
||||
def make_repartition(self, surveys, quotas=None, tqdm_obj=None):
|
||||
free_surveys = surveys.copy() # Remaining surveys
|
||||
while free_surveys: # Some students are not affected
|
||||
survey = free_surveys[0]
|
||||
buses = survey.ordered_buses() # Preferences of the student
|
||||
for bus, current_score in buses:
|
||||
if self.get_bus_information(bus).has_free_seats(surveys, quotas):
|
||||
# Selected bus has free places. Put student in the bus
|
||||
survey.select_bus(bus)
|
||||
survey.save()
|
||||
free_surveys.remove(survey)
|
||||
break
|
||||
else:
|
||||
# Current bus has not enough places. Remove the least preferred student from the bus if existing
|
||||
least_preferred_survey = None
|
||||
least_score = -1
|
||||
# Find the least student in the bus that has a lower score than the current student
|
||||
for survey2 in surveys:
|
||||
if not survey2.information.valid or survey2.information.get_selected_bus() != bus:
|
||||
continue
|
||||
score2 = survey2.score(bus)
|
||||
if current_score <= score2: # Ignore better students
|
||||
continue
|
||||
if least_preferred_survey is None or score2 < least_score:
|
||||
least_preferred_survey = survey2
|
||||
least_score = score2
|
||||
|
||||
if least_preferred_survey is not None:
|
||||
# Remove the least student from the bus and put the current student in.
|
||||
# If it does not exist, choose the next bus.
|
||||
least_preferred_survey.free()
|
||||
least_preferred_survey.save()
|
||||
free_surveys.append(least_preferred_survey)
|
||||
survey.select_bus(bus)
|
||||
survey.save()
|
||||
free_surveys.remove(survey)
|
||||
break
|
||||
else:
|
||||
raise ValueError(f"User {survey.registration.user} has no free seat")
|
||||
|
||||
if tqdm_obj is not None:
|
||||
tqdm_obj.n = len(surveys) - len(free_surveys)
|
||||
tqdm_obj.refresh()
|
@ -25,6 +25,7 @@ class TestWEIAlgorithm(TestCase):
|
||||
email="wei2021@example.com",
|
||||
date_start='2021-09-17',
|
||||
date_end='2021-09-19',
|
||||
year=2021,
|
||||
)
|
||||
|
||||
self.buses = []
|
||||
|
110
apps/wei/tests/test_wei_algorithm_2022.py
Normal file
110
apps/wei/tests/test_wei_algorithm_2022.py
Normal file
@ -0,0 +1,110 @@
|
||||
# Copyright (C) 2018-2022 by BDE ENS Paris-Saclay
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
import random
|
||||
|
||||
from django.contrib.auth.models import User
|
||||
from django.test import TestCase
|
||||
|
||||
from ..forms.surveys.wei2022 import WEIBusInformation2022, WEISurvey2022, WORDS, WEISurveyInformation2022
|
||||
from ..models import Bus, WEIClub, WEIRegistration
|
||||
|
||||
|
||||
class TestWEIAlgorithm(TestCase):
|
||||
"""
|
||||
Run some tests to ensure that the WEI algorithm is working well.
|
||||
"""
|
||||
fixtures = ('initial',)
|
||||
|
||||
def setUp(self):
|
||||
"""
|
||||
Create some test data, with one WEI and 10 buses with random score attributions.
|
||||
"""
|
||||
self.wei = WEIClub.objects.create(
|
||||
name="WEI 2022",
|
||||
email="wei2022@example.com",
|
||||
date_start='2022-09-16',
|
||||
date_end='2022-09-18',
|
||||
year=2022,
|
||||
)
|
||||
|
||||
self.buses = []
|
||||
for i in range(10):
|
||||
bus = Bus.objects.create(wei=self.wei, name=f"Bus {i}", size=10)
|
||||
self.buses.append(bus)
|
||||
information = WEIBusInformation2022(bus)
|
||||
for word in WORDS:
|
||||
information.scores[word] = random.randint(0, 101)
|
||||
information.save()
|
||||
bus.save()
|
||||
|
||||
def test_survey_algorithm_small(self):
|
||||
"""
|
||||
There are only a few people in each bus, ensure that each person has its best bus
|
||||
"""
|
||||
# Add a few users
|
||||
for i in range(10):
|
||||
user = User.objects.create(username=f"user{i}")
|
||||
registration = WEIRegistration.objects.create(
|
||||
user=user,
|
||||
wei=self.wei,
|
||||
first_year=True,
|
||||
birth_date='2000-01-01',
|
||||
)
|
||||
information = WEISurveyInformation2022(registration)
|
||||
for j in range(1, 21):
|
||||
setattr(information, f'word{j}', random.choice(WORDS))
|
||||
information.step = 20
|
||||
information.save(registration)
|
||||
registration.save()
|
||||
|
||||
# Run algorithm
|
||||
WEISurvey2022.get_algorithm_class()().run_algorithm()
|
||||
|
||||
# Ensure that everyone has its first choice
|
||||
for r in WEIRegistration.objects.filter(wei=self.wei).all():
|
||||
survey = WEISurvey2022(r)
|
||||
preferred_bus = survey.ordered_buses()[0][0]
|
||||
chosen_bus = survey.information.get_selected_bus()
|
||||
self.assertEqual(preferred_bus, chosen_bus)
|
||||
|
||||
def test_survey_algorithm_full(self):
|
||||
"""
|
||||
Buses are full of first year people, ensure that they are happy
|
||||
"""
|
||||
# Add a lot of users
|
||||
for i in range(95):
|
||||
user = User.objects.create(username=f"user{i}")
|
||||
registration = WEIRegistration.objects.create(
|
||||
user=user,
|
||||
wei=self.wei,
|
||||
first_year=True,
|
||||
birth_date='2000-01-01',
|
||||
)
|
||||
information = WEISurveyInformation2022(registration)
|
||||
for j in range(1, 21):
|
||||
setattr(information, f'word{j}', random.choice(WORDS))
|
||||
information.step = 20
|
||||
information.save(registration)
|
||||
registration.save()
|
||||
|
||||
# Run algorithm
|
||||
WEISurvey2022.get_algorithm_class()().run_algorithm()
|
||||
|
||||
penalty = 0
|
||||
# Ensure that everyone seems to be happy
|
||||
# We attribute a penalty for each user that didn't have its first choice
|
||||
# The penalty is the square of the distance between the score of the preferred bus
|
||||
# and the score of the attributed bus
|
||||
# We consider it acceptable if the mean of this distance is lower than 5 %
|
||||
for r in WEIRegistration.objects.filter(wei=self.wei).all():
|
||||
survey = WEISurvey2022(r)
|
||||
chosen_bus = survey.information.get_selected_bus()
|
||||
buses = survey.ordered_buses()
|
||||
score = min(v for bus, v in buses if bus == chosen_bus)
|
||||
max_score = buses[0][1]
|
||||
penalty += (max_score - score) ** 2
|
||||
|
||||
self.assertLessEqual(max_score - score, 25) # Always less than 25 % of tolerance
|
||||
|
||||
self.assertLessEqual(penalty / 100, 25) # Tolerance of 5 %
|
@ -782,7 +782,7 @@ class TestDefaultWEISurvey(TestCase):
|
||||
WEISurvey.update_form(None, None)
|
||||
|
||||
self.assertEqual(CurrentSurvey.get_algorithm_class().get_survey_class(), CurrentSurvey)
|
||||
self.assertEqual(CurrentSurvey.get_year(), 2021)
|
||||
self.assertEqual(CurrentSurvey.get_year(), 2022)
|
||||
|
||||
|
||||
class TestWeiAPI(TestAPI):
|
||||
|
@ -86,7 +86,7 @@ Génération
|
||||
|
||||
Les factures peuvent s'exporter au format PDF (là est tout leur intérêt). Pour cela, on utilise le template LaTeX
|
||||
présent à l'adresse suivante :
|
||||
`/templates/treasury/invoice_sample.tex <https://gitlab.crans.org/bde/nk20/-/tree/master/templates/treasury/invoice_sample.tex>`_
|
||||
`/templates/treasury/invoice_sample.tex <https://gitlab.crans.org/bde/nk20/-/tree/main/templates/treasury/invoice_sample.tex>`_
|
||||
|
||||
On le remplit avec les données de la facture et les données du BDE, hard-codées. On copie le template rempli dans un
|
||||
ficher tex dans un dossier temporaire. On fait ensuite 2 appels à ``pdflatex`` pour générer la facture au format PDF.
|
||||
|
@ -41,8 +41,14 @@ On a ensuite besoin de définir nos propres scopes afin d'avoir des permissions
|
||||
|
||||
OAUTH2_PROVIDER = {
|
||||
'SCOPES_BACKEND_CLASS': 'permission.scopes.PermissionScopes',
|
||||
'OAUTH2_VALIDATOR_CLASS': "permission.scopes.PermissionOAuth2Validator",
|
||||
'REFRESH_TOKEN_EXPIRE_SECONDS': timedelta(days=14),
|
||||
}
|
||||
|
||||
Cela a pour effet d'avoir des scopes sous la forme ``PERMISSION_CLUB``,
|
||||
et de demander des scopes facultatives (voir plus bas).
|
||||
Un jeton de rafraîchissement expire de plus au bout de 14 jours, si non-renouvelé.
|
||||
|
||||
On ajoute enfin les routes dans ``urls.py`` :
|
||||
|
||||
.. code:: python
|
||||
@ -94,6 +100,27 @@ du format renvoyé.
|
||||
Vous pouvez donc contrôler le plus finement possible les permissions octroyées à vos
|
||||
jetons.
|
||||
|
||||
.. danger::
|
||||
|
||||
Demander des scopes n'implique pas de les avoir.
|
||||
|
||||
Lorsque des scopes sont demandées par un client, la Note
|
||||
va considérer l'ensemble des permissions accessibles parmi
|
||||
ce qui est demandé. Dans vos programmes, vous devrez donc
|
||||
vérifier les permissions acquises (communiquées lors de la
|
||||
récupération du jeton d'accès à partir du grant code),
|
||||
et prévoir un comportement dans le cas où des permissions
|
||||
sont manquantes.
|
||||
|
||||
Cela offre un intérêt supérieur par rapport au protocole
|
||||
OAuth2 classique, consistant à demander trop de permissions
|
||||
et agir en conséquence.
|
||||
|
||||
Par exemple, vous pourriez demander la permission d'accéder
|
||||
aux membres d'un club ou de faire des transactions, et agir
|
||||
uniquement dans le cas où l'utilisateur connecté possède la
|
||||
permission problématique.
|
||||
|
||||
Avec Django-allauth
|
||||
###################
|
||||
|
||||
@ -116,6 +143,7 @@ installées (sur votre propre client), puis de bien ajouter l'application social
|
||||
SOCIALACCOUNT_PROVIDERS = {
|
||||
'notekfet': {
|
||||
# 'DOMAIN': 'note.crans.org',
|
||||
'SCOPE': ['1_1', '2_1'],
|
||||
},
|
||||
...
|
||||
}
|
||||
@ -123,6 +151,10 @@ installées (sur votre propre client), puis de bien ajouter l'application social
|
||||
Le paramètre ``DOMAIN`` permet de changer d'instance de Note Kfet. Par défaut, il
|
||||
se connectera à ``note.crans.org`` si vous ne renseignez rien.
|
||||
|
||||
Le paramètre ``SCOPE`` permet de définir les scopes à demander.
|
||||
Dans l'exemple ci-dessous, les permissions d'accéder à l'utilisateur
|
||||
et au profil sont demandées.
|
||||
|
||||
En créant l'application sur la note, vous pouvez renseigner
|
||||
``https://monsite.example.com/accounts/notekfet/login/callback/`` en URL de redirection,
|
||||
à adapter selon votre configuration.
|
||||
|
@ -88,7 +88,7 @@ On clone donc le dépôt en tant que ``www-data`` :
|
||||
|
||||
$ sudo -u www-data git clone https://gitlab.crans.org/bde/nk20.git /var/www/note_kfet
|
||||
|
||||
Par défaut, le dépôt est configuré pour suivre la branche ``master``, qui est la branche
|
||||
Par défaut, le dépôt est configuré pour suivre la branche ``main``, qui est la branche
|
||||
stable, notamment installée sur `<https://note.crans.org/>`_. Pour changer de branche,
|
||||
notamment passer sur la branche ``beta`` sur un serveur de pré-production (un peu comme
|
||||
`<https://note-dev.crans.org/>`_), on peut faire :
|
||||
@ -587,7 +587,7 @@ Dans ce fichier, remplissez :
|
||||
---
|
||||
note:
|
||||
server_name: note.crans.org
|
||||
git_branch: master
|
||||
git_branch: main
|
||||
cron_enabled: true
|
||||
email: notekfet2020@lists.crans.org
|
||||
|
||||
|
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@ -7,6 +7,8 @@
|
||||
import os
|
||||
|
||||
# Build paths inside the project like this: os.path.join(BASE_DIR, ...)
|
||||
from datetime import timedelta
|
||||
|
||||
BASE_DIR = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
# Quick-start development settings - unsuitable for production
|
||||
@ -22,6 +24,15 @@ ALLOWED_HOSTS = [
|
||||
os.getenv('NOTE_URL', 'localhost'),
|
||||
]
|
||||
|
||||
# Use secure cookies in production
|
||||
SESSION_COOKIE_SECURE = not DEBUG
|
||||
CSRF_COOKIE_SECURE = not DEBUG
|
||||
|
||||
# Remember HTTPS for 1 year
|
||||
SECURE_HSTS_SECONDS = 31536000
|
||||
SECURE_HSTS_INCLUDE_SUBDOMAINS = True
|
||||
SECURE_HSTS_PRELOAD = True
|
||||
|
||||
|
||||
# Application definition
|
||||
|
||||
@ -64,6 +75,7 @@ INSTALLED_APPS = [
|
||||
'permission',
|
||||
'registration',
|
||||
'scripts',
|
||||
'sheets',
|
||||
'treasury',
|
||||
'wei',
|
||||
]
|
||||
@ -248,6 +260,8 @@ REST_FRAMEWORK = {
|
||||
# OAuth2 Provider
|
||||
OAUTH2_PROVIDER = {
|
||||
'SCOPES_BACKEND_CLASS': 'permission.scopes.PermissionScopes',
|
||||
'OAUTH2_VALIDATOR_CLASS': "permission.scopes.PermissionOAuth2Validator",
|
||||
'REFRESH_TOKEN_EXPIRE_SECONDS': timedelta(days=14),
|
||||
}
|
||||
|
||||
# Take control on how widget templates are sourced
|
||||
|
@ -65,7 +65,7 @@ mark {
|
||||
|
||||
/* Last BDE colors */
|
||||
.bg-primary {
|
||||
background-color: rgb(18, 67, 4) !important;
|
||||
background-color: rgb(102, 83, 105) !important;
|
||||
}
|
||||
|
||||
html {
|
||||
@ -81,14 +81,14 @@ body {
|
||||
.btn-outline-primary:not(:disabled):not(.disabled).active,
|
||||
.btn-outline-primary:not(:disabled):not(.disabled):active {
|
||||
color: #fff;
|
||||
background-color: rgb(18, 67, 46);
|
||||
border-color: rgb(18, 67, 46);
|
||||
background-color: rgb(102, 83, 105);
|
||||
border-color: rgb(102, 83, 105);
|
||||
}
|
||||
|
||||
.btn-outline-primary {
|
||||
color: rgb(18, 67, 46);
|
||||
color: rgb(102, 83, 105);
|
||||
background-color: rgba(248, 249, 250, 0.9);
|
||||
border-color: rgb(18, 67, 46);
|
||||
border-color: rgb(102, 83, 105);
|
||||
}
|
||||
|
||||
.turbolinks-progress-bar {
|
||||
@ -97,37 +97,40 @@ body {
|
||||
|
||||
.btn-primary:hover,
|
||||
.btn-primary:not(:disabled):not(.disabled).active,
|
||||
.btn-primary:not(:disabled):not(.disabled):active {
|
||||
.btn-primary:not(:disabled):not(.disabled):active,
|
||||
a.badge-primary:hover,
|
||||
a.badge-primary:not(:disabled):not(.disabled).active,
|
||||
a.badge-primary:not(:disabled):not(.disabled):active {
|
||||
color: #fff;
|
||||
background-color: rgb(18, 67, 46);
|
||||
border-color: rgb(18, 67, 46);
|
||||
background-color: rgb(102, 83, 105);
|
||||
border-color: rgb(102, 83, 105);
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
.btn-primary, a.badge-primary {
|
||||
color: rgba(248, 249, 250, 0.9);
|
||||
background-color: rgb(28, 114, 10);
|
||||
border-color: rgb(18, 67, 46);
|
||||
background-color: rgb(102, 83, 105);
|
||||
border-color: rgb(102, 83, 105);
|
||||
}
|
||||
|
||||
.border-primary {
|
||||
border-color: rgb(28, 114, 10) !important;
|
||||
border-color: rgb(115, 15, 115) !important;
|
||||
}
|
||||
|
||||
a {
|
||||
color: rgb(28, 114, 10);
|
||||
color: rgb(102, 83, 105);
|
||||
}
|
||||
|
||||
a:hover {
|
||||
color: rgb(122, 163, 75);
|
||||
color: rgb(200, 30, 200);
|
||||
}
|
||||
|
||||
.form-control:focus {
|
||||
box-shadow: 0 0 0 0.25rem rgba(122, 163, 75, 0.25);
|
||||
border-color: rgb(122, 163, 75);
|
||||
box-shadow: 0 0 0 0.25rem rgba(200, 30, 200, 0.25);
|
||||
border-color: rgb(200, 30, 200);
|
||||
}
|
||||
|
||||
.btn-outline-primary.focus {
|
||||
box-shadow: 0 0 0 0.25rem rgba(122, 163, 75, 0.5);
|
||||
box-shadow: 0 0 0 0.25rem rgba(200, 30, 200, 0.5);
|
||||
}
|
||||
|
||||
|
||||
|
@ -13,21 +13,29 @@ $(document).ready(function () {
|
||||
$('#' + prefix + '_reset').removeClass('d-none')
|
||||
|
||||
$.getJSON(api_url + (api_url.includes('?') ? '&' : '?') + 'format=json&search=^' + input + api_url_suffix, function (objects) {
|
||||
let html = ''
|
||||
let html = '<ul class="list-group list-group-flush" id="' + prefix + '_list">'
|
||||
|
||||
objects.results.forEach(function (obj) {
|
||||
html += li(prefix + '_' + obj.id, obj[name_field])
|
||||
})
|
||||
html += '</ul>'
|
||||
|
||||
const results_list = $('#' + prefix + '_list')
|
||||
results_list.html(html)
|
||||
target.tooltip({
|
||||
html: true,
|
||||
placement: 'bottom',
|
||||
trigger: 'manual',
|
||||
container: target.parent(),
|
||||
fallbackPlacement: 'clockwise'
|
||||
})
|
||||
|
||||
target.attr("data-original-title", html).tooltip("show")
|
||||
|
||||
objects.results.forEach(function (obj) {
|
||||
$('#' + prefix + '_' + obj.id).click(function () {
|
||||
target.val(obj[name_field])
|
||||
$('#' + prefix + '_pk').val(obj.id)
|
||||
|
||||
results_list.html('')
|
||||
target.tooltip("hide")
|
||||
target.removeClass('is-invalid')
|
||||
target.addClass('is-valid')
|
||||
|
||||
@ -37,8 +45,8 @@ $(document).ready(function () {
|
||||
if (input === obj[name_field]) { $('#' + prefix + '_pk').val(obj.id) }
|
||||
})
|
||||
|
||||
if (results_list.children().length === 1 && e.originalEvent.keyCode >= 32) {
|
||||
results_list.children().first().trigger('click')
|
||||
if (objects.results.length === 1 && e.originalEvent.keyCode >= 32) {
|
||||
$('#' + prefix + '_' + objects.results[0].id).trigger('click')
|
||||
}
|
||||
})
|
||||
})
|
||||
|
6
note_kfet/static/js/turbolinks.js
Normal file
6
note_kfet/static/js/turbolinks.js
Normal file
File diff suppressed because one or more lines are too long
@ -9,9 +9,9 @@ SPDX-License-Identifier: GPL-3.0-or-later
|
||||
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 %}
|
||||
{% endfor %}>
|
||||
{% endfor %}
|
||||
aria-describedby="{{widget.attrs.id}}_tooltip">
|
||||
{% if widget.resetable %}
|
||||
<a id="{{ widget.attrs.id }}_reset" class="btn btn-light autocomplete-reset{% if not widget.value %} d-none{% endif %}">{% trans "Reset" %}</a>
|
||||
{% endif %}
|
||||
<ul class="list-group list-group-flush" id="{{ widget.attrs.id }}_list">
|
||||
</ul>
|
||||
|
||||
|
@ -33,8 +33,7 @@ SPDX-License-Identifier: GPL-3.0-or-later
|
||||
<script src="{% static "jquery/jquery.min.js" %}"></script>
|
||||
<script src="{% static "popper.js/umd/popper.min.js" %}"></script>
|
||||
<script src="{% static "bootstrap4/js/bootstrap.min.js" %}"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/turbolinks/5.2.0/turbolinks.js"
|
||||
crossorigin="anonymous"></script>
|
||||
<script src="{% static "js/turbolinks.js" %}"></script>
|
||||
<script src="{% static "js/base.js" %}"></script>
|
||||
<script src="{% static "js/konami.js" %}"></script>
|
||||
|
||||
|
@ -21,6 +21,7 @@ urlpatterns = [
|
||||
path('activity/', include('activity.urls')),
|
||||
path('treasury/', include('treasury.urls')),
|
||||
path('wei/', include('wei.urls')),
|
||||
path('sheets/', include('sheets.urls')),
|
||||
|
||||
# Include Django Contrib and Core routers
|
||||
path('i18n/', include('django.conf.urls.i18n')),
|
||||
|
Reference in New Issue
Block a user