mirror of
https://gitlab.crans.org/bde/nk20
synced 2025-11-17 20:07:52 +01:00
Compare commits
1 Commits
main
...
6bf21b103f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6bf21b103f |
@@ -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
|
||||
}
|
||||
}
|
||||
]
|
||||
]
|
||||
@@ -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>
|
||||
|
||||
@@ -152,11 +152,9 @@ class ActivityDetailView(ProtectQuerysetMixin, LoginRequiredMixin, MultiTableMix
|
||||
def get_tables_data(self):
|
||||
return [
|
||||
Guest.objects.filter(activity=self.object)
|
||||
.filter(PermissionBackend.filter_queryset(self.request, Guest, "view"))
|
||||
.distinct(),
|
||||
.filter(PermissionBackend.filter_queryset(self.request, Guest, "view")),
|
||||
self.object.opener.filter(activity=self.object)
|
||||
.filter(PermissionBackend.filter_queryset(self.request, Opener, "view"))
|
||||
.distinct(),
|
||||
.filter(PermissionBackend.filter_queryset(self.request, Opener, "view")),
|
||||
]
|
||||
|
||||
def render_to_response(self, context, **response_kwargs):
|
||||
@@ -311,7 +309,7 @@ class ActivityInviteView(ProtectQuerysetMixin, ProtectedCreateView):
|
||||
@transaction.atomic
|
||||
def form_valid(self, form):
|
||||
form.instance.activity = Activity.objects\
|
||||
.filter(PermissionBackend.filter_queryset(self.request, Activity, "view")).distinct().get(pk=self.kwargs["pk"])
|
||||
.filter(PermissionBackend.filter_queryset(self.request, Activity, "view")).get(pk=self.kwargs["pk"])
|
||||
return super().form_valid(form)
|
||||
|
||||
def get_success_url(self, **kwargs):
|
||||
|
||||
@@ -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__'
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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', ]
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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'),
|
||||
),
|
||||
]
|
||||
@@ -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')},
|
||||
),
|
||||
]
|
||||
@@ -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")
|
||||
|
||||
46
apps/food/static/food/js/order.js
Normal file
46
apps/food/static/food/js/order.js
Normal 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);
|
||||
});
|
||||
}
|
||||
@@ -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',
|
||||
}
|
||||
|
||||
25
apps/food/templates/food/dish_confirm_delete.html
Normal file
25
apps/food/templates/food/dish_confirm_delete.html
Normal 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 %}
|
||||
41
apps/food/templates/food/dish_detail.html
Normal file
41
apps/food/templates/food/dish_detail.html
Normal 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 %}
|
||||
94
apps/food/templates/food/dish_form.html
Normal file
94
apps/food/templates/food/dish_form.html
Normal 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 %}
|
||||
33
apps/food/templates/food/dish_list.html
Normal file
33
apps/food/templates/food/dish_list.html
Normal 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 %}
|
||||
@@ -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 %}
|
||||
|
||||
25
apps/food/templates/food/order_confirm_delete.html
Normal file
25
apps/food/templates/food/order_confirm_delete.html
Normal 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 %}
|
||||
21
apps/food/templates/food/order_form.html
Normal file
21
apps/food/templates/food/order_form.html
Normal 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 %}
|
||||
30
apps/food/templates/food/order_list.html
Normal file
30
apps/food/templates/food/order_list.html
Normal 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%}
|
||||
21
apps/food/templates/food/served_order_list.html
Normal file
21
apps/food/templates/food/served_order_list.html
Normal 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%}
|
||||
17
apps/food/templates/food/supplement_detail.html
Normal file
17
apps/food/templates/food/supplement_detail.html
Normal 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 %}
|
||||
@@ -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'),
|
||||
]
|
||||
|
||||
@@ -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"]})
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -50,15 +50,6 @@ class CustomLoginView(LoginView):
|
||||
self.request.session['permission_mask'] = form.cleaned_data['permission_mask'].rank
|
||||
return super().form_valid(form)
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
user_agent = self.request.META.get('HTTP_USER_AGENT', '').lower()
|
||||
|
||||
context['display_appstore_badge'] = 'iphone' in user_agent or 'android' not in user_agent
|
||||
context['display_playstore_badge'] = 'android' in user_agent or 'iphone' not in user_agent
|
||||
|
||||
return context
|
||||
|
||||
|
||||
class UserUpdateView(ProtectQuerysetMixin, LoginRequiredMixin, UpdateView):
|
||||
"""
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -26,42 +26,24 @@ class PermissionBackend(ModelBackend):
|
||||
|
||||
@staticmethod
|
||||
@memoize
|
||||
def get_raw_permissions(request, t): # noqa: C901
|
||||
def get_raw_permissions(request, t):
|
||||
"""
|
||||
Query permissions of a certain type for a user, then memoize it.
|
||||
:param request: The current request
|
||||
:param t: The type of the permissions: view, change, add or delete
|
||||
:return: The queryset of the permissions of the user (memoized) grouped by clubs
|
||||
"""
|
||||
# Permission for auth
|
||||
if hasattr(request, 'oauth2') and request.oauth2 is not None and 'scope' in request.oauth2:
|
||||
if hasattr(request, 'auth') and request.auth is not None and hasattr(request.auth, 'scope'):
|
||||
# OAuth2 Authentication
|
||||
user = request.oauth2['user']
|
||||
|
||||
def permission_filter(membership_obj):
|
||||
query = Q(pk=-1)
|
||||
for scope in request.oauth2['scope']:
|
||||
if scope == "openid":
|
||||
continue
|
||||
permission_id, club_id = scope.split('_')
|
||||
if int(club_id) == membership_obj.club_id:
|
||||
query |= Q(pk=permission_id, mask__rank__lte=request.oauth2['mask'])
|
||||
return query
|
||||
|
||||
# Restreint token permission to his scope
|
||||
elif hasattr(request, 'auth') and request.auth is not None and hasattr(request.auth, 'scope'):
|
||||
user = request.auth.user
|
||||
|
||||
def permission_filter(membership_obj):
|
||||
query = Q(pk=-1)
|
||||
for scope in request.auth.scope.split(' '):
|
||||
if scope == "openid" or scope == "0_0":
|
||||
continue
|
||||
permission_id, club_id = scope.split('_')
|
||||
if int(club_id) == membership_obj.club_id:
|
||||
query |= Q(pk=permission_id)
|
||||
return query
|
||||
|
||||
else:
|
||||
user = request.user
|
||||
|
||||
@@ -95,6 +77,7 @@ class PermissionBackend(ModelBackend):
|
||||
:param type: The type of the permissions: view, change, add or delete
|
||||
:return: A generator of the requested permissions
|
||||
"""
|
||||
|
||||
if hasattr(request, 'auth') and request.auth is not None and hasattr(request.auth, 'scope'):
|
||||
# OAuth2 Authentication
|
||||
user = request.auth.user
|
||||
|
||||
@@ -927,7 +927,7 @@
|
||||
"note",
|
||||
"transactiontemplate"
|
||||
],
|
||||
"query": "[\"AND\", {\"destination\": [\"club\", \"note\"]}, {\"category__name\": \"Clubs\"}]",
|
||||
"query": "{\"destination\": [\"club\", \"note\"]}",
|
||||
"type": "view",
|
||||
"mask": 2,
|
||||
"field": "",
|
||||
@@ -943,7 +943,7 @@
|
||||
"note",
|
||||
"transactiontemplate"
|
||||
],
|
||||
"query": "[\"AND\", {\"destination\": [\"club\", \"note\"]}, {\"category__name\": \"Clubs\"}]",
|
||||
"query": "{\"destination\": [\"club\", \"note\"]}",
|
||||
"type": "add",
|
||||
"mask": 3,
|
||||
"field": "",
|
||||
@@ -959,7 +959,7 @@
|
||||
"note",
|
||||
"transactiontemplate"
|
||||
],
|
||||
"query": "[\"AND\", {\"destination\": [\"club\", \"note\"]}, {\"category__name\": \"Clubs\"}]",
|
||||
"query": "{\"destination\": [\"club\", \"note\"]}",
|
||||
"type": "change",
|
||||
"mask": 3,
|
||||
"field": "",
|
||||
@@ -3484,23 +3484,7 @@
|
||||
"mask": 1,
|
||||
"permanent": false,
|
||||
"description": "Voir la bouffe servie"
|
||||
}
|
||||
},
|
||||
{
|
||||
"model": "permission.permission",
|
||||
"pk": 223,
|
||||
"fields": {
|
||||
"model": [
|
||||
"note",
|
||||
"templatecategory"
|
||||
],
|
||||
"query": "{\"name\": \"Clubs\"}",
|
||||
"type": "view",
|
||||
"mask": 2,
|
||||
"field": "",
|
||||
"permanent": false,
|
||||
"description": "Voir la catégorie de bouton Clubs"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"model": "permission.permission",
|
||||
@@ -4912,6 +4896,7 @@
|
||||
19,
|
||||
20,
|
||||
21,
|
||||
27,
|
||||
59,
|
||||
60,
|
||||
61,
|
||||
@@ -4922,7 +4907,6 @@
|
||||
182,
|
||||
184,
|
||||
185,
|
||||
223,
|
||||
239,
|
||||
240,
|
||||
241
|
||||
@@ -5287,12 +5271,6 @@
|
||||
176,
|
||||
177,
|
||||
197,
|
||||
211,
|
||||
212,
|
||||
213,
|
||||
214,
|
||||
215,
|
||||
216,
|
||||
311,
|
||||
319
|
||||
]
|
||||
|
||||
@@ -10,8 +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):
|
||||
"""
|
||||
@@ -25,9 +23,7 @@ class PermissionScopes(BaseScopes):
|
||||
if 'scopes' in kwargs:
|
||||
for scope in kwargs['scopes']:
|
||||
if scope == 'openid':
|
||||
scopes['openid'] = _("OpenID Connect (username and email)")
|
||||
elif scope == '0_0':
|
||||
scopes['0_0'] = _("Useless scope which do nothing")
|
||||
scopes['openid'] = "OpenID Connect"
|
||||
else:
|
||||
p = Permission.objects.get(id=scope.split('_')[0])
|
||||
club = Club.objects.get(id=scope.split('_')[1])
|
||||
@@ -36,8 +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['0_0'] = _("Useless scope which do nothing")
|
||||
scopes['openid'] = "OpenID Connect"
|
||||
return scopes
|
||||
|
||||
def get_available_scopes(self, application=None, request=None, *args, **kwargs):
|
||||
@@ -46,7 +41,7 @@ class PermissionScopes(BaseScopes):
|
||||
scopes = [f"{p.id}_{p.membership.club.id}"
|
||||
for t in Permission.PERMISSION_TYPES
|
||||
for p in PermissionBackend.get_raw_permissions(get_current_request(), t[0])]
|
||||
scopes.append('0_0') # always available
|
||||
scopes.append('openid')
|
||||
return scopes
|
||||
|
||||
def get_default_scopes(self, application=None, request=None, *args, **kwargs):
|
||||
@@ -54,7 +49,7 @@ class PermissionScopes(BaseScopes):
|
||||
return []
|
||||
scopes = [f"{p.id}_{p.membership.club.id}"
|
||||
for p in PermissionBackend.get_raw_permissions(get_current_request(), 'view')]
|
||||
scopes = ['0_0'] # always default
|
||||
scopes.append('openid')
|
||||
return scopes
|
||||
|
||||
|
||||
@@ -72,77 +67,10 @@ class PermissionOAuth2Validator(OAuth2Validator):
|
||||
"email": request.user.email,
|
||||
}
|
||||
|
||||
def get_userinfo_claims(self, request):
|
||||
claims = super().get_userinfo_claims(request)
|
||||
claims['is_active'] = request.user.is_active
|
||||
return claims
|
||||
|
||||
def get_discovery_claims(self, request):
|
||||
claims = super().get_discovery_claims(self)
|
||||
return claims + ["name", "normalized_name", "email"]
|
||||
|
||||
def validate_client_credentials_scopes(self, client_id, scopes, client, request, *args, **kwargs):
|
||||
"""
|
||||
For client credentials valid scopes are scope of the app owner
|
||||
"""
|
||||
valid_scopes = set()
|
||||
request.oauth2 = {}
|
||||
request.oauth2['user'] = client.user
|
||||
request.oauth2['user'].is_anomymous = False
|
||||
request.oauth2['scope'] = scopes
|
||||
# mask implementation
|
||||
if hasattr(request.decoded_body, 'mask'):
|
||||
try:
|
||||
request.oauth2['mask'] = int(request.decoded_body['mask'])
|
||||
except ValueError:
|
||||
request.oauth2['mask'] = 42
|
||||
else:
|
||||
request.oauth2['mask'] = 42
|
||||
|
||||
for t in Permission.PERMISSION_TYPES:
|
||||
for p in PermissionBackend.get_raw_permissions(request, t[0]):
|
||||
scope = f"{p.id}_{p.membership.club.id}"
|
||||
if scope in scopes:
|
||||
valid_scopes.add(scope)
|
||||
|
||||
# Always give one scope to generate token
|
||||
if not valid_scopes:
|
||||
valid_scopes.add('0_0')
|
||||
|
||||
request.scopes = valid_scopes
|
||||
return valid_scopes
|
||||
|
||||
def validate_ropb_scopes(self, client_id, scopes, client, request, *args, **kwargs):
|
||||
"""
|
||||
For ROPB valid scopes are scope of the user
|
||||
"""
|
||||
valid_scopes = set()
|
||||
request.oauth2 = {}
|
||||
request.oauth2['user'] = request.user
|
||||
request.oauth2['user'].is_anomymous = False
|
||||
request.oauth2['scope'] = scopes
|
||||
# mask implementation
|
||||
if hasattr(request.decoded_body, 'mask'):
|
||||
try:
|
||||
request.oauth2['mask'] = int(request.decoded_body['mask'])
|
||||
except ValueError:
|
||||
request.oauth2['mask'] = 42
|
||||
else:
|
||||
request.oauth2['mask'] = 42
|
||||
|
||||
for t in Permission.PERMISSION_TYPES:
|
||||
for p in PermissionBackend.get_raw_permissions(request, t[0]):
|
||||
scope = f"{p.id}_{p.membership.club.id}"
|
||||
if scope in scopes:
|
||||
valid_scopes.add(scope)
|
||||
|
||||
# Always give one scope to generate token
|
||||
if not valid_scopes:
|
||||
valid_scopes.add('0_0')
|
||||
|
||||
request.scopes = valid_scopes
|
||||
return valid_scopes
|
||||
|
||||
def validate_scopes(self, client_id, scopes, client, request, *args, **kwargs):
|
||||
"""
|
||||
User can request as many scope as he wants, including invalid scopes,
|
||||
@@ -151,35 +79,17 @@ class PermissionOAuth2Validator(OAuth2Validator):
|
||||
This allows clients to request more permission to get finally a
|
||||
subset of permissions.
|
||||
"""
|
||||
valid_scopes = set()
|
||||
if hasattr(request, 'grant_type') and request.grant_type == 'client_credentials':
|
||||
return self.validate_client_credentials_scopes(client_id, scopes, client, request, args, kwargs)
|
||||
if hasattr(request, 'grant_type') and request.grant_type == 'password':
|
||||
return self.validate_ropb_scopes(client_id, scopes, client, request, args, kwargs)
|
||||
|
||||
# Authorization code and Implicit are the same for scope, OIDC it's only a layer
|
||||
|
||||
valid_scopes = set()
|
||||
req = get_current_request()
|
||||
request.oauth2 = {}
|
||||
request.oauth2['user'] = req.user
|
||||
request.oauth2['scope'] = scopes
|
||||
# mask implementation
|
||||
request.oauth2['mask'] = req.session.load()['permission_mask']
|
||||
|
||||
for t in Permission.PERMISSION_TYPES:
|
||||
for p in PermissionBackend.get_raw_permissions(request, t[0]):
|
||||
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)
|
||||
|
||||
# We grant openid scope if user is active
|
||||
if 'openid' in scopes and req.user.is_active:
|
||||
if 'openid' in scopes:
|
||||
valid_scopes.add('openid')
|
||||
|
||||
# Always give one scope to generate token
|
||||
if not valid_scopes:
|
||||
valid_scopes.add('0_0')
|
||||
|
||||
request.scopes = valid_scopes
|
||||
return valid_scopes
|
||||
|
||||
@@ -21,7 +21,6 @@ class OAuth2TestCase(TestCase):
|
||||
def setUp(self):
|
||||
self.user = User.objects.create(
|
||||
username="toto",
|
||||
password="toto1234",
|
||||
)
|
||||
self.application = Application.objects.create(
|
||||
name="Test",
|
||||
@@ -93,40 +92,3 @@ class OAuth2TestCase(TestCase):
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
self.assertIn(self.application, resp.context['scopes'])
|
||||
self.assertIn('1_1', resp.context['scopes'][self.application]) # Now the user has this permission
|
||||
|
||||
def test_oidc(self):
|
||||
"""
|
||||
Ensure OIDC work
|
||||
"""
|
||||
# Create access token that has access to our own user detail
|
||||
token = AccessToken.objects.create(
|
||||
user=self.user,
|
||||
application=self.application,
|
||||
scope="openid",
|
||||
token=get_random_string(64),
|
||||
expires=timezone.now() + timedelta(days=365),
|
||||
)
|
||||
|
||||
# No access without token
|
||||
resp = self.client.get('/o/userinfo/') # userinfo endpoint
|
||||
self.assertEqual(resp.status_code, 401)
|
||||
|
||||
# Valid token
|
||||
resp = self.client.get('/o/userinfo/', **{'Authorization': f'Bearer {token.token}'})
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
|
||||
# Create membership to test api
|
||||
NoteUser.objects.create(user=self.user)
|
||||
membership = Membership.objects.create(user=self.user, club_id=1)
|
||||
membership.roles.add(Role.objects.get(name="Adhérent⋅e BDE"))
|
||||
membership.save()
|
||||
|
||||
# Token can always be use to see yourself
|
||||
resp = self.client.get('/api/me/',
|
||||
**{'Authorization': f'Bearer {token.token}'})
|
||||
|
||||
# Token is not granted to see other api
|
||||
resp = self.client.get(f'/api/members/profile/{self.user.profile.pk}/',
|
||||
**{'Authorization': f'Bearer {token.token}'})
|
||||
|
||||
self.assertEqual(resp.status_code, 404)
|
||||
@@ -1,444 +0,0 @@
|
||||
# Copyright (C) 2018-2025 by BDE ENS Paris-Saclay
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
|
||||
from django.contrib.auth.hashers import PBKDF2PasswordHasher
|
||||
from django.contrib.auth.models import User
|
||||
from django.utils.crypto import get_random_string
|
||||
from django.test import TestCase
|
||||
from member.models import Membership, Club
|
||||
from note.models import NoteUser
|
||||
from oauth2_provider.models import Application, AccessToken, Grant
|
||||
|
||||
from ..models import Role, Permission
|
||||
|
||||
|
||||
class OAuth2FlowTestCase(TestCase):
|
||||
fixtures = ('initial', )
|
||||
|
||||
def setUp(self):
|
||||
self.user_password = "toto1234"
|
||||
hasher = PBKDF2PasswordHasher()
|
||||
|
||||
self.user = User.objects.create(
|
||||
username="toto",
|
||||
password=hasher.encode(self.user_password, hasher.salt()),
|
||||
)
|
||||
|
||||
NoteUser.objects.create(user=self.user)
|
||||
membership = Membership.objects.create(user=self.user, club_id=1)
|
||||
membership.roles.add(Role.objects.get(name="Adhérent⋅e BDE"))
|
||||
membership.save()
|
||||
|
||||
bde = Club.objects.get(name="BDE")
|
||||
view_user_perm = Permission.objects.get(pk=1) # View own user detail
|
||||
|
||||
self.base_scope = f'{view_user_perm.pk}_{bde.pk}'
|
||||
|
||||
def test_oauth2_authorization_code_flow(self):
|
||||
"""
|
||||
Ensure OAuth2 Authorization Code Flow work
|
||||
"""
|
||||
|
||||
app = Application.objects.create(
|
||||
name="Test Authorization Code",
|
||||
client_type=Application.CLIENT_CONFIDENTIAL,
|
||||
authorization_grant_type=Application.GRANT_AUTHORIZATION_CODE,
|
||||
user=self.user,
|
||||
hash_client_secret=False,
|
||||
redirect_uris='http://127.0.0.1:8000/noexist/callback',
|
||||
algorithm=Application.NO_ALGORITHM,
|
||||
)
|
||||
|
||||
credential = base64.b64encode(f'{app.client_id}:{app.client_secret}'.encode('utf-8')).decode()
|
||||
|
||||
############################
|
||||
# Minimal RFC6749 requests #
|
||||
############################
|
||||
|
||||
resp = self.client.get('/o/authorize/',
|
||||
data={"response_type": "code", # REQUIRED
|
||||
"client_id": app.client_id}, # REQUIRED
|
||||
**{"Content-Type": 'application/x-www-form-urlencoded'})
|
||||
|
||||
# Get user authorization
|
||||
|
||||
##################################################################################
|
||||
|
||||
url = resp.url
|
||||
csrf_token = resp.text.split('CSRF_TOKEN = "')[0].split('"')[0]
|
||||
|
||||
resp = self.client.post(url,
|
||||
data={"username": self.user.username,
|
||||
"password": self.user_password,
|
||||
"permission_mask": 1,
|
||||
"csrfmiddlewaretoken": csrf_token})
|
||||
|
||||
url = resp.url
|
||||
resp = self.client.get(url)
|
||||
|
||||
csrf_token = resp.text.split('CSRF_TOKEN = "')[0].split('"')[0]
|
||||
|
||||
resp = self.client.post(url,
|
||||
follow=True,
|
||||
data={"allow": "Authorize",
|
||||
"scope": "0_0",
|
||||
"csrfmiddlewaretoken": csrf_token,
|
||||
"response_type": "code",
|
||||
"client_id": app.client_id,
|
||||
"redirect_uri": app.redirect_uris})
|
||||
|
||||
keys = resp.request['QUERY_STRING'].split("&")
|
||||
for key in keys:
|
||||
if len(key.split('code=')) == 2:
|
||||
code = key.split('code=')[1]
|
||||
|
||||
##################################################################################
|
||||
|
||||
grant = Grant.objects.get(code=code)
|
||||
self.assertEqual(grant.scope, '0_0')
|
||||
|
||||
# Now we can ask an Access Token
|
||||
|
||||
resp = self.client.post('/o/token/',
|
||||
data={"grant_type": 'authorization_code', # REQUIRED
|
||||
"code": code}, # REQUIRED
|
||||
**{"Content-Type": 'application/x-www-form-urlencoded',
|
||||
"HTTP_Authorization": f'Basic {credential}'})
|
||||
|
||||
# We should have refresh token
|
||||
self.assertEqual('refresh_token' in resp.json(), True)
|
||||
|
||||
token = AccessToken.objects.get(token=resp.json()['access_token'])
|
||||
|
||||
# Token do nothing, it should be have the useless scope
|
||||
self.assertEqual(token.scope, '0_0')
|
||||
|
||||
# Logout user
|
||||
self.client.logout()
|
||||
|
||||
#############################################
|
||||
# Maximal RFC6749 + RFC7636 (PKCE) requests #
|
||||
#############################################
|
||||
|
||||
state = get_random_string(32)
|
||||
|
||||
# PKCE
|
||||
code_verifier = get_random_string(100) # 43-128 characters [A-Z,a-z,0-9,"-",".","_","~"]
|
||||
code_challenge = hashlib.sha256(code_verifier.encode('utf-8')).digest()
|
||||
code_challenge = base64.urlsafe_b64encode(code_challenge).decode('utf-8').replace('=', '')
|
||||
cc_method = "S256"
|
||||
|
||||
resp = self.client.get('/o/authorize/',
|
||||
data={"response_type": "code", # REQUIRED
|
||||
"code_challenge": code_challenge, # PKCE REQUIRED
|
||||
"code_challenge_method": cc_method, # PKCE REQUIRED
|
||||
"client_id": app.client_id, # REQUIRED
|
||||
"redirect_uri": app.redirect_uris, # OPTIONAL
|
||||
"scope": self.base_scope, # OPTIONAL
|
||||
"state": state}, # RECOMMENDED
|
||||
**{"Content-Type": 'application/x-www-form-urlencoded'})
|
||||
|
||||
# Get user authorization
|
||||
##################################################################################
|
||||
url = resp.url
|
||||
csrf_token = resp.text.split('CSRF_TOKEN = "')[0].split('"')[0]
|
||||
|
||||
resp = self.client.post(url,
|
||||
data={"username": self.user.username,
|
||||
"password": self.user_password,
|
||||
"permission_mask": 1,
|
||||
"csrfmiddlewaretoken": csrf_token})
|
||||
|
||||
url = resp.url
|
||||
resp = self.client.get(url)
|
||||
|
||||
csrf_token = resp.text.split('CSRF_TOKEN = "')[0].split('"')[0]
|
||||
|
||||
resp = self.client.post(url,
|
||||
follow=True,
|
||||
data={"allow": "Authorize",
|
||||
"scope": self.base_scope,
|
||||
"csrfmiddlewaretoken": csrf_token,
|
||||
"response_type": "code",
|
||||
"code_challenge": code_challenge,
|
||||
"code_challenge_method": cc_method,
|
||||
"client_id": app.client_id,
|
||||
"state": state,
|
||||
"redirect_uri": app.redirect_uris})
|
||||
|
||||
keys = resp.request['QUERY_STRING'].split("&")
|
||||
for key in keys:
|
||||
if len(key.split('code=')) == 2:
|
||||
code = key.split('code=')[1]
|
||||
if len(key.split('state=')) == 2:
|
||||
resp_state = key.split('state=')[1]
|
||||
|
||||
##################################################################################
|
||||
|
||||
grant = Grant.objects.get(code=code)
|
||||
self.assertEqual(grant.scope, self.base_scope)
|
||||
self.assertEqual(state, resp_state)
|
||||
|
||||
# Now we can ask an Access Token
|
||||
|
||||
resp = self.client.post('/o/token/',
|
||||
data={"grant_type": 'authorization_code', # REQUIRED
|
||||
"code": code, # REQUIRED
|
||||
"code_verifier": code_verifier, # PKCE REQUIRED
|
||||
"redirect_uri": app.redirect_uris}, # REQUIRED
|
||||
**{"Content-Type": 'application/x-www-form-urlencoded',
|
||||
"HTTP_Authorization": f'Basic {credential}'})
|
||||
|
||||
# We should have refresh token
|
||||
self.assertEqual('refresh_token' in resp.json(), True)
|
||||
|
||||
token = AccessToken.objects.get(token=resp.json()['access_token'])
|
||||
|
||||
# Token can have access, it shouldn't have the useless scope
|
||||
self.assertEqual(token.scope, self.base_scope)
|
||||
|
||||
def test_oauth2_implicit_flow(self):
|
||||
"""
|
||||
Ensure OAuth2 Implicit Flow work
|
||||
"""
|
||||
app = Application.objects.create(
|
||||
name="Test Implicit Flow",
|
||||
client_type=Application.CLIENT_CONFIDENTIAL,
|
||||
authorization_grant_type=Application.GRANT_IMPLICIT,
|
||||
user=self.user,
|
||||
hash_client_secret=False,
|
||||
algorithm=Application.NO_ALGORITHM,
|
||||
redirect_uris='http://127.0.0.1:8000/noexist/callback/',
|
||||
)
|
||||
|
||||
############################
|
||||
# Minimal RFC6749 requests #
|
||||
############################
|
||||
|
||||
resp = self.client.get('/o/authorize/',
|
||||
data={'response_type': 'token', # REQUIRED
|
||||
'client_id': app.client_id}, # REQUIRED
|
||||
**{"Content-Type": 'application/x-www-form-urlencoded'}
|
||||
)
|
||||
|
||||
# Get user authorization
|
||||
##################################################################################
|
||||
url = resp.url
|
||||
csrf_token = resp.text.split('CSRF_TOKEN = "')[0].split('"')[0]
|
||||
|
||||
resp = self.client.post(url,
|
||||
data={"username": self.user.username,
|
||||
"password": self.user_password,
|
||||
"permission_mask": 1,
|
||||
"csrfmiddlewaretoken": csrf_token})
|
||||
|
||||
url = resp.url
|
||||
resp = self.client.get(url)
|
||||
|
||||
csrf_token = resp.text.split('CSRF_TOKEN = "')[0].split('"')[0]
|
||||
|
||||
resp = self.client.post(url,
|
||||
follow=True,
|
||||
data={"allow": "Authorize",
|
||||
"scope": '0_0',
|
||||
"csrfmiddlewaretoken": csrf_token,
|
||||
"response_type": "token",
|
||||
"client_id": app.client_id,
|
||||
"redirect_uri": app.redirect_uris})
|
||||
|
||||
url = resp.redirect_chain[0][0]
|
||||
keys = url.split('#')[1]
|
||||
refresh_token = ''
|
||||
|
||||
for couple in keys.split('&'):
|
||||
if couple.split('=')[0] == 'access_token':
|
||||
token = couple.split('=')[1]
|
||||
if couple.split('=')[0] == 'refresh_token':
|
||||
refresh_token = couple.split('=')[1]
|
||||
|
||||
##################################################################################
|
||||
|
||||
self.assertEqual(refresh_token, '')
|
||||
|
||||
access_token = AccessToken.objects.get(token=token)
|
||||
|
||||
# Token do nothing, it should be have the useless scope
|
||||
self.assertEqual(access_token.scope, '0_0')
|
||||
|
||||
# Logout user
|
||||
self.client.logout()
|
||||
|
||||
############################
|
||||
# Maximal RFC6749 requests #
|
||||
############################
|
||||
|
||||
state = get_random_string(32)
|
||||
|
||||
resp = self.client.get('/o/authorize/',
|
||||
data={'response_type': 'token', # REQUIRED
|
||||
'client_id': app.client_id, # REQUIRED
|
||||
'redirect_uri': app.redirect_uris, # OPTIONAL
|
||||
'scope': self.base_scope, # OPTIONAL
|
||||
'state': state}, # RECOMMENDED
|
||||
**{"Content-Type": 'application/x-www-form-urlencoded'}
|
||||
)
|
||||
|
||||
# Get user authorization
|
||||
##################################################################################
|
||||
url = resp.url
|
||||
csrf_token = resp.text.split('CSRF_TOKEN = "')[0].split('"')[0]
|
||||
|
||||
resp = self.client.post(url,
|
||||
data={"username": self.user.username,
|
||||
"password": self.user_password,
|
||||
"permission_mask": 1,
|
||||
"csrfmiddlewaretoken": csrf_token})
|
||||
|
||||
url = resp.url
|
||||
resp = self.client.get(url)
|
||||
|
||||
csrf_token = resp.text.split('CSRF_TOKEN = "')[0].split('"')[0]
|
||||
|
||||
resp = self.client.post(url,
|
||||
follow=True,
|
||||
data={"allow": "Authorize",
|
||||
"scope": self.base_scope,
|
||||
"state": state,
|
||||
"csrfmiddlewaretoken": csrf_token,
|
||||
"response_type": "token",
|
||||
"client_id": app.client_id,
|
||||
"redirect_uri": app.redirect_uris})
|
||||
|
||||
url = resp.redirect_chain[0][0]
|
||||
keys = url.split('#')[1]
|
||||
refresh_token = ''
|
||||
|
||||
for couple in keys.split('&'):
|
||||
if couple.split('=')[0] == 'access_token':
|
||||
token = couple.split('=')[1]
|
||||
if couple.split('=')[0] == 'refresh_token':
|
||||
refresh_token = couple.split('=')[1]
|
||||
if couple.split('=')[0] == 'state':
|
||||
resp_state = couple.split('=')[1]
|
||||
|
||||
##################################################################################
|
||||
|
||||
self.assertEqual(refresh_token, '')
|
||||
|
||||
access_token = AccessToken.objects.get(token=token)
|
||||
|
||||
# Token can have access, it shouldn't have the useless scope
|
||||
self.assertEqual(access_token.scope, self.base_scope)
|
||||
|
||||
self.assertEqual(state, resp_state)
|
||||
|
||||
def test_oauth2_resource_owner_password_credentials_flow(self):
|
||||
"""
|
||||
Ensure OAuth2 Resource Owner Password Credentials Flow work
|
||||
"""
|
||||
app = Application.objects.create(
|
||||
name="Test ROPB",
|
||||
client_type=Application.CLIENT_CONFIDENTIAL,
|
||||
authorization_grant_type=Application.GRANT_PASSWORD,
|
||||
user=self.user,
|
||||
hash_client_secret=False,
|
||||
algorithm=Application.NO_ALGORITHM,
|
||||
)
|
||||
|
||||
credential = base64.b64encode(f'{app.client_id}:{app.client_secret}'.encode('utf-8')).decode()
|
||||
|
||||
# No token without real password
|
||||
resp = self.client.post('/o/token/',
|
||||
data={"grant_type": "password", # REQUIRED
|
||||
"username": self.user, # REQUIRED
|
||||
"password": "password"}, # REQUIRED
|
||||
**{"Content-Type": 'application/x-www-form-urlencoded',
|
||||
"Http_Authorization": f'Basic {credential}'}
|
||||
)
|
||||
|
||||
self.assertEqual(resp.status_code, 400)
|
||||
|
||||
resp = self.client.post('/o/token/',
|
||||
data={"grant_type": "password", # REQUIRED
|
||||
"username": self.user, # REQUIRED
|
||||
"password": self.user_password}, # REQUIRED
|
||||
**{"Content-Type": 'application/x-www-form-urlencoded',
|
||||
"HTTP_Authorization": f'Basic {credential}'}
|
||||
)
|
||||
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
|
||||
access_token = AccessToken.objects.get(token=resp.json()['access_token'])
|
||||
self.assertEqual('refresh_token' in resp.json(), True)
|
||||
|
||||
self.assertEqual(access_token.scope, '0_0') # token do nothing
|
||||
|
||||
# RFC6749 4.3.2 allows use of scope in ROPB token access request
|
||||
|
||||
resp = self.client.post('/o/token/',
|
||||
data={"grant_type": "password", # REQUIRED
|
||||
"username": self.user, # REQUIRED
|
||||
"password": self.user_password, # REQUIRED
|
||||
"scope": self.base_scope}, # OPTIONAL
|
||||
**{"Content-Type": 'application/x-www-form-urlencoded',
|
||||
"HTTP_Authorization": f'Basic {credential}'}
|
||||
)
|
||||
|
||||
token = AccessToken.objects.get(token=resp.json()['access_token'])
|
||||
|
||||
self.assertEqual(token.scope, self.base_scope) # token do nothing more than base_scope
|
||||
|
||||
def test_oauth2_client_credentials(self):
|
||||
"""
|
||||
Ensure OAuth2 Client Credentials work
|
||||
"""
|
||||
app = Application.objects.create(
|
||||
name="Test client_credentials",
|
||||
client_type=Application.CLIENT_CONFIDENTIAL,
|
||||
authorization_grant_type=Application.GRANT_CLIENT_CREDENTIALS,
|
||||
user=self.user,
|
||||
hash_client_secret=False,
|
||||
algorithm=Application.NO_ALGORITHM,
|
||||
)
|
||||
|
||||
# No token without credential
|
||||
resp = self.client.post('/o/token/',
|
||||
data={"grant_type": "client_credentials"}, # REQUIRED
|
||||
**{"Content-Type": 'application/x-www-form-urlencoded'}
|
||||
)
|
||||
|
||||
self.assertEqual(resp.status_code, 401)
|
||||
|
||||
# Access with credential
|
||||
credential = base64.b64encode(f'{app.client_id}:{app.client_secret}'.encode('utf-8')).decode()
|
||||
|
||||
resp = self.client.post('/o/token/',
|
||||
data={"grant_type": "client_credentials"}, # REQUIRED
|
||||
**{'HTTP_Authorization': f'Basic {credential}',
|
||||
"Content-Type": 'application/x-www-form-urlencoded'}
|
||||
)
|
||||
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
|
||||
token = AccessToken.objects.get(token=resp.json()['access_token'])
|
||||
|
||||
# Token do nothing, it should be have the useless scope
|
||||
self.assertEqual(token.scope, '0_0')
|
||||
|
||||
# RFC6749 4.4.2 allows use of scope in client credential flow
|
||||
resp = self.client.post('/o/token/',
|
||||
data={"grant_type": "client_credentials", # REQUIRED
|
||||
"scope": self.base_scope}, # OPTIONAL
|
||||
**{'http_Authorization': f'Basic {credential}',
|
||||
"Content-Type": 'application/x-www-form-urlencoded'}
|
||||
)
|
||||
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
|
||||
token = AccessToken.objects.get(token=resp.json()['access_token'])
|
||||
|
||||
# Token can have access, it shouldn't have the useless scope
|
||||
self.assertEqual(token.scope, self.base_scope)
|
||||
@@ -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'),
|
||||
),
|
||||
]
|
||||
@@ -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")
|
||||
@@ -338,7 +332,7 @@ class SogeCredit(models.Model):
|
||||
last_name=self.user.last_name,
|
||||
first_name=self.user.first_name,
|
||||
bank="Société générale",
|
||||
valid=True,
|
||||
valid=False,
|
||||
)
|
||||
credit_transaction._force_save = True
|
||||
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)
|
||||
@@ -403,7 +397,7 @@ class SogeCredit(models.Model):
|
||||
self.transactions.add(m.transaction)
|
||||
|
||||
for tr in self.transactions.all():
|
||||
tr.valid = True
|
||||
tr.valid = False
|
||||
tr.save()
|
||||
|
||||
def invalidate(self):
|
||||
@@ -428,7 +422,6 @@ class SogeCredit(models.Model):
|
||||
self.invalidate()
|
||||
# Refresh credit amount
|
||||
self.save()
|
||||
self.valid = True
|
||||
self.credit_transaction.valid = True
|
||||
self.credit_transaction._force_save = True
|
||||
self.credit_transaction.save()
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 104 KiB After Width: | Height: | Size: 284 KiB |
@@ -56,7 +56,6 @@ class InvoiceTable(tables.Table):
|
||||
model = Invoice
|
||||
template_name = 'django_tables2/bootstrap4.html'
|
||||
fields = ('id', 'name', 'object', 'acquitted', 'invoice',)
|
||||
order_by = ('-id',)
|
||||
|
||||
|
||||
class RemittanceTable(tables.Table):
|
||||
|
||||
@@ -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
|
||||
@@ -417,7 +418,7 @@ class SogeCreditListView(LoginRequiredMixin, ProtectQuerysetMixin, SingleTableVi
|
||||
)
|
||||
|
||||
if "valid" not in self.request.GET or not self.request.GET["valid"]:
|
||||
qs = qs.filter(valid=False)
|
||||
qs = qs.filter(credit_transaction__valid=False)
|
||||
|
||||
return qs
|
||||
|
||||
|
||||
@@ -680,7 +680,7 @@ class TestWEIRegistration(TestCase):
|
||||
self.assertTrue(soge_credit.exists())
|
||||
soge_credit = soge_credit.get()
|
||||
self.assertTrue(membership.transaction in soge_credit.transactions.all())
|
||||
self.assertTrue(membership.transaction.valid)
|
||||
self.assertFalse(membership.transaction.valid)
|
||||
|
||||
# Check that if the WEI is started, we can't update a wei
|
||||
self.wei.date_start = date(2000, 1, 1)
|
||||
|
||||
@@ -84,7 +84,7 @@ Le script *generate_wrapped* fonctionne de la manière suivante :
|
||||
wrapped·s va/vont être généré·s
|
||||
ou regénéré·s.
|
||||
* ``global_data`` : le script génére ensuite des statistiques globales qui concernent pas qu'une seule
|
||||
note (nombre de soirée, classement, etc).
|
||||
note (nombre de soirée, classement, etc).
|
||||
* ``unique_data`` : le script génére les statitiques uniques à chaque note, et rajoute des données
|
||||
globales si nécessaire, pour chaque note on souhaite avoir un json avec toutes les données qui
|
||||
seront dans le wrapped.
|
||||
|
||||
@@ -18,21 +18,11 @@ note. De cette façon, chaque application peut authentifier ses utilisateur⋅ri
|
||||
et récupérer leurs adhésions, leur nom de note afin d'éventuellement faire des transferts
|
||||
via l'API.
|
||||
|
||||
Trois protocoles d'authentification sont implémentées :
|
||||
Deux protocoles d'authentification sont implémentées :
|
||||
|
||||
* `CAS <cas>`_
|
||||
* `OAuth2 <oauth2>`_
|
||||
* Open ID Connect
|
||||
|
||||
À ce jour, ce mécanisme est notamment utilisé par :
|
||||
* Le `serveur photo <https://photos.crans.org>`_
|
||||
* L'`imprimante <https://helloworld.crans.org>`_ du `Cr@ns <https://crans.org>`_
|
||||
* Le serveur `Matrix <https://element.crans.org>`_ du `Cr@ns <https://crans.org>`_
|
||||
* La `base de donnée de la Mediatek <https://med.crans.org>`_
|
||||
* Le site du `K-WEI <https://kwei.crans.org>`_
|
||||
|
||||
Et dans un futur plus ou moins proche :
|
||||
* Le site pour loger les admissibles pendant les oraux (cf. `ici <https://gitlab.crans.org/bde/la25>`_)
|
||||
* L'application mobile de la note
|
||||
* Le site pour les commandes Terre à Terre (cf. `là <https://gitlab.crans.org/tat/blog>`_)
|
||||
* Le futur wiki...
|
||||
À ce jour, il n'y a pas encore d'exemple d'utilisation d'application qui utilise ce
|
||||
mécanisme, mais on peut imaginer par exemple que la Mediatek ou l'AMAP implémentent
|
||||
ces protocoles pour récupérer leurs adhérent⋅es.
|
||||
|
||||
@@ -47,6 +47,7 @@ On a ensuite besoin de définir nos propres scopes afin d'avoir des permissions
|
||||
'OIDC_ENABLED': True,
|
||||
'OIDC_RSA_PRIVATE_KEY':
|
||||
os.getenv('OIDC_RSA_PRIVATE_KEY', '/var/secrets/oidc.key'),
|
||||
'SCOPES': { 'openid': "OpenID Connect scope" },
|
||||
}
|
||||
|
||||
Cela a pour effet d'avoir des scopes sous la forme ``PERMISSION_CLUB``,
|
||||
@@ -98,7 +99,7 @@ du format renvoyé.
|
||||
|
||||
.. warning::
|
||||
|
||||
Un petit mot sur les scopes : tel qu'implémenté, un scope est une permission unitaire
|
||||
Un petit mot sur les scopes : tel qu'implémenté, une scope est une permission unitaire
|
||||
(telle que décrite dans le modèle ``Permission``) associée à un club. Ainsi, un jeton
|
||||
a accès à une scope si et seulement si læ propriétaire du jeton dispose d'une adhésion
|
||||
courante dans le club lié à la scope qui lui octroie cette permission.
|
||||
@@ -112,9 +113,6 @@ du format renvoyé.
|
||||
Vous pouvez donc contrôler le plus finement possible les permissions octroyées à vos
|
||||
jetons.
|
||||
|
||||
Deux scopes sont un peu particulier, le scope "0_0" qui ne donne aucune permission
|
||||
et le scope "openid" pour l'OIDC.
|
||||
|
||||
.. danger::
|
||||
|
||||
Demander des scopes n'implique pas de les avoir.
|
||||
@@ -136,11 +134,6 @@ du format renvoyé.
|
||||
uniquement dans le cas où l'utilisateur⋅rice connecté⋅e
|
||||
possède la permission problématique.
|
||||
|
||||
Dans le cas extrême ou aucun scope demandé n'est obtenus, vous
|
||||
obtiendriez le scope "0_0" qui ne permet l'accès à rien.
|
||||
Cela permet de générer un token pour toute les requêtes valides.
|
||||
|
||||
|
||||
Avec Django-allauth
|
||||
###################
|
||||
|
||||
@@ -149,10 +142,6 @@ le module pré-configuré disponible ici :
|
||||
`<https://gitlab.crans.org/bde/allauth-note-kfet>`_. Pour l'installer, vous
|
||||
pouvez simplement faire :
|
||||
|
||||
.. warning::
|
||||
À cette heure (11/2025), ce paquet est déprécié et il est plutôt conseillé de créer
|
||||
sa propre application.
|
||||
|
||||
.. code:: bash
|
||||
|
||||
$ pip3 install git+https://gitlab.crans.org/bde/allauth-note-kfet.git
|
||||
@@ -206,20 +195,6 @@ récupérés. Les autres données sont stockées mais inutilisées.
|
||||
Application personnalisée
|
||||
#########################
|
||||
|
||||
.. note::
|
||||
|
||||
Tout les flow (c'est-à-dire les différentes suites de requête possible pour obtenir
|
||||
un token d'accès) de l'OAuth2 sont reproduits dans les
|
||||
`tests <https://gitlab.crans.org/bde/nk20/-/tree/main/apps/permission/tests/test_oauth2_flow.py>`_
|
||||
de l'application permission de la Note. L'OIDC n'étant qu'une extension du protocole
|
||||
OAuth2 vous pouvez facilement reproduire les requêtes en vous inspirant de
|
||||
l'Authorization Code de OAuth2.
|
||||
|
||||
.. danger::
|
||||
|
||||
Pour des raisons de rétrocompatibilité, PKCE (Proof Key for Code Exchange) n'est pas requis,
|
||||
son utilisation est néanmoins très vivement conseillé.
|
||||
|
||||
Ce modèle vous permet de créer vos propres applications à interfacer avec la Note Kfet.
|
||||
|
||||
Commencez par créer une application : `<https://note.crans.org/o/applications/register>`_.
|
||||
@@ -248,8 +223,6 @@ c'est sur cette page qu'il faut rediriger les utilisateur⋅rices. Il faut mettr
|
||||
autorisée par l'application. À des fins de test, peut être `<http://localhost/>`_.
|
||||
* ``state`` : optionnel, peut être utilisé pour permettre au client de détecter des requêtes
|
||||
provenant d'autres sites.
|
||||
* ``code_challenge``: PKCE, le hash d'une chaine d'entre 43 et 128 caractères.
|
||||
* ``code_challenge_method``: PKCE, ``S256`` si le hasher est sha256.
|
||||
|
||||
Sur cette page, les permissions demandées seront listées, et l'utilisateur⋅rice aura le
|
||||
choix d'accepter ou non. Dans les deux cas, l'utilisateur⋅rice sera redirigée vers
|
||||
@@ -310,4 +283,4 @@ de rafraichissement à usage unique. Il suffit pour cela de refaire une requête
|
||||
Le serveur vous fournira alors une nouvelle paire de jetons, comme précédemment.
|
||||
À noter qu'un jeton de rafraîchissement est à usage unique.
|
||||
|
||||
N'hésitez pas à vous renseigner sur `OAuth2 <https://www.rfc-editor.org/rfc/rfc6749.html>`_ ou sur le protocole `OIDC <https://openid.net/specs/openid-connect-core-1_0.html>`_ pour plus d'informations.
|
||||
N'hésitez pas à vous renseigner sur OAuth2 pour plus d'informations.
|
||||
|
||||
@@ -4366,14 +4366,6 @@ msgstr ""
|
||||
msgid "Forgotten your password or username?"
|
||||
msgstr "Passwort oder Username vergessen?"
|
||||
|
||||
#: note_kfet/templates/registration/login.html:44
|
||||
msgid "Download on the AppStore"
|
||||
msgstr "Im AppStore herunterladen"
|
||||
|
||||
#: note_kfet/templates/registration/login.html:48
|
||||
msgid "Get it on Google Play"
|
||||
msgstr "Bei Google Play herunterladen"
|
||||
|
||||
#: note_kfet/templates/registration/password_change_done.html:13
|
||||
msgid "Your password was changed."
|
||||
msgstr "Ihr Passwort wurde geändert."
|
||||
|
||||
@@ -4281,14 +4281,6 @@ msgstr ""
|
||||
msgid "Forgotten your password or username?"
|
||||
msgstr "¿ Contraseña o nombre de usuario olvidado ?"
|
||||
|
||||
#: note_kfet/templates/registration/login.html:44
|
||||
msgid "Download on the AppStore"
|
||||
msgstr "Descargar en la AppStore"
|
||||
|
||||
#: note_kfet/templates/registration/login.html:48
|
||||
msgid "Get it on Google Play"
|
||||
msgstr "Descargar en Google Play"
|
||||
|
||||
#: note_kfet/templates/registration/password_change_done.html:13
|
||||
msgid "Your password was changed."
|
||||
msgstr "Su contraseña fue cambiada con éxito."
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -273,9 +273,9 @@ OAUTH2_PROVIDER = {
|
||||
'REFRESH_TOKEN_EXPIRE_SECONDS': timedelta(days=14),
|
||||
'PKCE_REQUIRED': False, # PKCE (fix a breaking change of django-oauth-toolkit 2.0.0)
|
||||
'OIDC_ENABLED': True,
|
||||
'OIDC_RP_INITIATED_LOGOUT_ENABLED': False,
|
||||
'OIDC_RSA_PRIVATE_KEY':
|
||||
os.getenv('OIDC_RSA_PRIVATE_KEY', 'CHANGE_ME_IN_ENV_SETTINGS').replace('\\n', '\n'), # for multilines
|
||||
'SCOPES': { 'openid': "OpenID Connect scope" },
|
||||
}
|
||||
|
||||
# Take control on how widget templates are sourced
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
<svg id="livetype" xmlns="http://www.w3.org/2000/svg" width="126.50751" height="40" viewBox="0 0 126.50751 40">
|
||||
<title>Download_on_the_App_Store_Badge_FR_RGB_blk_100517</title>
|
||||
<g>
|
||||
<g>
|
||||
<path d="M116.97821,0H9.53468c-.3667,0-.729,0-1.09473.002-.30615.002-.60986.00781-.91895.0127A13.21476,13.21476,0,0,0,5.5171.19141a6.66509,6.66509,0,0,0-1.90088.627A6.43779,6.43779,0,0,0,1.99757,1.99707,6.25844,6.25844,0,0,0,.81935,3.61816a6.60119,6.60119,0,0,0-.625,1.90332,12.993,12.993,0,0,0-.1792,2.002C.00587,7.83008.00489,8.1377,0,8.44434V31.5586c.00489.3105.00587.6113.01515.9219a12.99232,12.99232,0,0,0,.1792,2.0019,6.58756,6.58756,0,0,0,.625,1.9043A6.20778,6.20778,0,0,0,1.99757,38.001a6.27445,6.27445,0,0,0,1.61865,1.1787,6.70082,6.70082,0,0,0,1.90088.6308,13.45514,13.45514,0,0,0,2.0039.1768c.30909.0068.6128.0107.91895.0107C8.80567,40,9.168,40,9.53468,40H116.97821c.3594,0,.7246,0,1.084-.002.3047,0,.6172-.0039.9219-.0107a13.279,13.279,0,0,0,2-.1768,6.80432,6.80432,0,0,0,1.9082-.6308,6.27742,6.27742,0,0,0,1.6172-1.1787,6.39482,6.39482,0,0,0,1.1816-1.6143,6.60413,6.60413,0,0,0,.6191-1.9043,13.50641,13.50641,0,0,0,.1856-2.0019c.0039-.3106.0039-.6114.0039-.9219.0078-.3633.0078-.7246.0078-1.0938V9.53613c0-.36621,0-.72949-.0078-1.09179,0-.30664,0-.61426-.0039-.9209a13.50709,13.50709,0,0,0-.1856-2.002,6.6177,6.6177,0,0,0-.6191-1.90332,6.46619,6.46619,0,0,0-2.7988-2.7998,6.76753,6.76753,0,0,0-1.9082-.627,13.04394,13.04394,0,0,0-2-.17676c-.3047-.00488-.6172-.01074-.9219-.01269-.3594-.002-.7246-.002-1.084-.002Z" style="fill: #a6a6a6"/>
|
||||
<path d="M8.44482,39.125c-.30467,0-.602-.0039-.90428-.0107a12.68714,12.68714,0,0,1-1.86914-.1631,5.88381,5.88381,0,0,1-1.65674-.5479,5.40573,5.40573,0,0,1-1.397-1.0166,5.32082,5.32082,0,0,1-1.02051-1.3965,5.72186,5.72186,0,0,1-.543-1.6572,12.41351,12.41351,0,0,1-.1665-1.875c-.00634-.2109-.01464-.9131-.01464-.9131V8.44434S.88185,7.75293.8877,7.5498a12.37138,12.37138,0,0,1,.16552-1.87207,5.75577,5.75577,0,0,1,.54347-1.6621A5.37365,5.37365,0,0,1,2.61182,2.61768,5.56562,5.56562,0,0,1,4.01417,1.59521a5.82309,5.82309,0,0,1,1.65332-.54394A12.58579,12.58579,0,0,1,7.543.88721L8.44532.875h109.612l.9131.0127a12.38493,12.38493,0,0,1,1.8584.16259,5.93833,5.93833,0,0,1,1.6709.54785,5.59375,5.59375,0,0,1,2.415,2.41993,5.76267,5.76267,0,0,1,.5352,1.64892,12.995,12.995,0,0,1,.1738,1.88721c.0029.2832.0029.5874.0029.89014.0079.375.0079.73193.0079,1.09179V30.4648c0,.3633,0,.7178-.0079,1.0752,0,.3252,0,.6231-.0039.9297a12.73126,12.73126,0,0,1-.1709,1.8535,5.739,5.739,0,0,1-.54,1.67,5.48029,5.48029,0,0,1-1.0156,1.3857,5.4129,5.4129,0,0,1-1.3994,1.0225,5.86168,5.86168,0,0,1-1.668.5498,12.54218,12.54218,0,0,1-1.8692.1631c-.2929.0068-.5996.0107-.8974.0107l-1.084.002Z"/>
|
||||
</g>
|
||||
<g>
|
||||
<g id="_Group_" data-name="<Group>">
|
||||
<g id="_Group_2" data-name="<Group>">
|
||||
<g id="_Group_3" data-name="<Group>">
|
||||
<path id="_Path_" data-name="<Path>" d="M24.7718,20.30068a4.94881,4.94881,0,0,1,2.35656-4.15206,5.06566,5.06566,0,0,0-3.99116-2.15768c-1.67924-.17626-3.30719,1.00483-4.1629,1.00483-.87227,0-2.18977-.98733-3.6085-.95814a5.31529,5.31529,0,0,0-4.47292,2.72787c-1.934,3.34842-.49141,8.26947,1.3612,10.97608.9269,1.32535,2.01018,2.8058,3.42763,2.7533,1.38706-.05753,1.9051-.88448,3.5794-.88448,1.65876,0,2.14479.88448,3.591.8511,1.48838-.02416,2.42613-1.33124,3.32051-2.66914A10.962,10.962,0,0,0,27.691,24.69985,4.78205,4.78205,0,0,1,24.7718,20.30068Z" style="fill: #fff"/>
|
||||
<path id="_Path_2" data-name="<Path>" d="M22.04017,12.21089a4.87248,4.87248,0,0,0,1.11452-3.49062,4.95746,4.95746,0,0,0-3.20758,1.65961,4.63634,4.63634,0,0,0-1.14371,3.36139A4.09905,4.09905,0,0,0,22.04017,12.21089Z" style="fill: #fff"/>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
<g id="_Group_4" data-name="<Group>">
|
||||
<g>
|
||||
<path d="M35.65528,14.70166V9.57813h-1.877V8.73486h4.67676v.84326H36.582v5.12354Z" style="fill: #fff"/>
|
||||
<path d="M42.76466,13.48584a1.828,1.828,0,0,1-1.95117,1.30273,2.04531,2.04531,0,0,1-2.08008-2.32422,2.07685,2.07685,0,0,1,2.07617-2.35254c1.25293,0,2.00879.856,2.00879,2.27v.31006H39.63868v.0498a1.1902,1.1902,0,0,0,1.19922,1.29,1.07934,1.07934,0,0,0,1.07129-.5459Zm-3.126-1.45117H41.9131a1.08647,1.08647,0,0,0-1.1084-1.1665A1.15162,1.15162,0,0,0,39.63868,12.03467ZM40.2754,9.4458l1.03809-1.42236h1.042L41.19337,9.4458Z" style="fill: #fff"/>
|
||||
<path d="M44.05274,8.44092h.88867v6.26074h-.88867Z" style="fill: #fff"/>
|
||||
<path d="M50.208,13.48584a1.828,1.828,0,0,1-1.95117,1.30273,2.04531,2.04531,0,0,1-2.08008-2.32422,2.07685,2.07685,0,0,1,2.07617-2.35254c1.25293,0,2.00879.856,2.00879,2.27v.31006H47.082v.0498a1.1902,1.1902,0,0,0,1.19922,1.29,1.07934,1.07934,0,0,0,1.07129-.5459Zm-3.126-1.45117h2.27441a1.08647,1.08647,0,0,0-1.1084-1.1665A1.15162,1.15162,0,0,0,47.082,12.03467Zm.63672-2.58887,1.03809-1.42236h1.042L48.63673,9.4458Z" style="fill: #fff"/>
|
||||
<path d="M54.40333,11.67041a1.00546,1.00546,0,0,0-1.06348-.76465c-.74414,0-1.19922.57031-1.19922,1.52979,0,.97607.459,1.55908,1.19922,1.55908a.97873.97873,0,0,0,1.06348-.74023h.86426a1.762,1.762,0,0,1-1.92285,1.53418,2.06791,2.06791,0,0,1-2.11328-2.353,2.05305,2.05305,0,0,1,2.1084-2.32373,1.77731,1.77731,0,0,1,1.92773,1.55859Z" style="fill: #fff"/>
|
||||
<path d="M56.44728,8.44092h.88086v2.48145h.07031a1.3856,1.3856,0,0,1,1.373-.80664,1.48339,1.48339,0,0,1,1.55078,1.67871v2.90723h-.88965v-2.688c0-.71924-.335-1.0835-.96289-1.0835a1.05194,1.05194,0,0,0-1.13379,1.1416v2.62988h-.88867Z" style="fill: #fff"/>
|
||||
<path d="M61.43946,13.42822c0-.81055.60352-1.27783,1.6748-1.34424l1.21973-.07031V11.625c0-.47559-.31445-.74414-.92188-.74414-.49609,0-.83984.18213-.93848.50049h-.86035c.09082-.77344.81836-1.26953,1.83984-1.26953,1.12891,0,1.76514.562,1.76514,1.51318v3.07666h-.855v-.63281H64.293a1.515,1.515,0,0,1-1.35254.707A1.36026,1.36026,0,0,1,61.43946,13.42822Zm2.89453-.38477V12.667l-1.09961.07031c-.62012.0415-.90137.25244-.90137.64941,0,.40527.35156.64111.835.64111A1.0615,1.0615,0,0,0,64.334,13.04346Z" style="fill: #fff"/>
|
||||
<path d="M66.60987,10.19873h.85547v.69043h.06641a1.22092,1.22092,0,0,1,1.21582-.76514,1.86836,1.86836,0,0,1,.39648.03711v.877a2.43442,2.43442,0,0,0-.49609-.05371A1.05507,1.05507,0,0,0,67.49855,12.043v2.65869h-.88867Z" style="fill: #fff"/>
|
||||
<path d="M69.96144,15.15234h.90918c.0752.32666.45117.5376,1.05078.5376.74023,0,1.17871-.35156,1.17871-.94678v-.86426H73.0337a1.51433,1.51433,0,0,1-1.38965.75635c-1.14941,0-1.86035-.88867-1.86035-2.23682,0-1.373.71875-2.27441,1.86914-2.27441a1.56045,1.56045,0,0,1,1.41406.79395h.07031v-.71924h.85156v4.54c0,1.02979-.80664,1.68311-2.08008,1.68311C70.7837,16.42188,70.05616,15.91748,69.96144,15.15234Zm3.15527-2.7583c0-.897-.46387-1.47168-1.2207-1.47168-.76465,0-1.19434.57471-1.19434,1.47168,0,.89746.42969,1.47217,1.19434,1.47217C72.65773,13.86621,73.11671,13.2959,73.11671,12.394Z" style="fill: #fff"/>
|
||||
<path d="M79.21241,13.48584a1.828,1.828,0,0,1-1.95117,1.30273,2.04531,2.04531,0,0,1-2.08008-2.32422,2.07685,2.07685,0,0,1,2.07617-2.35254c1.25293,0,2.00879.856,2.00879,2.27v.31006H76.08644v.0498a1.1902,1.1902,0,0,0,1.19922,1.29,1.07934,1.07934,0,0,0,1.07129-.5459Zm-3.126-1.45117h2.27441a1.08647,1.08647,0,0,0-1.1084-1.1665A1.15162,1.15162,0,0,0,76.08644,12.03467Z" style="fill: #fff"/>
|
||||
<path d="M80.45948,10.19873H81.315v.69043h.06641a1.22092,1.22092,0,0,1,1.21582-.76514,1.86836,1.86836,0,0,1,.39648.03711v.877a2.43442,2.43442,0,0,0-.49609-.05371A1.05507,1.05507,0,0,0,81.34815,12.043v2.65869h-.88867Z" style="fill: #fff"/>
|
||||
<path d="M86.19581,12.44824c0-1.42285.73145-2.32422,1.86914-2.32422a1.484,1.484,0,0,1,1.38086.79h.06641V8.44092h.88867v6.26074h-.85156v-.71143H89.479a1.56284,1.56284,0,0,1-1.41406.78564C86.91944,14.77588,86.19581,13.87451,86.19581,12.44824Zm.918,0c0,.95508.4502,1.52979,1.20313,1.52979.749,0,1.21191-.583,1.21191-1.52588,0-.93848-.46777-1.52979-1.21191-1.52979C87.56886,10.92236,87.11378,11.501,87.11378,12.44824Z" style="fill: #fff"/>
|
||||
<path d="M91.60206,13.42822c0-.81055.60352-1.27783,1.6748-1.34424l1.21973-.07031V11.625c0-.47559-.31445-.74414-.92187-.74414-.49609,0-.83984.18213-.93848.50049h-.86035c.09082-.77344.81836-1.26953,1.83984-1.26953,1.12891,0,1.76563.562,1.76563,1.51318v3.07666h-.85547v-.63281h-.07031a1.515,1.515,0,0,1-1.35254.707A1.36026,1.36026,0,0,1,91.60206,13.42822Zm2.89453-.38477V12.667L93.397,12.7373c-.62012.0415-.90137.25244-.90137.64941,0,.40527.35156.64111.835.64111A1.0615,1.0615,0,0,0,94.49659,13.04346Z" style="fill: #fff"/>
|
||||
<path d="M96.773,10.19873h.85547v.71533h.06641a1.348,1.348,0,0,1,1.34375-.80225,1.46456,1.46456,0,0,1,1.55859,1.6748v2.915h-.88867V12.00977c0-.72363-.31445-1.0835-.97168-1.0835a1.03294,1.03294,0,0,0-1.0752,1.14111v2.63428H96.773Z" style="fill: #fff"/>
|
||||
<path d="M103.61769,10.11182c1.0127,0,1.6748.47119,1.76172,1.26514h-.85254c-.082-.33057-.40527-.5415-.90918-.5415-.49609,0-.873.23535-.873.58691,0,.269.22754.43848.71582.55029l.748.17334c.85645.19873,1.25781.56689,1.25781,1.22852,0,.84766-.79,1.41406-1.86523,1.41406-1.07129,0-1.76953-.48389-1.84863-1.28174h.88965a.91365.91365,0,0,0,.97949.562c.55371,0,.94727-.248.94727-.60791,0-.26855-.21094-.44238-.66211-.5498l-.78516-.18213c-.85645-.20264-1.25293-.58691-1.25293-1.25684C101.86866,10.67383,102.60011,10.11182,103.61769,10.11182Z" style="fill: #fff"/>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
<g>
|
||||
<path d="M35.19825,18.06689h1.85938V30.48535H35.19825Z" style="fill: #fff"/>
|
||||
<path d="M39.29786,22.61084l1.01563-4.54395h1.80664l-1.23047,4.54395Z" style="fill: #fff"/>
|
||||
<path d="M49.14649,27.12891H44.4131l-1.13672,3.35645H41.27149l4.4834-12.41846h2.083l4.4834,12.41846H50.28224Zm-4.24316-1.54883h3.752l-1.84961-5.44775h-.05176Z" style="fill: #fff"/>
|
||||
<path d="M62.00294,25.959c0,2.81348-1.50586,4.62109-3.77832,4.62109a3.0693,3.0693,0,0,1-2.84863-1.584h-.043v4.48438h-1.8584V21.43115h1.79883V22.937h.03418a3.21162,3.21162,0,0,1,2.88281-1.60059C60.48829,21.33643,62.00294,23.15283,62.00294,25.959Zm-1.91016,0c0-1.8335-.94727-3.03857-2.39258-3.03857-1.41992,0-2.375,1.23047-2.375,3.03857,0,1.82422.95508,3.0459,2.375,3.0459C59.14552,29.00488,60.09278,27.80859,60.09278,25.959Z" style="fill: #fff"/>
|
||||
<path d="M71.9673,25.959c0,2.81348-1.50586,4.62109-3.77832,4.62109a3.0693,3.0693,0,0,1-2.84863-1.584h-.043v4.48438H63.43946V21.43115H65.2378V22.937H65.272a3.21162,3.21162,0,0,1,2.88281-1.60059C70.45265,21.33643,71.9673,23.15283,71.9673,25.959Zm-1.91016,0c0-1.8335-.94727-3.03857-2.39258-3.03857-1.41992,0-2.375,1.23047-2.375,3.03857,0,1.82422.95508,3.0459,2.375,3.0459C69.10987,29.00488,70.05714,27.80859,70.05714,25.959Z" style="fill: #fff"/>
|
||||
<path d="M78.55323,27.02539c.1377,1.23145,1.334,2.04,2.96875,2.04,1.56641,0,2.69336-.80859,2.69336-1.91895,0-.96387-.67969-1.541-2.28906-1.93652l-1.60937-.38818C78.03663,24.271,76.978,23.20459,76.978,21.47412c0-2.14258,1.86719-3.61426,4.51855-3.61426,2.624,0,4.42285,1.47168,4.4834,3.61426H84.104c-.1123-1.23926-1.13672-1.9873-2.63379-1.9873s-2.52148.75684-2.52148,1.8584c0,.87793.6543,1.39453,2.25488,1.79l1.36816.33594c2.54785.60254,3.60645,1.62646,3.60645,3.44287,0,2.32324-1.85059,3.77832-4.79395,3.77832-2.75391,0-4.61328-1.4209-4.7334-3.667Z" style="fill: #fff"/>
|
||||
<path d="M90.19,19.28857v2.14258h1.72168v1.47168H90.19v4.9917c0,.77539.34473,1.13672,1.10156,1.13672a5.80752,5.80752,0,0,0,.61133-.043v1.46289a5.10351,5.10351,0,0,1-1.03223.08594c-1.833,0-2.54785-.68848-2.54785-2.44434V22.90283H87.00636V21.43115h1.31641V19.28857Z" style="fill: #fff"/>
|
||||
<path d="M92.90773,25.959c0-2.84912,1.67773-4.63916,4.29395-4.63916,2.625,0,4.29492,1.79,4.29492,4.63916,0,2.85645-1.66113,4.63867-4.29492,4.63867C94.56886,30.59766,92.90773,28.81543,92.90773,25.959Zm6.69531,0c0-1.95459-.89551-3.10791-2.40137-3.10791s-2.40039,1.16211-2.40039,3.10791c0,1.96191.89453,3.10645,2.40039,3.10645S99.603,27.9209,99.603,25.959Z" style="fill: #fff"/>
|
||||
<path d="M103.02882,21.43115h1.77246v1.541h.043a2.1594,2.1594,0,0,1,2.17773-1.63574,2.86616,2.86616,0,0,1,.63672.06934V23.144a2.59794,2.59794,0,0,0-.835-.1123,1.8728,1.8728,0,0,0-1.93652,2.0835v5.37012h-1.8584Z" style="fill: #fff"/>
|
||||
<path d="M116.22608,27.82617c-.25,1.64355-1.85059,2.77148-3.89844,2.77148-2.63379,0-4.26855-1.76465-4.26855-4.5957,0-2.84033,1.64355-4.68213,4.19043-4.68213,2.50488,0,4.08008,1.7207,4.08008,4.46631v.63672h-6.39453v.1123a2.358,2.358,0,0,0,2.43555,2.56445,2.04834,2.04834,0,0,0,2.09082-1.27344ZM109.94386,25.124h4.52637a2.17744,2.17744,0,0,0-2.2207-2.29834A2.29214,2.29214,0,0,0,109.94386,25.124Z" style="fill: #fff"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 12 KiB |
@@ -1,57 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg id="Calque_2" data-name="Calque 2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 126.5 40">
|
||||
<defs>
|
||||
<style>
|
||||
.cls-1 {
|
||||
fill: #a6a6a6;
|
||||
}
|
||||
|
||||
.cls-2 {
|
||||
fill: #fff;
|
||||
}
|
||||
</style>
|
||||
</defs>
|
||||
<g id="livetype">
|
||||
<g>
|
||||
<g>
|
||||
<path class="cls-1" d="M116.97,0H9.53c-.37,0-.73,0-1.09,0-.31,0-.61,0-.92.01-.67.02-1.34.06-2,.18-.67.12-1.29.32-1.9.63-.6.31-1.15.71-1.62,1.18-.48.47-.88,1.02-1.18,1.62-.31.61-.51,1.23-.63,1.9-.12.66-.16,1.33-.18,2,0,.31-.01.61-.02.92v23.11c0,.31,0,.61.02.92.02.67.06,1.34.18,2,.12.67.31,1.3.63,1.9.3.6.7,1.14,1.18,1.61.47.48,1.02.88,1.62,1.18.61.31,1.23.51,1.9.63.66.12,1.34.16,2,.18.31,0,.61.01.92.01.37,0,.73,0,1.09,0h107.44c.36,0,.72,0,1.08,0,.3,0,.62,0,.92-.01.67-.02,1.34-.06,2-.18.67-.12,1.29-.32,1.91-.63.6-.3,1.14-.7,1.62-1.18.48-.47.87-1.02,1.18-1.61.31-.61.51-1.23.62-1.9.12-.66.16-1.33.19-2,0-.31,0-.61,0-.92,0-.36,0-.72,0-1.09V9.54c0-.37,0-.73,0-1.09,0-.31,0-.61,0-.92-.02-.67-.06-1.34-.19-2-.11-.67-.31-1.29-.62-1.9-.31-.6-.71-1.15-1.18-1.62-.47-.47-1.02-.87-1.62-1.18-.62-.31-1.24-.51-1.91-.63-.66-.12-1.33-.16-2-.18-.3,0-.62-.01-.92-.01-.36,0-.72,0-1.08,0h0Z"/>
|
||||
<path d="M8.44,39.12c-.3,0-.6,0-.9-.01-.56-.02-1.22-.05-1.87-.16-.61-.11-1.15-.29-1.66-.55-.52-.26-.99-.61-1.4-1.02-.41-.41-.75-.87-1.02-1.4-.26-.5-.44-1.05-.54-1.66-.12-.67-.15-1.36-.17-1.88,0-.21-.01-.91-.01-.91V8.44s0-.69.01-.89c.01-.52.04-1.21.17-1.87.11-.61.28-1.16.54-1.66.27-.52.61-.99,1.02-1.4.41-.41.88-.76,1.4-1.02.51-.26,1.06-.44,1.65-.54.67-.12,1.36-.15,1.88-.16h.9s109.6-.01,109.6-.01h.91c.51.03,1.2.06,1.86.18.6.11,1.15.28,1.67.55.51.26.98.61,1.39,1.02.41.41.75.88,1.02,1.4.26.51.43,1.05.54,1.65.12.63.15,1.28.17,1.89,0,.28,0,.59,0,.89,0,.38,0,.73,0,1.09v20.93c0,.36,0,.72,0,1.08,0,.33,0,.62,0,.93-.02.59-.06,1.24-.17,1.85-.1.61-.28,1.16-.54,1.67-.27.52-.61.99-1.02,1.39-.41.42-.88.76-1.4,1.02-.52.26-1.05.44-1.67.55-.64.12-1.3.15-1.87.16-.29,0-.6.01-.9.01h-1.08s-108.53,0-108.53,0Z"/>
|
||||
</g>
|
||||
<g>
|
||||
<g>
|
||||
<path class="cls-2" d="M24.77,20.3c-.03-2.75,2.25-4.09,2.36-4.15-1.29-1.88-3.29-2.14-3.99-2.16-1.68-.18-3.31,1-4.16,1s-2.19-.99-3.61-.96c-1.83.03-3.54,1.09-4.47,2.73-1.93,3.35-.49,8.27,1.36,10.98.93,1.33,2.01,2.81,3.43,2.75,1.39-.06,1.91-.88,3.58-.88s2.14.88,3.59.85c1.49-.02,2.43-1.33,3.32-2.67,1.07-1.52,1.5-3.02,1.52-3.09-.03-.01-2.89-1.1-2.92-4.4Z"/>
|
||||
<path class="cls-2" d="M22.04,12.21c.75-.93,1.26-2.2,1.11-3.49-1.08.05-2.43.75-3.21,1.66-.69.8-1.3,2.12-1.14,3.36,1.21.09,2.46-.61,3.24-1.53Z"/>
|
||||
</g>
|
||||
<g>
|
||||
<path class="cls-2" d="M37.55,8.73c1.17,0,1.97.81,1.97,1.99s-.83,1.97-2,1.97h-1.38v2.01h-.93v-5.97h2.34ZM36.14,11.88h1.17c.8,0,1.27-.41,1.27-1.15s-.45-1.17-1.27-1.17h-1.17v2.32Z"/>
|
||||
<path class="cls-2" d="M40.73,10.2h.86v.69h.07c.13-.44.63-.77,1.22-.77.13,0,.3.01.4.04v.88c-.07-.02-.34-.05-.5-.05-.67,0-1.15.43-1.15,1.06v2.66h-.89v-4.5Z"/>
|
||||
<path class="cls-2" d="M47.94,13.49c-.2.81-.92,1.3-1.95,1.3-1.29,0-2.08-.88-2.08-2.32s.81-2.35,2.08-2.35,2.01.86,2.01,2.27v.31h-3.18v.05c.03.79.49,1.29,1.2,1.29.54,0,.91-.19,1.07-.55h.86ZM44.81,12.03h2.27c-.02-.71-.45-1.17-1.11-1.17s-1.12.46-1.17,1.17ZM45.45,9.45l1.04-1.42h1.04l-1.16,1.42h-.92Z"/>
|
||||
<path class="cls-2" d="M52.14,11.67c-.1-.44-.47-.76-1.06-.76-.74,0-1.2.57-1.2,1.53s.46,1.56,1.2,1.56c.56,0,.95-.26,1.06-.74h.86c-.12.91-.81,1.53-1.92,1.53-1.31,0-2.11-.88-2.11-2.35s.8-2.32,2.11-2.32c1.13,0,1.81.66,1.93,1.56h-.86Z"/>
|
||||
<path class="cls-2" d="M53.92,12.45c0-1.45.81-2.34,2.12-2.34s2.12.88,2.12,2.34-.81,2.34-2.12,2.34-2.12-.88-2.12-2.34ZM57.25,12.45c0-.98-.44-1.55-1.21-1.55s-1.21.57-1.21,1.55.43,1.55,1.21,1.55,1.21-.57,1.21-1.55Z"/>
|
||||
<path class="cls-2" d="M59.35,10.2h.86v.72h.07c.2-.51.65-.81,1.25-.81s1.04.32,1.24.81h.07c.23-.49.74-.81,1.37-.81.91,0,1.44.55,1.44,1.49v3.1h-.89v-2.87c0-.61-.29-.91-.87-.91s-.95.41-.95.94v2.83h-.87v-2.96c0-.51-.34-.82-.87-.82s-.95.44-.95,1.02v2.75h-.89v-4.5Z"/>
|
||||
<path class="cls-2" d="M67.02,10.2h.86v.72h.07c.2-.51.65-.81,1.25-.81s1.04.32,1.24.81h.07c.23-.49.74-.81,1.37-.81.91,0,1.44.55,1.44,1.49v3.1h-.89v-2.87c0-.61-.29-.91-.87-.91s-.95.41-.95.94v2.83h-.87v-2.96c0-.51-.34-.82-.87-.82s-.95.44-.95,1.02v2.75h-.89v-4.5Z"/>
|
||||
<path class="cls-2" d="M74.43,13.43c0-.81.6-1.28,1.67-1.34l1.22-.07v-.39c0-.48-.31-.74-.92-.74-.5,0-.84.18-.94.5h-.86c.09-.77.82-1.27,1.84-1.27,1.13,0,1.77.56,1.77,1.51v3.08h-.86v-.63h-.07c-.27.45-.76.71-1.35.71-.87,0-1.5-.52-1.5-1.35ZM77.33,13.04v-.38l-1.1.07c-.62.04-.9.25-.9.65s.35.64.83.64c.67,0,1.17-.43,1.17-.98Z"/>
|
||||
<path class="cls-2" d="M79.6,10.2h.86v.72h.07c.22-.5.67-.8,1.34-.8,1,0,1.56.6,1.56,1.67v2.92h-.89v-2.69c0-.72-.31-1.08-.97-1.08s-1.08.44-1.08,1.14v2.63h-.89v-4.5Z"/>
|
||||
<path class="cls-2" d="M84.58,12.45c0-1.42.73-2.32,1.87-2.32.62,0,1.14.29,1.38.79h.07v-2.47h.89v6.26h-.85v-.71h-.07c-.27.49-.79.79-1.41.79-1.15,0-1.87-.9-1.87-2.33ZM85.5,12.45c0,.96.45,1.53,1.2,1.53s1.21-.58,1.21-1.53-.47-1.53-1.21-1.53-1.2.58-1.2,1.53Z"/>
|
||||
<path class="cls-2" d="M94.05,13.49c-.2.81-.92,1.3-1.95,1.3-1.29,0-2.08-.88-2.08-2.32s.81-2.35,2.08-2.35,2.01.86,2.01,2.27v.31h-3.18v.05c.03.79.49,1.29,1.2,1.29.54,0,.91-.19,1.07-.55h.86ZM90.92,12.03h2.27c-.02-.71-.45-1.17-1.11-1.17s-1.12.46-1.17,1.17Z"/>
|
||||
<path class="cls-2" d="M95.3,10.2h.86v.69h.07c.13-.44.63-.77,1.22-.77.13,0,.3.01.4.04v.88c-.07-.02-.34-.05-.5-.05-.67,0-1.15.43-1.15,1.06v2.66h-.89v-4.5Z"/>
|
||||
<path class="cls-2" d="M102.9,10.11c1.01,0,1.67.47,1.76,1.27h-.85c-.08-.33-.41-.54-.91-.54s-.87.24-.87.59c0,.27.23.44.72.55l.75.17c.86.2,1.26.57,1.26,1.23,0,.85-.79,1.41-1.87,1.41s-1.77-.48-1.85-1.28h.89c.11.35.44.56.98.56s.95-.25.95-.61c0-.27-.21-.44-.66-.55l-.79-.18c-.86-.2-1.25-.59-1.25-1.26,0-.8.73-1.36,1.75-1.36Z"/>
|
||||
<path class="cls-2" d="M109.74,14.7h-.86v-.72h-.07c-.22.51-.68.8-1.36.8-1,0-1.55-.61-1.55-1.67v-2.92h.89v2.69c0,.73.29,1.08.95,1.08.72,0,1.11-.43,1.11-1.13v-2.63h.89v4.5Z"/>
|
||||
<path class="cls-2" d="M111.16,10.2h.86v.69h.07c.13-.44.63-.77,1.22-.77.13,0,.3.01.4.04v.88c-.07-.02-.34-.05-.5-.05-.67,0-1.15.43-1.15,1.06v2.66h-.89v-4.5Z"/>
|
||||
</g>
|
||||
</g>
|
||||
<g>
|
||||
<path class="cls-2" d="M35.2,18.07h1.86v12.42h-1.86v-12.42Z"/>
|
||||
<path class="cls-2" d="M39.3,22.61l1.02-4.54h1.81l-1.23,4.54h-1.59Z"/>
|
||||
<path class="cls-2" d="M49.15,27.13h-4.73l-1.14,3.36h-2l4.48-12.42h2.08l4.48,12.42h-2.04l-1.14-3.36ZM44.9,25.58h3.75l-1.85-5.45h-.05l-1.85,5.45Z"/>
|
||||
<path class="cls-2" d="M62,25.96c0,2.81-1.51,4.62-3.78,4.62-1.29,0-2.31-.58-2.85-1.58h-.04v4.48h-1.86v-12.05h1.8v1.51h.03c.52-.97,1.62-1.6,2.88-1.6,2.3,0,3.81,1.82,3.81,4.62ZM60.09,25.96c0-1.83-.95-3.04-2.39-3.04s-2.38,1.23-2.38,3.04.96,3.05,2.38,3.05,2.39-1.2,2.39-3.05Z"/>
|
||||
<path class="cls-2" d="M71.97,25.96c0,2.81-1.51,4.62-3.78,4.62-1.29,0-2.31-.58-2.85-1.58h-.04v4.48h-1.86v-12.05h1.8v1.51h.03c.52-.97,1.62-1.6,2.88-1.6,2.3,0,3.81,1.82,3.81,4.62ZM70.06,25.96c0-1.83-.95-3.04-2.39-3.04s-2.38,1.23-2.38,3.04.96,3.05,2.38,3.05,2.39-1.2,2.39-3.05Z"/>
|
||||
<path class="cls-2" d="M78.55,27.03c.14,1.23,1.33,2.04,2.97,2.04s2.69-.81,2.69-1.92c0-.96-.68-1.54-2.29-1.94l-1.61-.39c-2.28-.55-3.34-1.62-3.34-3.35,0-2.14,1.87-3.61,4.52-3.61s4.42,1.47,4.48,3.61h-1.88c-.11-1.24-1.14-1.99-2.63-1.99s-2.52.76-2.52,1.86c0,.88.65,1.39,2.25,1.79l1.37.34c2.55.6,3.61,1.63,3.61,3.44,0,2.32-1.85,3.78-4.79,3.78-2.75,0-4.61-1.42-4.73-3.67h1.9Z"/>
|
||||
<path class="cls-2" d="M90.19,19.29v2.14h1.72v1.47h-1.72v4.99c0,.78.34,1.14,1.1,1.14.19,0,.49-.03.61-.04v1.46c-.21.05-.62.09-1.03.09-1.83,0-2.55-.69-2.55-2.44v-5.19h-1.32v-1.47h1.32v-2.14h1.87Z"/>
|
||||
<path class="cls-2" d="M92.91,25.96c0-2.85,1.68-4.64,4.29-4.64s4.29,1.79,4.29,4.64-1.66,4.64-4.29,4.64-4.29-1.78-4.29-4.64ZM99.6,25.96c0-1.95-.9-3.11-2.4-3.11s-2.4,1.16-2.4,3.11.9,3.11,2.4,3.11,2.4-1.14,2.4-3.11Z"/>
|
||||
<path class="cls-2" d="M103.03,21.43h1.77v1.54h.04c.28-1.02,1.11-1.64,2.18-1.64.27,0,.49.04.64.07v1.74c-.15-.06-.47-.11-.83-.11-1.2,0-1.94.81-1.94,2.08v5.37h-1.86v-9.05Z"/>
|
||||
<path class="cls-2" d="M116.23,27.83c-.25,1.64-1.85,2.77-3.9,2.77-2.63,0-4.27-1.76-4.27-4.6s1.64-4.68,4.19-4.68,4.08,1.72,4.08,4.47v.64h-6.39v.11c0,1.55.97,2.56,2.44,2.56,1.03,0,1.84-.49,2.09-1.27h1.76ZM109.94,25.12h4.53c-.04-1.39-.93-2.3-2.22-2.3s-2.21.93-2.31,2.3Z"/>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 8.2 KiB |
@@ -1,63 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg id="artwork" xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 135 40">
|
||||
<!-- Generator: Adobe Illustrator 29.2.1, SVG Export Plug-In . SVG Version: 2.1.0 Build 116) -->
|
||||
<defs>
|
||||
<style>
|
||||
.st0 {
|
||||
fill: #4285f4;
|
||||
}
|
||||
|
||||
.st1 {
|
||||
isolation: isolate;
|
||||
}
|
||||
|
||||
.st2 {
|
||||
fill: #a6a6a6;
|
||||
}
|
||||
|
||||
.st3 {
|
||||
fill: #34a853;
|
||||
}
|
||||
|
||||
.st4 {
|
||||
fill: #fbbc04;
|
||||
}
|
||||
|
||||
.st5 {
|
||||
fill: #fff;
|
||||
}
|
||||
|
||||
.st6 {
|
||||
fill: #ea4335;
|
||||
}
|
||||
</style>
|
||||
</defs>
|
||||
<g>
|
||||
<rect width="135" height="40" rx="5" ry="5"/>
|
||||
<path class="st2" d="M130,.8c2.3,0,4.2,1.9,4.2,4.2v30c0,2.3-1.9,4.2-4.2,4.2H5c-2.3,0-4.2-1.9-4.2-4.2V5c0-2.3,1.9-4.2,4.2-4.2h125M130,0H5C2.2,0,0,2.2,0,5v30c0,2.8,2.2,5,5,5h125c2.8,0,5-2.2,5-5V5c0-2.8-2.2-5-5-5h0Z"/>
|
||||
<path class="st5" d="M68.1,21.8c-2.4,0-4.3,1.8-4.3,4.3s1.9,4.3,4.3,4.3,4.3-1.8,4.3-4.3-1.9-4.3-4.3-4.3ZM68.1,28.6c-1.3,0-2.4-1.1-2.4-2.6s1.1-2.6,2.4-2.6,2.4,1,2.4,2.6-1.1,2.6-2.4,2.6ZM58.8,21.8c-2.4,0-4.3,1.8-4.3,4.3s1.9,4.3,4.3,4.3,4.3-1.8,4.3-4.3-1.9-4.3-4.3-4.3ZM58.8,28.6c-1.3,0-2.4-1.1-2.4-2.6s1.1-2.6,2.4-2.6,2.4,1,2.4,2.6-1.1,2.6-2.4,2.6ZM47.7,23.1v1.8h4.3c-.1,1-.5,1.8-1,2.3-.6.6-1.6,1.3-3.3,1.3-2.7,0-4.7-2.1-4.7-4.8s2.1-4.8,4.7-4.8,2.5.6,3.3,1.3l1.3-1.3c-1.1-1-2.5-1.8-4.5-1.8-3.6,0-6.7,3-6.7,6.6s3.1,6.6,6.7,6.6,3.4-.6,4.6-1.9c1.2-1.2,1.6-2.9,1.6-4.2s0-.8,0-1.1h-6.1,0ZM93.1,24.5c-.4-1-1.4-2.7-3.6-2.7s-4,1.7-4,4.3,1.8,4.3,4.2,4.3,3.1-1.2,3.5-1.9l-1.4-1c-.5.7-1.1,1.2-2.1,1.2s-1.6-.4-2.1-1.3l5.7-2.4-.2-.5h0ZM87.3,25.9c0-1.6,1.3-2.5,2.2-2.5s1.4.4,1.6.9c0,0-3.8,1.6-3.8,1.6ZM82.6,30h1.9v-12.5h-1.9v12.5ZM79.6,22.7h0c-.4-.5-1.2-1-2.2-1-2.1,0-4.1,1.9-4.1,4.3s1.9,4.2,4.1,4.2,1.8-.5,2.2-1h0v.6c0,1.6-.9,2.5-2.3,2.5s-1.9-.8-2.1-1.5l-1.6.7c.5,1.1,1.7,2.5,3.8,2.5s4-1.3,4-4.4v-7.6h-1.8s0,.7,0,.7ZM77.4,28.6c-1.3,0-2.4-1.1-2.4-2.6s1.1-2.6,2.4-2.6,2.3,1.1,2.3,2.6-1,2.6-2.3,2.6ZM101.8,17.5h-4.5v12.5h1.9v-4.7h2.6c2.1,0,4.1-1.5,4.1-3.9s-2-3.9-4.1-3.9ZM101.9,23.5h-2.7v-4.3h2.7c1.4,0,2.2,1.2,2.2,2.1s-.8,2.1-2.2,2.1h0ZM113.4,21.7c-1.4,0-2.8.6-3.3,1.9l1.7.7c.4-.7,1-.9,1.7-.9s1.9.6,2,1.6h0c-.3,0-1.1-.4-1.9-.4-1.8,0-3.6,1-3.6,2.8s1.5,2.8,3.1,2.8,1.9-.6,2.4-1.2h0v1h1.8v-4.8c0-2.2-1.7-3.5-3.8-3.5h0ZM113.2,28.6c-.6,0-1.5-.3-1.5-1.1s1.1-1.3,2-1.3,1.2.2,1.7.4c-.1,1.2-1.1,2-2.2,2ZM123.7,22l-2.1,5.4h0l-2.2-5.4h-2l3.3,7.6-1.9,4.2h1.9l5.1-11.8h-2.1ZM106.9,30h1.9v-12.5h-1.9v12.5Z"/>
|
||||
<g>
|
||||
<path class="st6" d="M20.7,19.4l-10.6,11.3s0,0,0,0c.3,1.2,1.4,2.1,2.8,2.1s1-.1,1.5-.4h0s12-6.9,12-6.9l-5.6-6.1Z"/>
|
||||
<path class="st4" d="M31.5,17.5h0s-5.2-3-5.2-3l-5.8,5.2,5.8,5.8,5.1-3c.9-.5,1.5-1.4,1.5-2.5s-.6-2-1.5-2.5h0Z"/>
|
||||
<path class="st0" d="M10.1,9.3c0,.2,0,.5,0,.7v20c0,.3,0,.5,0,.7l11-11s-11-10.4-11-10.4Z"/>
|
||||
<path class="st3" d="M20.8,20l5.5-5.5-12-6.9c-.4-.3-.9-.4-1.5-.4-1.3,0-2.5.9-2.8,2.1h0s10.7,10.7,10.7,10.7h0Z"/>
|
||||
</g>
|
||||
</g>
|
||||
<g class="st1">
|
||||
<g class="st1">
|
||||
<path class="st5" d="M41.8,6.9h2c.6,0,1.2.1,1.7.4s.9.6,1.1,1.1c.3.5.4,1,.4,1.6s-.1,1.1-.4,1.6c-.3.5-.6.8-1.1,1.1s-1,.4-1.7.4h-2v-6.2ZM43.8,12.2c.7,0,1.2-.2,1.6-.6.4-.4.6-.9.6-1.6s-.2-1.2-.6-1.6c-.4-.4-.9-.6-1.6-.6h-1v4.4h1Z"/>
|
||||
<path class="st5" d="M48.1,6.9h1v6.2h-1v-6.2Z"/>
|
||||
<path class="st5" d="M50.9,12.8c-.4-.3-.7-.7-.9-1.3l.9-.4c0,.3.3.6.5.8s.5.3.8.3.6,0,.8-.2c.2-.2.3-.4.3-.7s0-.5-.3-.6-.5-.3-1-.5h-.4c-.4-.3-.8-.5-1.1-.8-.3-.3-.4-.6-.4-1.1s0-.6.3-.9c.2-.3.4-.5.7-.6.3-.2.6-.2,1-.2.5,0,1,.1,1.3.4s.5.6.7.9l-.9.4c0-.2-.2-.4-.4-.5-.2-.2-.4-.2-.7-.2s-.5,0-.7.2-.3.3-.3.6,0,.4.3.5c.2.1.4.3.8.4h.4c.5.3.9.6,1.2.9s.4.7.4,1.2-.1.7-.3,1c-.2.3-.5.5-.8.6-.3.1-.7.2-1,.2-.5,0-1-.2-1.4-.5Z"/>
|
||||
<path class="st5" d="M55.5,6.9h2.2c.4,0,.7,0,1,.2.3.2.6.4.7.7s.3.6.3,1,0,.7-.3,1-.4.5-.7.7c-.3.2-.6.2-1,.2h-1.2v2.4h-1v-6.2ZM57.7,9.8c.2,0,.4,0,.6-.1.2,0,.3-.2.4-.4,0-.2.1-.3.1-.5s0-.3-.1-.5c0-.2-.2-.3-.4-.4-.2,0-.3-.1-.6-.1h-1.2v2h1.2Z"/>
|
||||
<path class="st5" d="M61.9,12.8c-.5-.3-.9-.7-1.2-1.2-.3-.5-.4-1-.4-1.6s.1-1.1.4-1.6c.3-.5.7-.9,1.2-1.2.5-.3,1-.4,1.6-.4s1.1.1,1.6.4c.5.3.9.7,1.2,1.2.3.5.4,1,.4,1.6s-.1,1.1-.4,1.6c-.3.5-.7.9-1.2,1.2-.5.3-1,.4-1.6.4s-1.2-.1-1.6-.4ZM64.6,12c.3-.2.6-.5.8-.8.2-.4.3-.8.3-1.2s0-.9-.3-1.2-.5-.6-.8-.8-.7-.3-1.1-.3-.8,0-1.1.3c-.3.2-.6.5-.8.8s-.3.8-.3,1.2.1.9.3,1.2c.2.4.5.6.8.8.3.2.7.3,1.1.3s.8,0,1.1-.3Z"/>
|
||||
<path class="st5" d="M67.9,6.9h1.2l2.8,4.5h0v-1.2c0,0,0-3.3,0-3.3h1v6.2h-1l-2.9-4.8h0v1.2c0,0,0,3.6,0,3.6h-1v-6.2Z"/>
|
||||
<path class="st5" d="M74.2,6.9h1v6.2h-1v-6.2Z"/>
|
||||
<path class="st5" d="M76.6,6.9h2.3c.3,0,.6,0,.9.2.3.1.5.3.7.6.2.3.3.5.3.8s0,.6-.2.8c-.2.2-.4.4-.6.5h0c.3.2.6.3.8.6s.3.6.3.9,0,.6-.3.9c-.2.3-.4.5-.7.6-.3.1-.6.2-1,.2h-2.4v-6.2ZM78.9,9.5c.3,0,.5,0,.7-.3.2-.2.3-.4.3-.6s0-.4-.2-.6c-.2-.2-.4-.3-.6-.3h-1.4v1.7h1.3ZM79,12.2c.3,0,.5,0,.7-.3.2-.2.3-.4.3-.6s0-.5-.3-.6c-.2-.2-.4-.3-.7-.3h-1.4v1.8h1.5Z"/>
|
||||
<path class="st5" d="M82,6.9h1v5.3h2.7v.9h-3.6v-6.2Z"/>
|
||||
<path class="st5" d="M86.7,6.9h3.8v.9h-2.8v1.7h2.5v.9h-2.5v1.7h2.8v.9h-3.8v-6.2Z"/>
|
||||
<path class="st5" d="M93.9,12.8c-.4-.3-.7-.7-.9-1.3l.9-.4c0,.3.3.6.5.8.2.2.5.3.8.3s.6,0,.8-.2c.2-.2.3-.4.3-.7s0-.5-.3-.6-.5-.3-1-.5h-.4c-.4-.3-.8-.5-1.1-.8-.3-.3-.4-.6-.4-1.1s0-.6.3-.9.4-.5.7-.6.6-.2,1-.2c.5,0,1,.1,1.3.4.3.3.5.6.7.9l-.9.4c0-.2-.2-.4-.4-.5-.2-.2-.4-.2-.7-.2s-.5,0-.7.2-.3.3-.3.6,0,.4.3.5c.2.1.4.3.8.4h.4c.5.3.9.6,1.2.9s.4.7.4,1.2-.1.7-.3,1c-.2.3-.5.5-.8.6-.3.1-.7.2-1,.2-.5,0-1-.2-1.4-.5Z"/>
|
||||
<path class="st5" d="M99.5,13c-.4-.2-.6-.5-.8-.9s-.3-.8-.3-1.3v-3.8h1v3.9c0,.5.1.8.4,1.1s.6.4,1,.4.8-.1,1-.4c.2-.3.4-.7.4-1.1v-3.9h1v3.8c0,.5,0,.9-.3,1.3s-.5.7-.8.9c-.4.2-.8.3-1.3.3s-.9-.1-1.2-.3Z"/>
|
||||
<path class="st5" d="M104.4,6.9h2.2c.4,0,.7,0,1,.2.3.2.5.4.7.7.2.3.3.6.3,1s-.1.8-.4,1.1c-.3.3-.6.5-1,.7h0s1.7,2.5,1.7,2.5h0c0,0-1.1,0-1.1,0l-1.6-2.4h-.7v2.4h-1v-6.2ZM106.5,9.8c.3,0,.5,0,.7-.3s.3-.4.3-.7,0-.3-.1-.5c0-.2-.2-.3-.3-.4-.2,0-.3-.1-.5-.1h-1.2v2h1.2Z"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 5.8 KiB |
@@ -1,66 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg id="artwork" xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 135 40">
|
||||
<!-- Generator: Adobe Illustrator 29.2.1, SVG Export Plug-In . SVG Version: 2.1.0 Build 116) -->
|
||||
<defs>
|
||||
<style>
|
||||
.st0 {
|
||||
fill: #4285f4;
|
||||
}
|
||||
|
||||
.st1 {
|
||||
isolation: isolate;
|
||||
}
|
||||
|
||||
.st2 {
|
||||
fill: #a6a6a6;
|
||||
}
|
||||
|
||||
.st3 {
|
||||
fill: #34a853;
|
||||
}
|
||||
|
||||
.st4 {
|
||||
fill: #fbbc04;
|
||||
}
|
||||
|
||||
.st5 {
|
||||
fill: #fff;
|
||||
}
|
||||
|
||||
.st6 {
|
||||
fill: #ea4335;
|
||||
}
|
||||
</style>
|
||||
</defs>
|
||||
<g>
|
||||
<rect width="135" height="40" rx="5" ry="5"/>
|
||||
<path class="st2" d="M130,.8c2.3,0,4.2,1.9,4.2,4.2v30c0,2.3-1.9,4.2-4.2,4.2H5c-2.3,0-4.2-1.9-4.2-4.2V5c0-2.3,1.9-4.2,4.2-4.2h125M130,0H5C2.2,0,0,2.2,0,5v30c0,2.8,2.2,5,5,5h125c2.8,0,5-2.2,5-5V5c0-2.8-2.2-5-5-5h0Z"/>
|
||||
<path class="st5" d="M68.1,21.8c-2.4,0-4.3,1.8-4.3,4.3s1.9,4.3,4.3,4.3,4.3-1.8,4.3-4.3-1.9-4.3-4.3-4.3ZM68.1,28.6c-1.3,0-2.4-1.1-2.4-2.6s1.1-2.6,2.4-2.6,2.4,1,2.4,2.6-1.1,2.6-2.4,2.6ZM58.8,21.8c-2.4,0-4.3,1.8-4.3,4.3s1.9,4.3,4.3,4.3,4.3-1.8,4.3-4.3-1.9-4.3-4.3-4.3ZM58.8,28.6c-1.3,0-2.4-1.1-2.4-2.6s1.1-2.6,2.4-2.6,2.4,1,2.4,2.6-1.1,2.6-2.4,2.6ZM47.7,23.1v1.8h4.3c-.1,1-.5,1.8-1,2.3-.6.6-1.6,1.3-3.3,1.3-2.7,0-4.7-2.1-4.7-4.8s2.1-4.8,4.7-4.8,2.5.6,3.3,1.3l1.3-1.3c-1.1-1-2.5-1.8-4.5-1.8-3.6,0-6.7,3-6.7,6.6s3.1,6.6,6.7,6.6,3.4-.6,4.6-1.9c1.2-1.2,1.6-2.9,1.6-4.2s0-.8,0-1.1h-6.1,0ZM93.1,24.5c-.4-1-1.4-2.7-3.6-2.7s-4,1.7-4,4.3,1.8,4.3,4.2,4.3,3.1-1.2,3.5-1.9l-1.4-1c-.5.7-1.1,1.2-2.1,1.2s-1.6-.4-2.1-1.3l5.7-2.4-.2-.5h0ZM87.3,25.9c0-1.6,1.3-2.5,2.2-2.5s1.4.4,1.6.9c0,0-3.8,1.6-3.8,1.6ZM82.6,30h1.9v-12.5h-1.9v12.5ZM79.6,22.7h0c-.4-.5-1.2-1-2.2-1-2.1,0-4.1,1.9-4.1,4.3s1.9,4.2,4.1,4.2,1.8-.5,2.2-1h0v.6c0,1.6-.9,2.5-2.3,2.5s-1.9-.8-2.1-1.5l-1.6.7c.5,1.1,1.7,2.5,3.8,2.5s4-1.3,4-4.4v-7.6h-1.8s0,.7,0,.7ZM77.4,28.6c-1.3,0-2.4-1.1-2.4-2.6s1.1-2.6,2.4-2.6,2.3,1.1,2.3,2.6-1,2.6-2.3,2.6ZM101.8,17.5h-4.5v12.5h1.9v-4.7h2.6c2.1,0,4.1-1.5,4.1-3.9s-2-3.9-4.1-3.9ZM101.9,23.5h-2.7v-4.3h2.7c1.4,0,2.2,1.2,2.2,2.1s-.8,2.1-2.2,2.1h0ZM113.4,21.7c-1.4,0-2.8.6-3.3,1.9l1.7.7c.4-.7,1-.9,1.7-.9s1.9.6,2,1.6h0c-.3,0-1.1-.4-1.9-.4-1.8,0-3.6,1-3.6,2.8s1.5,2.8,3.1,2.8,1.9-.6,2.4-1.2h0v1h1.8v-4.8c0-2.2-1.7-3.5-3.8-3.5h0ZM113.2,28.6c-.6,0-1.5-.3-1.5-1.1s1.1-1.3,2-1.3,1.2.2,1.7.4c-.1,1.2-1.1,2-2.2,2ZM123.7,22l-2.1,5.4h0l-2.2-5.4h-2l3.3,7.6-1.9,4.2h1.9l5.1-11.8h-2.1ZM106.9,30h1.9v-12.5h-1.9v12.5Z"/>
|
||||
<g>
|
||||
<path class="st6" d="M20.7,19.4l-10.6,11.3s0,0,0,0c.3,1.2,1.4,2.1,2.8,2.1s1-.1,1.5-.4h0s12-6.9,12-6.9l-5.6-6.1Z"/>
|
||||
<path class="st4" d="M31.5,17.5h0s-5.2-3-5.2-3l-5.8,5.2,5.8,5.8,5.1-3c.9-.5,1.5-1.4,1.5-2.5s-.6-2-1.5-2.5h0Z"/>
|
||||
<path class="st0" d="M10.1,9.3c0,.2,0,.5,0,.7v20c0,.3,0,.5,0,.7l11-11s-11-10.4-11-10.4Z"/>
|
||||
<path class="st3" d="M20.8,20l5.5-5.5-12-6.9c-.4-.3-.9-.4-1.5-.4-1.3,0-2.5.9-2.8,2.1h0s10.7,10.7,10.7,10.7h0Z"/>
|
||||
</g>
|
||||
</g>
|
||||
<g class="st1">
|
||||
<g class="st1">
|
||||
<path class="st5" d="M42.1,12.8c-.4-.3-.6-.7-.8-1.2l.8-.3c0,.3.2.6.5.8.2.2.5.3.8.3s.5,0,.7-.2c.2-.1.3-.3.3-.6s0-.4-.3-.6c-.2-.2-.5-.3-.9-.5h-.4c-.4-.3-.7-.5-1-.7-.3-.3-.4-.6-.4-1s0-.5.2-.8c.2-.2.4-.4.6-.6.3-.1.6-.2.9-.2.5,0,.9.1,1.2.4.3.2.5.5.6.8l-.8.3c0-.2-.2-.3-.3-.5s-.4-.2-.6-.2-.5,0-.7.2c-.2.1-.3.3-.3.5s0,.4.2.5c.2.1.4.3.8.4h.4c.5.3.9.5,1.1.8.3.3.4.6.4,1.1s0,.7-.3.9c-.2.3-.4.4-.7.6-.3.1-.6.2-.9.2-.5,0-.9-.1-1.3-.4Z"/>
|
||||
<path class="st5" d="M46.4,7.4h3.5v.9h-2.6v1.6h2.3v.8h-2.3v1.6h2.6v.9h-3.5v-5.7Z"/>
|
||||
<path class="st5" d="M52.8,7.4h2c.3,0,.6,0,.9.2.3.1.5.4.7.6s.3.6.3.9,0,.6-.3.9-.4.5-.7.6c-.3.1-.6.2-.9.2h-1.1v2.2h-.9v-5.7ZM54.8,10.1c.2,0,.4,0,.5-.1s.3-.2.3-.3.1-.3.1-.4,0-.3-.1-.4-.2-.2-.3-.3c-.1,0-.3-.1-.5-.1h-1.1v1.8h1.1Z"/>
|
||||
<path class="st5" d="M57.6,7.4h2c.3,0,.7,0,.9.2s.5.4.7.6.2.6.2.9-.1.7-.4,1-.6.5-.9.6h0s1.6,2.3,1.6,2.3h0s-1,0-1,0l-1.5-2.2h-.7v2.2h-.9v-5.7ZM59.6,10.1c.3,0,.5,0,.7-.3.2-.2.3-.4.3-.7s0-.3-.1-.4-.2-.3-.3-.3-.3-.1-.5-.1h-1.1v1.8h1.1Z"/>
|
||||
<path class="st5" d="M62.5,7.4h3.5v.9h-2.6v1.6h2.3v.8h-2.3v1.6h2.6v.9h-3.5v-5.7ZM64.2,5.9h1l-.6,1.1h-.7l.4-1.1Z"/>
|
||||
<path class="st5" d="M67.1,7.4h.9v5.7h-.9v-5.7Z"/>
|
||||
<path class="st5" d="M69.3,7.4h1.1l2.6,4.2h0v-1.1s0-3.1,0-3.1h.9v5.7h-.9l-2.7-4.4h0v1.1s0,3.3,0,3.3h-.9v-5.7Z"/>
|
||||
<path class="st5" d="M75.5,12.8c-.4-.3-.6-.7-.8-1.2l.8-.3c0,.3.2.6.5.8.2.2.5.3.8.3s.5,0,.7-.2c.2-.1.3-.3.3-.6s0-.4-.3-.6c-.2-.2-.5-.3-.9-.5h-.4c-.4-.3-.7-.5-1-.7s-.4-.6-.4-1,0-.5.2-.8c.2-.2.4-.4.6-.6.3-.1.6-.2.9-.2.5,0,.9.1,1.2.4.3.2.5.5.6.8l-.8.3c0-.2-.2-.3-.3-.5-.2-.1-.4-.2-.6-.2s-.5,0-.7.2c-.2.1-.3.3-.3.5s0,.4.2.5.4.3.8.4h.4c.5.3.9.5,1.1.8.3.3.4.6.4,1.1s0,.7-.3.9c-.2.3-.4.4-.7.6-.3.1-.6.2-.9.2-.5,0-.9-.1-1.3-.4Z"/>
|
||||
<path class="st5" d="M80.9,12.9c-.5-.3-.8-.6-1.1-1.1s-.4-1-.4-1.5.1-1.1.4-1.5.6-.8,1.1-1.1,1-.4,1.5-.4.8,0,1.2.2c.4.2.7.4.9.7l-.6.6c-.2-.2-.4-.4-.7-.5-.2-.1-.5-.2-.8-.2s-.7,0-1.1.3c-.3.2-.6.4-.8.7-.2.3-.3.7-.3,1.1s0,.8.3,1.1c.2.3.4.6.8.7s.7.3,1.1.3c.6,0,1.2-.3,1.6-.8l.6.6c-.3.3-.6.6-1,.8-.4.2-.8.3-1.3.3s-1.1-.1-1.5-.4Z"/>
|
||||
<path class="st5" d="M85.7,7.4h2c.3,0,.7,0,.9.2.3.1.5.4.7.6.2.3.2.6.2.9s-.1.7-.4,1-.6.5-.9.6h0s1.6,2.3,1.6,2.3h0s-1,0-1,0l-1.5-2.2h-.7v2.2h-.9v-5.7ZM87.7,10.1c.3,0,.5,0,.7-.3.2-.2.3-.4.3-.7s0-.3-.1-.4c0-.1-.2-.3-.3-.3s-.3-.1-.5-.1h-1.1v1.8h1.1Z"/>
|
||||
<path class="st5" d="M90.6,7.4h.9v5.7h-.9v-5.7Z"/>
|
||||
<path class="st5" d="M92.8,7.4h2c.3,0,.7,0,.9.2.3.1.5.4.7.6.2.3.2.6.2.9s-.1.7-.4,1-.6.5-.9.6h0s1.6,2.3,1.6,2.3h0s-1,0-1,0l-1.5-2.2h-.7v2.2h-.9v-5.7ZM94.8,10.1c.3,0,.5,0,.7-.3s.3-.4.3-.7,0-.3-.1-.4c0-.1-.2-.3-.3-.3s-.3-.1-.5-.1h-1.1v1.8h1.1Z"/>
|
||||
<path class="st5" d="M97.7,7.4h3.5v.9h-2.6v1.6h2.3v.8h-2.3v1.6h2.6v.9h-3.5v-5.7Z"/>
|
||||
<path class="st5" d="M104.3,12.8c-.4-.3-.6-.7-.8-1.2l.8-.3c0,.3.2.6.5.8.2.2.5.3.8.3s.5,0,.7-.2c.2-.1.3-.3.3-.6s0-.4-.3-.6c-.2-.2-.5-.3-.9-.5h-.4c-.4-.3-.7-.5-1-.7s-.4-.6-.4-1,0-.5.2-.8c.2-.2.4-.4.6-.6.3-.1.6-.2.9-.2.5,0,.9.1,1.2.4.3.2.5.5.6.8l-.8.3c0-.2-.2-.3-.3-.5-.2-.1-.4-.2-.6-.2s-.5,0-.7.2c-.2.1-.3.3-.3.5s0,.4.2.5.4.3.8.4h.4c.5.3.9.5,1.1.8.3.3.4.6.4,1.1s0,.7-.3.9c-.2.3-.4.4-.7.6-.3.1-.6.2-.9.2-.5,0-.9-.1-1.3-.4Z"/>
|
||||
<path class="st5" d="M109.5,13c-.3-.2-.6-.5-.8-.8-.2-.4-.3-.8-.3-1.2v-3.5h.9v3.6c0,.4.1.8.3,1,.2.3.5.4.9.4s.7-.1.9-.4c.2-.3.3-.6.3-1v-3.6h.9v3.5c0,.5,0,.9-.3,1.2-.2.4-.4.6-.8.8-.3.2-.7.3-1.2.3s-.8,0-1.1-.3Z"/>
|
||||
<path class="st5" d="M114,7.4h2c.3,0,.7,0,.9.2.3.1.5.4.7.6.2.3.2.6.2.9s-.1.7-.4,1-.6.5-.9.6h0s1.6,2.3,1.6,2.3h0s-1,0-1,0l-1.5-2.2h-.7v2.2h-.9v-5.7ZM116,10.1c.3,0,.5,0,.7-.3s.3-.4.3-.7,0-.3-.1-.4c0-.1-.2-.3-.3-.3s-.3-.1-.5-.1h-1.1v1.8h1.1Z"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 6.4 KiB |
@@ -39,23 +39,6 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
||||
<a href="{% url 'password_reset' %}"
|
||||
class="badge badge-light">{% trans 'Forgotten your password or username?' %}</a>
|
||||
</form>
|
||||
|
||||
<div class="text-center mt-4">
|
||||
{% now "Ymd" as current_date_str %}
|
||||
|
||||
{% if display_appstore_badge %}
|
||||
<a href="https://apps.apple.com/fr/app/la-note-kfet/id6754661723" class="d-inline-block mx-1" aria-label="{% trans 'Download on the AppStore' %}" style="cursor: pointer;">
|
||||
<img src="{% static 'img/' %}{% if current_date_str < '20260201' %}appstore_badge_fr_preorder.svg{% else %}appstore_badge_fr.svg{% endif %}"
|
||||
alt="{% trans 'Download on the AppStore' %}" style="height: 50px;">
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if display_playstore_badge %}
|
||||
<a href="https://play.google.com/store/apps/details?id=org.crans.bde.note&hl=fr" class="d-inline-block mx-1" aria-label="{% trans 'Get it on Google Play' %}" style="cursor: pointer;">
|
||||
<img src="{% static 'img/' %}{% if current_date_str < '20260201' %}playstore_badge_fr_preorder.svg{% else %}playstore_badge_fr.svg{% endif %}"
|
||||
alt="{% trans 'Get it on Google Play' %}" style="height: 50px;">
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user