1
0
mirror of https://gitlab.crans.org/bde/nk20 synced 2025-10-31 07:49:57 +01:00

Compare commits

..

1 Commits

Author SHA1 Message Date
Ehouarn
6bf21b103f Alpha version (without tests) 2025-10-30 23:54:23 +01:00
34 changed files with 1244 additions and 66 deletions

View File

@@ -48,5 +48,15 @@
"can_invite": true,
"guest_entry_fee": 0
}
},
{
"model": "activity.activitytype",
"pk": 8,
"fields": {
"name": "Perm bouffe",
"manage_entries": false,
"can_invite": false,
"guest_entry_fee": 0
}
}
]
]

View File

@@ -66,6 +66,10 @@ SPDX-License-Identifier: GPL-3.0-or-later
<a class="btn btn-warning btn-sm my-1" href="{% url 'activity:activity_entry' pk=activity.pk %}"> {% trans "Entry page" %}</a>
{% endif %}
{% if activity.activity_type.name == "Perm bouffe" %}
<a class="btn btn-warning btn-sm my-1" href="{% url 'food:dish_list' activity_pk=activity.pk %}"> {% trans "Dish page" %}</a>
{% endif %}
{% if request.path_info == activity_detail_url %}
{% if activity.valid and ".change__open"|has_perm:activity %}
<a class="btn btn-warning btn-sm my-1" id="open_activity"> {% if activity.open %}{% trans "close"|capfirst %}{% else %}{% trans "open"|capfirst %}{% endif %}</a>

View File

@@ -3,7 +3,7 @@
from rest_framework import serializers
from ..models import Allergen, Food, BasicFood, TransformedFood, QRCode
from ..models import Allergen, Food, BasicFood, TransformedFood, QRCode, Dish, Supplement, Order, FoodTransaction
class AllergenSerializer(serializers.ModelSerializer):
@@ -54,3 +54,43 @@ class QRCodeSerializer(serializers.ModelSerializer):
class Meta:
model = QRCode
fields = '__all__'
class DishSerializer(serializers.ModelSerializer):
"""
REST API Serializer for Dish.
The djangorestframework plugin will analyse the model `Dish` and parse all fields in the API.
"""
class Meta:
model = Dish
fields = '__all__'
class SupplementSerializer(serializers.ModelSerializer):
"""
REST API Serializer for Supplement.
The djangorestframework plugin will analyse the model `Supplement` and parse all fields in the API.
"""
class Meta:
model = Supplement
fields = '__all__'
class OrderSerializer(serializers.ModelSerializer):
"""
REST API Serializer for Order.
The djangorestframework plugin will analyse the model `Order` and parse all fields in the API.
"""
class Meta:
model = Order
fields = '__all__'
class FoodTransactionSerializer(serializers.ModelSerializer):
"""
REST API Serializer for FoodTransaction.
The djangorestframework plugin will analyse the model `FoodTransaction` and parse all fields in the API.
"""
class Meta:
model = FoodTransaction
fields = '__all__'

View File

@@ -1,7 +1,8 @@
# Copyright (C) 2018-2025 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later
from .views import AllergenViewSet, FoodViewSet, BasicFoodViewSet, TransformedFoodViewSet, QRCodeViewSet
from .views import AllergenViewSet, FoodViewSet, BasicFoodViewSet, TransformedFoodViewSet, QRCodeViewSet, \
DishViewSet, SupplementViewSet, OrderViewSet, FoodTransactionViewSet
def register_food_urls(router, path):
@@ -13,3 +14,7 @@ def register_food_urls(router, path):
router.register(path + '/basicfood', BasicFoodViewSet)
router.register(path + '/transformedfood', TransformedFoodViewSet)
router.register(path + '/qrcode', QRCodeViewSet)
router.register(path + '/dish', DishViewSet)
router.register(path + '/supplement', SupplementViewSet)
router.register(path + '/order', OrderViewSet)
router.register(path + '/foodtransaction', FoodTransactionViewSet)

View File

@@ -3,10 +3,12 @@
from api.viewsets import ReadProtectedModelViewSet
from django_filters.rest_framework import DjangoFilterBackend
from django.utils import timezone
from rest_framework.filters import SearchFilter
from .serializers import AllergenSerializer, FoodSerializer, BasicFoodSerializer, TransformedFoodSerializer, QRCodeSerializer
from ..models import Allergen, Food, BasicFood, TransformedFood, QRCode
from .serializers import AllergenSerializer, FoodSerializer, BasicFoodSerializer, TransformedFoodSerializer, QRCodeSerializer, \
DishSerializer, SupplementSerializer, OrderSerializer, FoodTransactionSerializer
from ..models import Allergen, Food, BasicFood, TransformedFood, QRCode, Dish, Supplement, Order, FoodTransaction
class AllergenViewSet(ReadProtectedModelViewSet):
@@ -72,3 +74,61 @@ class QRCodeViewSet(ReadProtectedModelViewSet):
filter_backends = [DjangoFilterBackend, SearchFilter]
filterset_fields = ['qr_code_number', ]
search_fields = ['$qr_code_number', ]
class DishViewSet(ReadProtectedModelViewSet):
"""
REST API View set.
The djangorestframework plugin will get all `Dish` objects, serialize it to JSON with the given serializer,
then render it on /api/food/dish/
"""
queryset = Dish.objects.order_by('id')
serializer_class = DishSerializer
filter_backends = [DjangoFilterBackend, SearchFilter]
filterset_fields = ['main__name', 'activity', ]
search_fields = ['$main__name', '$activity', ]
class SupplementViewSet(ReadProtectedModelViewSet):
"""
REST API View set.
The djangorestframework plugin will get all `Supplement` objects, serialize it to JSON with the given serializer,
then render it on /api/food/supplement/
"""
queryset = Supplement.objects.order_by('id')
serializer_class = SupplementSerializer
filter_backends = [DjangoFilterBackend, SearchFilter]
filterset_fields = ['food__name', 'dish__activity', ]
search_fields = ['$food__name', '$dish__activity', ]
class OrderViewSet(ReadProtectedModelViewSet):
"""
REST API View set.
The djangorestframework plugin will get all `Order` objects, serialize it to JSON with the given serializer,
then render it on /api/food/order/
"""
queryset = Order.objects.order_by('id')
serializer_class = OrderSerializer
filter_backends = [DjangoFilterBackend, SearchFilter]
filterset_fields = ['user', 'activity', 'dish', 'supplements', 'number', ]
search_fields = ['$user', '$activity', '$dish', '$supplements', '$number', ]
def perform_update(self, serializer):
instance = serializer.save()
if instance.served and not instance.served_at:
instance.served_at = timezone.now()
instance.save()
class FoodTransactionViewSet(ReadProtectedModelViewSet):
"""
REST API View set.
The djangorestframework plugin will get all `FoodTransaction` objects, serialize it to JSON with the given serializer,
then render it on /api/food/foodtransaction/
"""
queryset = FoodTransaction.objects.order_by('id')
serializer_class = FoodTransactionSerializer
filter_backends = [DjangoFilterBackend, SearchFilter]
filterset_fields = ['order', ]
search_fields = ['$order', ]

View File

@@ -4,15 +4,16 @@
from random import shuffle
from bootstrap_datepicker_plus.widgets import DateTimePickerInput
from crispy_forms.helper import FormHelper
from django import forms
from django.forms.widgets import NumberInput
from django.utils.translation import gettext_lazy as _
from member.models import Club
from note_kfet.inputs import Autocomplete
from note_kfet.inputs import Autocomplete, AmountInput
from note_kfet.middlewares import get_current_request
from permission.backends import PermissionBackend
from .models import Food, BasicFood, TransformedFood, QRCode
from .models import Food, BasicFood, TransformedFood, QRCode, Dish, Supplement, Order
class QRCodeForms(forms.ModelForm):
@@ -185,3 +186,60 @@ ManageIngredientsFormSet = forms.formset_factory(
ManageIngredientsForm,
extra=1,
)
class DishForm(forms.ModelForm):
"""
Form to create a dish
"""
class Meta:
model = Dish
fields = ('main', 'price', 'available')
widgets = {
"price": AmountInput(),
}
class SupplementForm(forms.ModelForm):
"""
Form to create a dish
"""
class Meta:
model = Supplement
fields = '__all__'
widgets = {
"price": AmountInput(),
}
# The 2 following classes are copied from treasury app
# Add a subform per supplement in the dish form, and manage correctly the link between the dish and
# its supplements. The FormSet will search automatically the ForeignKey in the Supplement model.
SupplementFormSet = forms.inlineformset_factory(
Dish,
Supplement,
form=SupplementForm,
extra=0,
)
class SupplementFormSetHelper(FormHelper):
"""
Specify some template information for the supplement form
"""
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 OrderForm(forms.ModelForm):
"""
Form to order food
"""
class Meta:
model = Order
exclude = ("activity", "number", "ordered_at", "served", "served_at")

View File

@@ -0,0 +1,23 @@
# Generated by Django 5.2.4 on 2025-08-30 00:16
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('food', '0001_initial'),
]
operations = [
migrations.AlterField(
model_name='food',
name='end_of_life',
field=models.CharField(blank=True, max_length=255, verbose_name='end of life'),
),
migrations.AlterField(
model_name='food',
name='order',
field=models.CharField(blank=True, max_length=255, verbose_name='order'),
),
]

View File

@@ -0,0 +1,86 @@
# Generated by Django 5.2.6 on 2025-10-30 22:46
import django.db.models.deletion
import django.utils.timezone
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('activity', '0007_alter_guest_activity'),
('food', '0002_alter_food_end_of_life_alter_food_order'),
('note', '0007_alter_note_polymorphic_ctype_and_more'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='Dish',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('price', models.PositiveIntegerField(verbose_name='price')),
('available', models.BooleanField(default=True, verbose_name='available')),
('activity', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='dishes', to='activity.activity', verbose_name='activity')),
('main', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='dishes_as_main', to='food.transformedfood', verbose_name='main food')),
],
options={
'verbose_name': 'Dish',
'verbose_name_plural': 'Dishes',
'unique_together': {('main', 'activity')},
},
),
migrations.CreateModel(
name='Order',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('request', models.TextField(blank=True, help_text='A specific request (to remove an ingredient for example)', verbose_name='request')),
('number', models.PositiveIntegerField(default=1, verbose_name='number')),
('ordered_at', models.DateTimeField(default=django.utils.timezone.now, verbose_name='order date')),
('served', models.BooleanField(default=False, verbose_name='served')),
('served_at', models.DateTimeField(blank=True, null=True, verbose_name='served date')),
('activity', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='food_orders', to='activity.activity', verbose_name='activity')),
('dish', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='orders', to='food.dish', verbose_name='dish')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='food_orders', to=settings.AUTH_USER_MODEL, verbose_name='user')),
],
options={
'verbose_name': 'Order',
'verbose_name_plural': 'Orders',
},
),
migrations.CreateModel(
name='FoodTransaction',
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')),
('order', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='transaction', to='food.order', verbose_name='order')),
],
options={
'verbose_name': 'food transaction',
'verbose_name_plural': 'food transactions',
},
bases=('note.transaction',),
),
migrations.CreateModel(
name='Supplement',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('price', models.PositiveIntegerField(verbose_name='price')),
('dish', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='supplements', to='food.dish', verbose_name='dish')),
('food', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='supplements', to='food.food', verbose_name='food')),
],
options={
'verbose_name': 'Supplement',
'verbose_name_plural': 'Supplements',
},
),
migrations.AddField(
model_name='order',
name='supplements',
field=models.ManyToManyField(blank=True, related_name='orders', to='food.supplement', verbose_name='supplements'),
),
migrations.AlterUniqueTogether(
name='order',
unique_together={('activity', 'number')},
),
]

View File

@@ -4,10 +4,14 @@
from datetime import timedelta
from django.db import models, transaction
from django.core.exceptions import ValidationError
from django.utils import timezone
from django.contrib.auth.models import User
from django.utils.translation import gettext_lazy as _
from polymorphic.models import PolymorphicModel
from member.models import Club
from activity.models import Activity
from note.models import Transaction
class Allergen(models.Model):
@@ -284,3 +288,199 @@ class QRCode(models.Model):
def __str__(self):
return _('QR-code number') + ' ' + str(self.qr_code_number)
class Dish(models.Model):
"""
A dish is a food proposed during a meal
"""
main = models.ForeignKey(
TransformedFood,
on_delete=models.PROTECT,
related_name='dishes_as_main',
verbose_name=_('main food'),
)
price = models.PositiveIntegerField(
verbose_name=_('price')
)
activity = models.ForeignKey(
Activity,
on_delete=models.CASCADE,
related_name='dishes',
verbose_name=_('activity'),
)
available = models.BooleanField(
default=True,
verbose_name=_('available'),
)
class Meta:
verbose_name = _('Dish')
verbose_name_plural = _('Dishes')
unique_together = ('main', 'activity')
def __str__(self):
return self.main.name + ' (' + str(self.activity) + ')'
def save(self, *args, **kwargs):
"Check the type of activity"
if self.activity.activity_type.name != 'Perm bouffe':
raise ValidationError(_('(You cannot select this type of activity.'))
return super().save(*args, **kwargs)
class Supplement(models.Model):
"""
A supplement is a food added to a dish
"""
dish = models.ForeignKey(
Dish,
on_delete=models.CASCADE,
related_name='supplements',
verbose_name=_('dish'),
)
food = models.ForeignKey(
Food,
on_delete=models.PROTECT,
related_name='supplements',
verbose_name=_('food'),
)
price = models.PositiveIntegerField(
verbose_name=_('price')
)
class Meta:
verbose_name = _('Supplement')
verbose_name_plural = _('Supplements')
def __str__(self):
return _("Supplement {food} for {dish}").format(
food=str(self.food), dish=str(self.dish))
class Order(models.Model):
"""
An order is a dish ordered by a member during an activity
"""
user = models.ForeignKey(
User,
on_delete=models.CASCADE,
related_name='food_orders',
verbose_name=_('user'),
)
activity = models.ForeignKey(
Activity,
on_delete=models.CASCADE,
related_name='food_orders',
verbose_name=_('activity'),
)
dish = models.ForeignKey(
Dish,
on_delete=models.CASCADE,
related_name='orders',
verbose_name=_('dish'),
)
supplements = models.ManyToManyField(
Supplement,
related_name='orders',
verbose_name=_('supplements'),
blank=True,
)
request = models.TextField(
blank=True,
verbose_name=_('request'),
help_text=_('A specific request (to remove an ingredient for example)')
)
number = models.PositiveIntegerField(
verbose_name=_('number'),
default=1,
)
ordered_at = models.DateTimeField(
default=timezone.now,
verbose_name=_('order date'),
)
served = models.BooleanField(
default=False,
verbose_name=_('served'),
)
served_at = models.DateTimeField(
null=True,
blank=True,
verbose_name=_('served date'),
)
class Meta:
verbose_name = _('Order')
verbose_name_plural = _('Orders')
unique_together = ('activity', 'number', )
@property
def amount(self):
return self.dish.price + sum(s.price for s in self.supplements.all())
def __str__(self):
return _("Order of {dish} by {user}").format(
dish=str(self.dish),
user=str(self.user))
def save(self, *args, **kwargs):
created = self.pk is None
if created:
last_order = Order.objects.filter(activity=self.activity).last()
if last_order is None:
self.number = 1
else:
self.number = last_order.number + 1
elif self.served:
if FoodTransaction.objects.filter(order=self).exists():
transaction = FoodTransaction.objects.get(order=self)
transaction.valid = True
transaction.save()
else:
transaction = FoodTransaction(
source=self.user.note,
destination=self.activity.organizer.note,
amount=self.amount,
quantity=1,
valid=True,
order=self,
)
transaction.save()
else:
if FoodTransaction.objects.filter(order=self).exists():
transaction = FoodTransaction.objects.get(order=self)
transaction.valid = False
transaction.save()
return super().save(*args, **kwargs)
class FoodTransaction(Transaction):
"""
Special type of :model:`note.Transaction` associated to a :model:`food.Order`.
"""
order = models.ForeignKey(
Order,
on_delete=models.PROTECT,
related_name='transaction',
verbose_name=_('order')
)
class Meta:
verbose_name = _("food transaction")
verbose_name_plural = _("food transactions")

View File

@@ -0,0 +1,46 @@
/**
* On click of "delete", delete the order
* @param button_id:Integer Order id to remove
* @param table_id: Id of the table to reload
*/
function delete_button (button_id, table_id) {
$.ajax({
url: '/api/food/order/' + button_id + '/',
method: 'DELETE',
headers: { 'X-CSRFTOKEN': CSRF_TOKEN }
}).done(function () {
$('#' + table_id).load(location.pathname + ' #' + table_id + ' > *')
}).fail(function (xhr, _textStatus, _error) {
errMsg(xhr.responseJSON, 10000)
})
}
/**
* On click of "Serve", mark the order as served
* @param button_id: Order id
* @param table_id: Id of the table to reload
*/
function serve_button(button_id, table_id, current_state) {
console.log("update")
const new_state = !current_state;
$.ajax({
url: '/api/food/order/' + button_id + '/',
method: 'PATCH',
headers: { 'X-CSRFTOKEN': CSRF_TOKEN },
contentType: 'application/json',
data: JSON.stringify({
served: new_state
})
})
.done(function () {
if (current_state) {
$('table').load(location.pathname + ' table')
}
else {
$('#' + table_id).load(location.pathname + ' #' + table_id + ' > *');
}
})
.fail(function (xhr) {
errMsg(xhr.responseJSON, 10000);
});
}

View File

@@ -3,8 +3,11 @@
import django_tables2 as tables
from django.utils.translation import gettext_lazy as _
from note_kfet.middlewares import get_current_request
from note.templatetags.pretty_money import pretty_money
from permission.backends import PermissionBackend
from .models import Food
from .models import Food, Dish, Order
class FoodTable(tables.Table):
@@ -35,3 +38,84 @@ class FoodTable(tables.Table):
'data-href': lambda record: 'detail/' + str(record.pk),
'style': 'cursor:pointer',
}
class DishTable(tables.Table):
"""
List dishes
"""
supplements = tables.Column(empty_values=(), verbose_name=_('Available supplements'), orderable=False)
def render_supplements(self, record):
return ", ".join(str(q.food) for q in record.supplements.all())
def render_price(self, value):
return pretty_money(value)
class Meta:
model = Dish
template_name = 'django_tables2/bootstrap4.html'
fields = ('main', 'supplements', 'price', 'available')
row_attrs = {
'class': 'table-row',
'data-href': lambda record: str(record.pk),
'style': 'cursor:pointer',
}
DELETE_TEMPLATE = """
<button id="{{ record.pk }}"
class="btn btn-danger btn-sm"
onclick="delete_button(this.id, 'orders_table_{{ table.prefix }}')">
{{ delete_trans }}
</button>
"""
SERVE_TEMPLATE = """
<button id="{{ record.pk }}"
class="btn btn-sm {% if record.served %}btn-secondary{% else %}btn-success{% endif %}"
onclick="serve_button(this.id, 'orders_table_{{ table.prefix }}', {{ record.served|yesno:'true,false' }})">
{% if record.served %}
{{ record.served_at|date:"d/m/Y H:i" }}
{% else %}""" + _('Serve') + """
{% endif %}
</button>
"""
class OrderTable(tables.Table):
"""
Lis all orders.
"""
delete = tables.TemplateColumn(
template_code=DELETE_TEMPLATE,
extra_context={"delete_trans": _('Delete')},
orderable=False,
attrs={'td': {'class': lambda record: 'col-sm-1' + (
' d-none' if not PermissionBackend.check_perm(
get_current_request(), "food.delete_order",
record) else '')}}, verbose_name=_("Delete"), )
serve = tables.TemplateColumn(
template_code=SERVE_TEMPLATE,
extra_context={"serve_trans": _('Serve')},
orderable=False,
attrs={'td': {'class': lambda record: 'col-sm-1' + (
' d-none' if not PermissionBackend.check_perm(
get_current_request(), "food.change_order_saved",
record) else '')}}, verbose_name=_("Serve"), )
request = tables.Column(
orderable=False
)
class Meta:
model = Order
template_name = 'django_tables2/bootstrap4.html'
fields = ('ordered_at', 'user', 'dish', 'supplements', 'request', 'serve', 'delete')
order_by = ('ordered_at', )
row_attrs = {
'class': 'table-row',
'style': 'cursor:pointer',
}

View File

@@ -0,0 +1,25 @@
{% extends "base.html" %}
{% comment %}
SPDX-License-Identifier: GPL-3.0-or-later
{% endcomment %}
{% load i18n crispy_forms_tags %}
{% block content %}
<div class="card bg-light">
<div class="card-header text-center">
<h4>{% trans "Delete dish" %}</h4>
</div>
<div class="card-body">
<div class="alert alert-warning">
{% blocktrans %}Are you sure you want to delete this dish? This action can't be undone.{% endblocktrans %}
</div>
</div>
<div class="card-footer text-center">
<form method="post">
{% csrf_token %}
<a class="btn btn-primary" href="{% url 'food:dish_detail' activity_pk=object.activity.pk pk=object.pk%}">{% trans "Return to dish detail" %}</a>
<button class="btn btn-danger" type="submit">{% trans "Delete" %}</button>
</form>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,41 @@
{% extends "base.html" %}
{% comment %}
Copyright (C) 2018-2025 by BDE ENS Paris-Saclay
SPDX-License-Identifier: GPL-3.0-or-later
{% endcomment %}
{% load render_table from django_tables2 %}
{% load i18n pretty_money %}
{% block content %}
<div class="card bg-white mb-3">
<h3 class="card-header text-center">
{{ title }} {{ food.name }}
</h3>
<div class="card-body">
<ul>
<li> {% trans "Associated food" %} :
<a href="{% url "food:transformedfood_view" pk=food.pk %}">
{{ food.name }}
</a>
</li>
<li> {% trans "Sell price" %} : {{ dish.price|pretty_money }}</li>
<li> {% trans "Available" %} : {{ dish.available|yesno }}</li>
<li> {% trans "Possible supplements" %} :
{% for supp in supplements %}
<a href="{% url "food:food_view" pk=supp.food.pk %}">{{ supp.food.name }} ({{ supp.price|pretty_money }})</a>{% if not forloop.last %},{% endif %}
{% endfor %}
</li>
</ul>
{% if update %}
<a class="btn btn-sm btn-secondary" href="{% url "food:dish_update" activity_pk=dish.activity.pk pk=dish.pk %}">
{% trans "Update" %}
</a>
{% endif %}
{% if delete %}
<a class="btn btn-sm btn-danger" href="{% url "food:dish_delete" activity_pk=dish.activity.pk pk=dish.pk %}">
{% trans "Delete" %}
</a>
{% endif %}
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,94 @@
{% extends "base.html" %}
{% comment %}
Copyright (C) 2018-2025 by BDE ENS Paris-Saclay
SPDX-License-Identifier: GPL-3.0-or-later
{% endcomment %}
{% load i18n crispy_forms_tags %}
{% block content %}
<div class="card bg-white mb-3">
<h3 class="card-header text-center">
{{ title }}
</h3>
<form method="post" action="">
{% csrf_token %}
<div class="card-body">
{% crispy form %}
</div>
<h3 class="card-header text-center">
{% trans "Ajouter des suppléments (optionnel)" %}
</h3>
{{ formset.management_form }}
<table class="table table-condensed table-striped">
{% for form in formset %}
{% if forloop.first %}
<thead>
<tr>
<th>{{ form.food.label }}<span class="asteriskField">*</span></th>
<th>{{ form.price.label }}<span class="asteriskField">*</span></th>
</tr>
</thead>
<tbody id="form_body">
{% endif %}
<tr class="row-formset">
<td>{{ form.food }}</td>
<td>{{ form.price }}</td>
{# These fields are hidden but handled by the formset to link the id and the invoice id #}
{{ form.dish }}
{{ form.id }}
</tr>
{% endfor %}
</tbody>
</table>
{# Display buttons to add and remove supplements #}
<div class="card-body">
<div class="btn-group btn-block" role="group">
<button type="button" id="add_more" class="btn btn-success">{% trans "Add supplement" %}</button>
<button type="button" id="remove_one" class="btn btn-danger">{% trans "Remove supplement" %}</button>
</div>
<button type="submit" class="btn btn-block btn-primary">{% trans "Submit" %}</button>
</div>
</form>
</div>
{# Hidden div that store an empty supplement 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.food }}</td>
<td>{{ formset.empty_form.price }} </td>
{{ formset.empty_form.dish }}
{{ formset.empty_form.id }}
</tr>
</tbody>
</table>
</div>
{% endblock %}
{% block extrajavascript %}
<script>
/* script that handles add and remove lines */
IDS = {};
$("#id_supplements-TOTAL_FORMS").val($(".row-formset").length - 1);
$('#add_more').click(function () {
let form_idx = $('#id_supplements-TOTAL_FORMS').val();
$('#form_body').append($('#for_real').html().replace(/__prefix__/g, form_idx));
$('#id_supplements-TOTAL_FORMS').val(parseInt(form_idx) + 1);
$('#id_supplements-' + parseInt(form_idx) + '-id').val(IDS[parseInt(form_idx)]);
});
$('#remove_one').click(function () {
let form_idx = $('#id_supplements-TOTAL_FORMS').val();
if (form_idx > 0) {
IDS[parseInt(form_idx) - 1] = $('#id_supplements-' + (parseInt(form_idx) - 1) + '-id').val();
$('#form_body tr:last-child').remove();
$('#id_supplements-TOTAL_FORMS').val(parseInt(form_idx) - 1);
}
});
</script>
{% endblock %}

View File

@@ -0,0 +1,33 @@
{% extends "base.html" %}
{% comment %}
Copyright (C) 2018-2025 by BDE ENS Paris-Saclay
SPDX-License-Identifier: GPL-3.0-or-later
{% endcomment %}
{% load render_table from django_tables2 %}
{% load i18n %}
{% block content %}
<div class="card bg-white mb-3">
<h3 class="card-header text-center">
{{ title }} {{activity.name}}
</h3>
{% render_table table %}
<div class="card-footer">
{% if can_add_dish %}
<a class="btn btn-sm btn-success" href="{% url 'food:dish_create' activity_pk=activity.pk %}">{% trans "New dish" %}</a>
{% endif %}
<a class="btn btn-sm btn-secondary" href="{% url 'activity:activity_detail' pk=activity.pk %}">{% trans "Activity page" %}</a>
<a class="btn btn-sm btn-primary" href="{% url "food:food_list" %}">
{% trans "Return to the food list" %}
</a>
</div>
</div>
{% endblock %}
{% block extrajavascript %}
<script type="text/javascript">
$(".table-row").click(function () {
window.document.location = $(this).data("href");
});
</script>
{% endblock %}

View File

@@ -64,13 +64,19 @@ SPDX-License-Identifier: GPL-3.0-or-later
<h3 class="card-header text-center">
{% trans "Meal served" %}
</h3>
{% if can_add_meal %}
<div class="card-footer">
{% if can_add_meal %}
<a class="btn btn-sm btn-primary" href="{% url 'food:transformedfood_create' %}">
{% trans "New meal" %}
</a>
{% endif %}
{% for activity in open_activities %}
<a class="btn btn-sm btn-secondary" href="{% url 'food:dish_list' activity_pk=activity.pk %}">
{% trans "View" %} {{ activity.name }}
</a>
{% endfor %}
</div>
{% endif %}
{% if served.data %}
{% render_table served %}
{% else %}

View File

@@ -0,0 +1,25 @@
{% extends "base.html" %}
{% comment %}
SPDX-License-Identifier: GPL-3.0-or-later
{% endcomment %}
{% load i18n crispy_forms_tags %}
{% block content %}
<div class="card bg-light">
<div class="card-header text-center">
<h4>{% trans "Delete order" %}</h4>
</div>
<div class="card-body">
<div class="alert alert-warning">
{% blocktrans %}Are you sure you want to delete this order? This action can't be undone.{% endblocktrans %}
</div>
</div>
<div class="card-footer text-center">
<form method="post">
{% csrf_token %}
<a class="btn btn-primary" href="{% url 'food:order_list' activity_pk=object.activity.pk%}">{% trans "Return to order list" %}</a>
<button class="btn btn-danger" type="submit">{% trans "Delete" %}</button>
</form>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,21 @@
{% extends "base.html" %}
{% comment %}
Copyright (C) 2018-2025 by BDE ENS Paris-Saclay
SPDX-License-Identifier: GPL-3.0-or-later
{% endcomment %}
{% load i18n crispy_forms_tags %}
{% block content %}
<div class="card bg-white mb-3">
<h3 class="card-header text-center">
{{ title }}
</h3>
<div class="card-body" id="form">
<form method="post">
{% csrf_token %}
{{ form | crispy }}
<button class="btn btn-primary" type="submit">{% trans "Submit"%}</button>
</form>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,30 @@
{% extends "base.html" %}
{% comment %}
Copyright (C) 2018-2025 by BDE ENS Paris-Saclay
SPDX-License-Identifier: GPL-3.0-or-later
{% endcomment %}
{% load render_table from django_tables2 %}
{% load static i18n %}
{% block content %}
<div class="card bg-white mb-3">
<h3 class="card-header text-center">
{{ title }}
</h3>
<a class="btn btn-primary" href="{% url 'food:served_order_list' activity_pk=activity.pk %}">{% trans "View served orders" %}</a>
{% for table in tables %}
<div class="card bg-light mb-3" id="orders_table_{{ table.prefix }}">
<h3 class="card-header text-center">
{% trans "Orders of " %} {{ table.prefix }}
</h3>
{% if table.data %}
{% render_table table %}
{% endif %}
</div>
{% endfor %}
</div>
{% endblock %}
{% block extrajavascript %}
<script src="{% static "food/js/order.js" %}"></script>
{% endblock%}

View File

@@ -0,0 +1,21 @@
{% extends "base.html" %}
{% comment %}
Copyright (C) 2018-2025 by BDE ENS Paris-Saclay
SPDX-License-Identifier: GPL-3.0-or-later
{% endcomment %}
{% load render_table from django_tables2 %}
{% load static i18n %}
{% block content %}
<div class="card bg-white mb-3">
<h3 class="card-header text-center">
{{ title }} {{activity.name}}
</h3>
<a class="btn btn-primary" href="{% url 'food:order_list' activity_pk=activity.pk %}">{% trans "View unserved orders" %}</a>
{% render_table table %}
</div>
{% endblock %}
{% block extrajavascript %}
<script src="{% static "food/js/order.js" %}"></script>
{% endblock%}

View File

@@ -0,0 +1,17 @@
{% extends "base.html" %}
{% comment %}
Copyright (C) 2018-2025 by BDE ENS Paris-Saclay
SPDX-License-Identifier: GPL-3.0-or-later
{% endcomment %}
{% load render_table from django_tables2 %}
{% load i18n pretty_money %}
{% block content %}
<div class="card bg-white mb-3">
<h3 class="card-header text-center">
{{ title }} {{ supplement.name }}
</h3>
<div class="card-body">
</div>
</div>
{% endblock %}

View File

@@ -19,4 +19,14 @@ urlpatterns = [
path('detail/transformed/<int:pk>', views.TransformedFoodDetailView.as_view(), name='transformedfood_view'),
path('add/ingredient/<int:pk>', views.AddIngredientView.as_view(), name='add_ingredient'),
path('redirect/', views.QRCodeRedirectView.as_view(), name='redirect_view'),
# TODO not always store activity_pk in url
path('activity/<int:activity_pk>/dishes/add/', views.DishCreateView.as_view(), name='dish_create'),
path('activity/<int:activity_pk>/dishes/', views.DishListView.as_view(), name='dish_list'),
path('activity/<int:activity_pk>/dishes/<int:pk>/', views.DishDetailView.as_view(), name='dish_detail'),
path('activity/<int:activity_pk>/dishes/<int:pk>/update/', views.DishUpdateView.as_view(), name='dish_update'),
path('activity/<int:activity_pk>/dishes/<int:pk>/delete/', views.DishDeleteView.as_view(), name='dish_delete'),
path('activity/<int:activity_pk>/order/', views.OrderCreateView.as_view(), name='order_create'),
path('activity/<int:activity_pk>/orders/', views.OrderListView.as_view(), name='order_list'),
path('activity/<int:activity_pk>/orders/served', views.ServedOrderListView.as_view(), name='served_order_list'),
path('activity/orders/<int:pk>/delete/', views.OrderDeleteView.as_view(), name='order_delete'),
]

View File

@@ -4,25 +4,30 @@
from datetime import timedelta
from api.viewsets import is_regex
from django_tables2.views import MultiTableMixin
from crispy_forms.helper import FormHelper
from django_tables2.views import SingleTableView, MultiTableMixin
from django.core.exceptions import PermissionDenied
from django.db import transaction
from django.db.models import Q
from django.http import HttpResponseRedirect, Http404
from django.views.generic import DetailView, UpdateView, CreateView
from django.views.generic.list import ListView
from django.views.generic.base import RedirectView
from django.views.generic.edit import DeleteView
from django.urls import reverse_lazy
from django.utils import timezone
from django.utils.translation import gettext_lazy as _
from member.models import Club, Membership
from activity.models import Activity
from permission.backends import PermissionBackend
from permission.views import ProtectQuerysetMixin, ProtectedCreateView, LoginRequiredMixin
from .models import Food, BasicFood, TransformedFood, QRCode
from .models import Food, BasicFood, TransformedFood, QRCode, Order, Dish
from .forms import QRCodeForms, BasicFoodForms, TransformedFoodForms, \
ManageIngredientsForm, ManageIngredientsFormSet, AddIngredientForms, \
BasicFoodUpdateForms, TransformedFoodUpdateForms
from .tables import FoodTable
BasicFoodUpdateForms, TransformedFoodUpdateForms, \
DishForm, SupplementFormSet, SupplementFormSetHelper, OrderForm
from .tables import FoodTable, DishTable, OrderTable
from .utils import pretty_duration
@@ -74,15 +79,11 @@ class FoodListView(ProtectQuerysetMixin, LoginRequiredMixin, MultiTableMixin, Li
search_table = qs.filter(PermissionBackend.filter_queryset(self.request, Food, 'view'))
# table open
open_table = self.get_queryset().filter(
open_table = self.get_queryset().order_by('expiry_date').filter(
Q(polymorphic_ctype__model='transformedfood')
| Q(polymorphic_ctype__model='basicfood', basicfood__date_type='DLC')).filter(
expiry_date__lt=timezone.now(), end_of_life='').filter(
PermissionBackend.filter_queryset(self.request, Food, 'view'))
open_table = open_table.union(self.get_queryset().filter(
Q(end_of_life='', order__iexact='open')
).filter(
PermissionBackend.filter_queryset(self.request, Food, 'view'))).order_by('expiry_date')
# table served
served_table = self.get_queryset().order_by('-pk').filter(
end_of_life='', is_ready=True).exclude(
@@ -116,6 +117,9 @@ class FoodListView(ProtectQuerysetMixin, LoginRequiredMixin, MultiTableMixin, Li
context['club_tables'] = tables[3:]
context['can_add_meal'] = PermissionBackend.check_perm(self.request, 'food.transformedfood_add')
context["open_activities"] = Activity.objects.filter(activity_type__name="Perm bouffe", open=True)
return context
@@ -530,3 +534,270 @@ class QRCodeRedirectView(RedirectView):
if slug:
return reverse_lazy('food:qrcode_create', kwargs={'slug': slug})
return reverse_lazy('food:list')
class DishCreateView(ProtectQuerysetMixin, ProtectedCreateView):
"""
Create a dish
"""
model = Dish
form_class = DishForm
extra_context = {"title": _('Create dish')}
def get_sample_object(self):
activity = Activity.objects.get(pk=self.kwargs["activity_pk"])
sample_food = TransformedFood(
name="Sample food",
owner=activity.organizer,
expiry_date=timezone.now() + timedelta(days=7),
is_ready=True,
)
sample_dish = Dish(
main=sample_food,
price=100,
activity=activity,
)
return sample_dish
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 supplements
form_set = SupplementFormSet(instance=form.instance)
context['formset'] = form_set
context['helper'] = SupplementFormSetHelper()
return context
def get_form(self, form_class=None):
form = super().get_form(form_class)
if "available" in form.fields:
del form.fields["available"]
return form
@transaction.atomic
def form_valid(self, form):
activity = Activity.objects.get(pk=self.kwargs["activity_pk"])
form.instance.activity = activity
ret = super().form_valid(form)
# For each supplement, we save it
formset = SupplementFormSet(self.request.POST, instance=form.instance)
if formset.is_valid():
for f in formset:
if f.is_valid():
f.save()
f.instance.save()
else:
f.instance = None
return ret
def get_success_url(self):
return reverse_lazy('food:dish_list', kwargs={"activity_pk": self.kwargs["activity_pk"]})
class DishListView(ProtectQuerysetMixin, LoginRequiredMixin, SingleTableView):
"""
List dishes for this activity
"""
model = Dish
table_class = DishTable
extra_context = {"title": _('Dishes served during')}
template_name = 'food/dish_list.html'
def get_queryset(self):
return super().get_queryset().filter(activity__pk=self.kwargs["activity_pk"])
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
activity = Activity.objects.get(pk=self.kwargs["activity_pk"])
context["activity"] = activity
context["can_add_dish"] = PermissionBackend.check_perm(self.request, 'food.dish_add')
return context
class DishDetailView(ProtectQuerysetMixin, LoginRequiredMixin, DetailView):
"""
View a dish for this activity
"""
model = Dish
extra_context = {"title": _('Details of:')}
context_oject_name = "dish"
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context["food"] = self.object.main
context["supplements"] = self.object.supplements.all()
context["update"] = PermissionBackend.check_perm(self.request, "food.change_dish")
context["delete"] = not Order.objects.filter(dish=self.get_object()).exists() and PermissionBackend.check_perm(self.request, "food.delete_dish")
return context
class DishUpdateView(ProtectQuerysetMixin, LoginRequiredMixin, UpdateView):
"""
A view to update a dish
"""
model = Dish
form_class = DishForm
extra_context = {"title": _("Update a dish")}
def get_form(self, **kwargs):
form = super().get_form(**kwargs)
if 'main' in form.fields:
del form.fields["main"]
return form
class DishDeleteView(ProtectQuerysetMixin, LoginRequiredMixin, DeleteView):
"""
Delete a dish with no order yet
"""
model = Dish
extra_context = {"title": _('Delete dish')}
def delete(self, request, *args, **kwargs):
if Order.objects.filter(dish=self.get_object()).exists():
raise PermissionDenied(_("This dish cannot be deleted because it has already been ordered"))
return super().delete(request, *args, **kwargs)
def get_success_url(self):
return reverse_lazy('food:dish_list', kwargs={"activity_pk": self.kwargs["activity_pk"]})
class OrderCreateView(ProtectQuerysetMixin, ProtectedCreateView):
"""
Order a meal
"""
model = Order
form_class = OrderForm
extra_context = {"title": _('Order food')}
def get_sample_object(self):
activity = Activity.objects.get(pk=self.kwargs["activity_pk"])
sample_order = Order(
user=self.request.user,
activity=activity,
dish=Dish.objects.filter(activity=activity).last(),
)
return sample_order
def get_form(self):
form = super().get_form()
form.fields["user"].initial = self.request.user
form.fields["user"].disabled = True
return form
def form_valid(self, form):
activity = Activity.objects.get(pk=self.kwargs["activity_pk"])
form.instance.activity = activity
return super().form_valid(form)
def get_success_url(self):
return reverse_lazy('food:food_list')
class OrderListView(ProtectQuerysetMixin, LoginRequiredMixin, MultiTableMixin, ListView):
"""
List existing Families
"""
model = Order
table_class = OrderTable
extra_context = {"title": _('Order list')}
paginate_by = 10
def get_queryset(self, **kwargs):
activity = Activity.objects.get(pk=self.kwargs["activity_pk"])
return Order.objects.filter(activity=activity)
def get_tables(self):
activity = Activity.objects.get(pk=self.kwargs["activity_pk"])
dishes = Dish.objects.filter(activity=activity)
tables = [OrderTable] * dishes.count()
self.tables = tables
tables = super().get_tables()
for i in range(dishes.count()):
tables[i].prefix = dishes[i].main.name
return tables
def get_tables_data(self):
activity = Activity.objects.get(pk=self.kwargs["activity_pk"])
dishes = Dish.objects.filter(activity=activity)
tables = []
for dish in dishes:
tables.append(self.get_queryset().order_by('ordered_at').filter(
dish=dish, served=False).filter(
PermissionBackend.filter_queryset(self.request, Order, 'view')
))
return tables
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context["activity"] = Activity.objects.get(pk=self.kwargs["activity_pk"])
return context
class ServedOrderListView(ProtectQuerysetMixin, LoginRequiredMixin, SingleTableView):
"""
View served orders
"""
model = Order
template_name = 'food/served_order_list.html'
table_class = OrderTable
def get_queryset(self):
return super().get_queryset().filter(activity__pk=self.kwargs["activity_pk"], served=True).order_by('-served_at')
def get_table(self, **kwargs):
table = super().get_table(**kwargs)
table.columns.hide("delete")
return table
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context["activity"] = Activity.objects.get(pk=self.kwargs["activity_pk"])
return context
class OrderDeleteView(ProtectQuerysetMixin, LoginRequiredMixin, DeleteView):
"""
Delete an order
"""
model = Order
extra_context = {"title": _('Delete dish')}
def delete(self, request, *args, **kwargs):
if self.get_object().served:
raise PermissionDenied(_("This order cannot be deleted because it has already been served"))
return super().delete(request, *args, **kwargs)
def get_success_url(self):
return reverse_lazy('food:order_list', kwargs={"activity_pk": self.kwargs["activity_pk"]})

View File

@@ -417,7 +417,7 @@ class Membership(models.Model):
A membership is valid if today is between the start and the end date.
"""
if self.date_end is not None:
return self.date_start.toordinal() <= datetime.datetime.now().toordinal() <= self.date_end.toordinal()
return self.date_start.toordinal() <= datetime.datetime.now().toordinal() < self.date_end.toordinal()
else:
return self.date_start.toordinal() <= datetime.datetime.now().toordinal()

View File

@@ -228,7 +228,7 @@ function consume (source, source_alias, dest, quantity, amount, reason, type, ca
addMsg(interpolate(gettext('Warning, the transaction from the note %s succeed, ' +
'but the emitter note %s is negative.'), [source_alias, source_alias]), 'warning', 30000)
}
if (source.membership && source.membership.date_end <= new Date().toISOString()) {
if (source.membership && source.membership.date_end < new Date().toISOString()) {
addMsg(interpolate(gettext('Warning, the emitter note %s is no more a BDE member.'), [source_alias]),
'danger', 30000)
}

View File

@@ -310,10 +310,10 @@ $('#btn_transfer').click(function () {
destination: dest.note.id,
destination_alias: dest.name
}).done(function () {
if (source.note.membership && source.note.membership.date_end <= new Date().toISOString()) {
if (source.note.membership && source.note.membership.date_end < new Date().toISOString()) {
addMsg(interpolate(gettext('Warning, the emitter note %s is no more a BDE member.'), [source.name]), 'danger', 30000)
}
if (dest.note.membership && dest.note.membership.date_end <= new Date().toISOString()) {
if (dest.note.membership && dest.note.membership.date_end < new Date().toISOString()) {
addMsg(interpolate(gettext('Warning, the destination note %s is no more a BDE member.'), [dest.name]), 'danger', 30000)
}
@@ -414,7 +414,7 @@ $('#btn_transfer').click(function () {
bank: $('#bank').val()
}).done(function () {
addMsg(gettext('Credit/debit succeed!'), 'success', 10000)
if (user_note.membership && user_note.membership.date_end <= new Date().toISOString()) { addMsg(gettext('Warning, the emitter note %s is no more a BDE member.'), 'danger', 10000) }
if (user_note.membership && user_note.membership.date_end < new Date().toISOString()) { addMsg(gettext('Warning, the emitter note %s is no more a BDE member.'), 'danger', 10000) }
reset()
}).fail(function (err) {
const errObj = JSON.parse(err.responseText)

View File

@@ -39,15 +39,7 @@ class PermissionBackend(ModelBackend):
def permission_filter(membership_obj):
query = Q(pk=-1)
if 'mask' in request.GET:
try:
rank = int(request.GET['mask'])
except:
rank = 42
query &= Q(mask__rank__lte=rank)
for scope in request.auth.scope.split(' '):
if scope == "openid":
continue
permission_id, club_id = scope.split('_')
if int(club_id) == membership_obj.club_id:
query |= Q(pk=permission_id)

View File

@@ -10,7 +10,6 @@ from note_kfet.middlewares import get_current_request
from .backends import PermissionBackend
from .models import Permission
from django.utils.translation import gettext_lazy as _
class PermissionScopes(BaseScopes):
"""
@@ -33,7 +32,7 @@ class PermissionScopes(BaseScopes):
scopes = {f"{p.id}_{club.id}": f"{p.description} (club {club.name})"
for p in Permission.objects.all() for club in Club.objects.all()}
scopes['openid'] = _("OpenID Connect (username and email)")
scopes['openid'] = "OpenID Connect"
return scopes
def get_available_scopes(self, application=None, request=None, *args, **kwargs):

View File

@@ -1,18 +0,0 @@
# Generated by Django 5.2.6 on 2025-09-28 20:12
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('treasury', '0010_alter_invoice_bde'),
]
operations = [
migrations.AddField(
model_name='sogecredit',
name='valid',
field=models.BooleanField(blank=True, default=False, verbose_name='Valid'),
),
]

View File

@@ -308,12 +308,6 @@ class SogeCredit(models.Model):
null=True,
)
valid = models.BooleanField(
default=False,
verbose_name=_("Valid"),
blank=True,
)
class Meta:
verbose_name = _("Credit from the Société générale")
verbose_name_plural = _("Credits from the Société générale")
@@ -344,7 +338,7 @@ class SogeCredit(models.Model):
credit_transaction.save()
credit_transaction.refresh_from_db()
self.credit_transaction = credit_transaction
elif not self.valid_legacy:
elif not self.valid:
self.credit_transaction.amount = self.amount
self.credit_transaction._force_save = True
self.credit_transaction.save()
@@ -352,12 +346,12 @@ class SogeCredit(models.Model):
return super().save(*args, **kwargs)
@property
def valid_legacy(self):
def valid(self):
return self.credit_transaction and self.credit_transaction.valid
@property
def amount(self):
if self.valid_legacy:
if self.valid:
return self.credit_transaction.total
amount = 0
transactions_wei = self.transactions.filter(membership__club__weiclub__isnull=False)
@@ -371,7 +365,7 @@ class SogeCredit(models.Model):
The Sogé credit may be created after the user already paid its memberships.
We query transactions and update the credit, if it is unvalid.
"""
if self.valid_legacy or not self.pk:
if self.valid or not self.pk:
return
# Soge do not pay BDE and kfet memberships since 2022
@@ -411,7 +405,7 @@ class SogeCredit(models.Model):
Invalidating a Société générale delete the transaction of the bank if it was already created.
Treasurers must know what they do, With Great Power Comes Great Responsibility...
"""
if self.valid_legacy:
if self.valid:
self.credit_transaction.valid = False
self.credit_transaction.save()
for tr in self.transactions.all():
@@ -420,7 +414,7 @@ class SogeCredit(models.Model):
tr.save()
def validate(self, force=False):
if self.valid_legacy and not force:
if self.valid and not force:
# The credit is already done
return

Binary file not shown.

Before

Width:  |  Height:  |  Size: 104 KiB

After

Width:  |  Height:  |  Size: 284 KiB

View File

@@ -359,7 +359,7 @@ class TestSogeCredits(TestCase):
))
self.assertRedirects(response, reverse("treasury:manage_soge_credit", args=(soge_credit.pk,)), 302, 200)
soge_credit.refresh_from_db()
self.assertTrue(soge_credit.valid_legacy)
self.assertTrue(soge_credit.valid)
self.user.note.refresh_from_db()
self.assertEqual(
Transaction.objects.filter(Q(source=self.user.note) | Q(destination=self.user.note)).count(), 3)

View File

@@ -74,6 +74,7 @@ class InvoiceCreateView(ProtectQuerysetMixin, ProtectedCreateView):
# For each product, we save it
formset = ProductFormSet(self.request.POST, instance=form.instance)
print(formset)
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

View File

@@ -12,7 +12,7 @@ django-filter~=25.1
django-mailer~=2.3.2
django-oauth-toolkit~=3.0.1
django-phonenumber-field~=8.1.0
django-polymorphic~=4.1.0
django-polymorphic~=3.1.0
djangorestframework~=3.16.0
django-rest-polymorphic~=0.1.10
django-tables2~=2.7.5