1
0
mirror of https://gitlab.crans.org/bde/nk20 synced 2025-11-29 00:37:05 +01:00

Compare commits

..

37 Commits

Author SHA1 Message Date
Ehouarn
bac9ed2353 Merge branch 'main' into note_sheet 2025-11-20 17:50:11 +01:00
Ehouarn
0fad2a876d Hide orders&co for now 2025-11-20 16:21:16 +01:00
Ehouarn
12b2e7869a Some permissions 2025-11-20 16:02:38 +01:00
quark
0240ea5388 Merge branch 'oauth2' into 'main'
Oauth2

See merge request bde/nk20!360
2025-11-15 20:41:57 +01:00
quark
13171899c2 translations 2025-11-13 16:00:05 +01:00
quark
dacedbff20 Merge branch 'main' into oauth2 2025-11-13 15:53:39 +01:00
quark
a61a4667b9 docs 2025-11-10 18:07:32 +01:00
quark
9998189dbf token access 2025-11-09 14:48:29 +01:00
quark
08593700fc implicit flow #137 2025-11-09 11:18:11 +01:00
quark
54d28b30e5 Authorization Code Flow #137 2025-11-08 23:12:42 +01:00
quark
c09f133652 ropb implementation #137 2025-11-07 18:46:55 +01:00
quark
bfd50e3cd5 Client Credential Flow implementation 2025-11-07 15:49:01 +01:00
Ehouarn
6a77cfd4dd Add model Recipe 2025-11-07 14:29:03 +01:00
quark
68341a2a7e Add test for oauth2 flow, add temporary ROPB for NoteApp #137 2025-11-07 10:41:10 +01:00
alexismdr
7af3c42a02 Merge branch 'app_download_links' into 'main'
apps: add download links on login page

See merge request bde/nk20!358
2025-11-04 11:30:58 +01:00
alexismdr
73b63186fd fix: remove margin below App Store preorder badge 2025-11-04 09:18:27 +01:00
Ehouarn
48b1ef9ec8 Add field 'traces' for model Food 2025-11-02 18:43:33 +01:00
Ehouarn
4f016fed38 'Add all identical food' also for QRcode input 2025-11-02 18:42:14 +01:00
Alexis Mercier des Rochettes
e119e2295c apps: add preorder badges
* add appstore_badge_fr_preorder.svg static asset
* add playstore_badge_fr_preorder.svg static asset
* now displays preorder badges instead of download before 01 feb 2026 (estimated availability date)
2025-11-01 17:21:53 +01:00
Alexis Mercier des Rochettes
37beb8f421 apps: fix playstore badge google sans font 2025-11-01 16:54:42 +01:00
Alexis Mercier des Rochettes
cae86bcd46 apps: display appstore badges based on UA
* on iPhone, only AppStore badge displays
* on Android, only PlayStore badge displays
* on any other platform, both display
2025-11-01 16:24:34 +01:00
ehouarn
74aee64161 Merge branch 'small_features' into 'main'
Small features

See merge request bde/nk20!359
2025-10-31 23:50:58 +01:00
Ehouarn
206a967827 Permissions fixed 2025-10-31 21:53:35 +01:00
Alexis Mercier des Rochettes
04001202f2 apps: add download links on login page
* Add Badges with official links to store pages on login page
* Add AppStore/Google Play badges in static img assets [1][2]
* Add translation for "Download on the AppStore" and "Get it on Google Play"

[1] https://developer.apple.com/app-store/marketing/guidelines/
[2] https://partnermarketinghub.withgoogle.com/brands/google-play/visual-identity/badge-guidelines/

Signed-off-by: Alexis Mercier des Rochettes <apernouille@gmail.com>
2025-10-30 01:31:25 +01:00
Ehouarn
69aedccbae Get rid of activity and guests duplicates 2025-10-19 23:58:41 +02:00
ehouarn
af36d1427a Merge branch 'small_features' into 'main'
Second step for SogeCredit validity

See merge request bde/nk20!357
2025-10-17 19:45:24 +02:00
Ehouarn
75a59e0a7a Incorrect wei test due to new SogeCredit logic 2025-10-17 19:14:17 +02:00
Ehouarn
af39bf7068 Second step for SogeCredit validity 2025-10-17 17:55:43 +02:00
quark
d2cc1b902d allows mask for Oauth2 2025-10-17 17:45:41 +02:00
ehouarn
4c40566513 Merge branch 'small_features' into 'main'
Small features

See merge request bde/nk20!355
2025-10-16 20:25:02 +02:00
Ehouarn
7c45b59298 Fixed treasury test 2025-10-16 20:05:27 +02:00
ehouarn
418268db27 Merge branch 'update_invoice_template' into 'main'
Replace Diolistos_bg.jpg

See merge request bde/nk20!354
2025-10-12 18:49:43 +02:00
ehouarn
73045586a3 Replace Diolistos_bg.jpg 2025-10-12 18:26:39 +02:00
quark
22d668a75c membership date end 2025-10-02 19:11:26 +02:00
quark
5dfa12fad2 update django_polymorphic (3.1 to 3.2) 2025-10-02 18:58:59 +02:00
Ehouarn
5af69f719d First step to re-write logic of SogeCredit validity 2025-09-28 22:13:52 +02:00
Ehouarn
4f6b1d5b6c More open food 2025-09-28 21:51:54 +02:00
45 changed files with 2210 additions and 314 deletions

View File

@@ -65,10 +65,11 @@ SPDX-License-Identifier: GPL-3.0-or-later
{% if activity.open and activity.activity_type.manage_entries and ".change__open"|has_perm:activity %}
<a class="btn btn-warning btn-sm my-1" href="{% url 'activity:activity_entry' pk=activity.pk %}"> {% trans "Entry page" %}</a>
{% endif %}
{% if false %}
{% 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 %}
{% endif %}
{% if request.path_info == activity_detail_url %}
{% if activity.valid and ".change__open"|has_perm:activity %}

View File

@@ -152,9 +152,11 @@ 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")),
.filter(PermissionBackend.filter_queryset(self.request, Guest, "view"))
.distinct(),
self.object.opener.filter(activity=self.object)
.filter(PermissionBackend.filter_queryset(self.request, Opener, "view")),
.filter(PermissionBackend.filter_queryset(self.request, Opener, "view"))
.distinct(),
]
def render_to_response(self, context, **response_kwargs):
@@ -309,7 +311,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")).get(pk=self.kwargs["pk"])
.filter(PermissionBackend.filter_queryset(self.request, Activity, "view")).distinct().get(pk=self.kwargs["pk"])
return super().form_valid(form)
def get_success_url(self, **kwargs):

View File

@@ -6,14 +6,15 @@ 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.forms import CheckboxSelectMultiple
from django.forms.widgets import NumberInput, TextInput
from django.utils.translation import gettext_lazy as _
from member.models import Club
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, Dish, Supplement, Order
from .models import Food, BasicFood, TransformedFood, QRCode, Dish, Supplement, Order, Recipe
class QRCodeForms(forms.ModelForm):
@@ -55,7 +56,7 @@ class BasicFoodForms(forms.ModelForm):
class Meta:
model = BasicFood
fields = ('name', 'owner', 'date_type', 'expiry_date', 'allergens', 'order',)
fields = ('name', 'owner', 'date_type', 'expiry_date', 'allergens', 'traces', 'order',)
widgets = {
"owner": Autocomplete(
model=Club,
@@ -98,7 +99,7 @@ class BasicFoodUpdateForms(forms.ModelForm):
"""
class Meta:
model = BasicFood
fields = ('name', 'owner', 'date_type', 'expiry_date', 'end_of_life', 'is_ready', 'order', 'allergens')
fields = ('name', 'owner', 'date_type', 'expiry_date', 'end_of_life', 'is_ready', 'order', 'allergens', 'traces')
widgets = {
"owner": Autocomplete(
model=Club,
@@ -134,7 +135,7 @@ class AddIngredientForms(forms.ModelForm):
Form for add an ingredient
"""
fully_used = forms.BooleanField()
fully_used.initial = True
fully_used.initial = False
fully_used.required = False
fully_used.label = _("Fully used")
@@ -142,11 +143,14 @@ class AddIngredientForms(forms.ModelForm):
super().__init__(*args, **kwargs)
# TODO find a better way to get pk (be not url scheme dependant)
pk = get_current_request().path.split('/')[-1]
self.fields['ingredients'].queryset = self.fields['ingredients'].queryset.filter(
qs = self.fields['ingredients'].queryset.filter(
polymorphic_ctype__model="transformedfood",
is_ready=False,
end_of_life='',
).filter(PermissionBackend.filter_queryset(get_current_request(), Food, "change")).exclude(pk=pk)
).filter(PermissionBackend.filter_queryset(get_current_request(), Food, "change"))
if pk:
qs = qs.exclude(pk=pk)
self.fields['ingredients'].queryset = qs
class Meta:
model = TransformedFood
@@ -158,7 +162,7 @@ class ManageIngredientsForm(forms.Form):
Form to manage ingredient
"""
fully_used = forms.BooleanField()
fully_used.initial = True
fully_used.initial = False
fully_used.required = True
fully_used.label = _('Fully used')
@@ -248,3 +252,49 @@ class OrderForm(forms.ModelForm):
class Meta:
model = Order
exclude = ("activity", "number", "ordered_at", "served", "served_at")
class RecipeForm(forms.ModelForm):
"""
Form to create a recipe
"""
class Meta:
model = Recipe
fields = ('name', 'creater',)
widgets = {
"creater": Autocomplete(
model=Club,
attrs={"api_url": "/api/members/club/"},
),
}
class RecipeIngredientsForm(forms.Form):
"""
Form to add ingredients to a recipe
"""
name = forms.CharField()
name.widget = TextInput()
name.label = _("Name")
RecipeIngredientsFormSet = forms.formset_factory(
RecipeIngredientsForm,
extra=1,
)
class UseRecipeForm(forms.Form):
"""
Form to add ingredients to a TransformedFood using a Recipe
"""
recipe = forms.ModelChoiceField(
queryset=Recipe.objects,
label=_('Recipe'),
)
ingredients = forms.ModelMultipleChoiceField(
queryset=Food.objects,
label=_("Ingredients"),
widget=CheckboxSelectMultiple(),
)

View File

@@ -0,0 +1,18 @@
# Generated by Django 5.2.6 on 2025-11-02 17:38
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('food', '0004_alter_foodtransaction_order'),
]
operations = [
migrations.AddField(
model_name='food',
name='traces',
field=models.ManyToManyField(blank=True, related_name='food_with_traces', to='food.allergen', verbose_name='traces'),
),
]

View File

@@ -0,0 +1,29 @@
# Generated by Django 5.2.6 on 2025-11-06 17:02
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('food', '0005_food_traces'),
('member', '0015_alter_profile_promotion'),
]
operations = [
migrations.CreateModel(
name='Recipe',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=255, verbose_name='name')),
('ingredients_json', models.TextField(blank=True, default='[]', help_text='Ingredients of the recipe, encoded in JSON', verbose_name='list of ingredients')),
('creater', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='member.club', verbose_name='creater')),
],
options={
'verbose_name': 'Recipe',
'verbose_name_plural': 'Recipes',
'unique_together': {('name', 'creater')},
},
),
]

View File

@@ -1,6 +1,7 @@
# Copyright (C) 2018-2025 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later
import json
from datetime import timedelta
from django.db import models, transaction
@@ -53,6 +54,13 @@ class Food(PolymorphicModel):
verbose_name=_('allergens'),
)
traces = models.ManyToManyField(
Allergen,
blank=True,
verbose_name=_('traces'),
related_name='food_with_traces'
)
expiry_date = models.DateTimeField(
verbose_name=_('expiry date'),
null=False,
@@ -91,6 +99,19 @@ class Food(PolymorphicModel):
if old_allergens != list(parent.allergens.all()):
parent.save(old_allergens=old_allergens)
@transaction.atomic
def update_traces(self):
# update parents
for parent in self.transformed_ingredient_inv.iterator():
old_traces = list(parent.traces.all()).copy()
parent.traces.clear()
for child in parent.ingredients.iterator():
if child.pk != self.pk:
parent.traces.set(parent.traces.union(child.traces.all()))
parent.traces.set(parent.traces.union(self.traces.all()))
if old_traces != list(parent.traces.all()):
parent.save(old_traces=old_traces)
def update_expiry_date(self):
# update parents
for parent in self.transformed_ingredient_inv.iterator():
@@ -142,6 +163,10 @@ class BasicFood(Food):
and list(self.allergens.all()) != kwargs['old_allergens']):
self.update_allergens()
if ('old_traces' in kwargs
and list(self.traces.all()) != kwargs['old_traces']):
self.update_traces()
# Expiry date
if ((self.expiry_date != old_food.expiry_date
and self.date_type == 'DLC')
@@ -214,7 +239,7 @@ class TransformedFood(Food):
created = self.pk is None
if not created:
# Check if important fields are updated
update = {'allergens': False, 'expiry_date': False}
update = {'allergens': False, 'traces': False, 'expiry_date': False}
old_food = Food.objects.select_for_update().get(pk=self.pk)
if not hasattr(self, "_force_save"):
# Allergens
@@ -224,6 +249,10 @@ class TransformedFood(Food):
and list(self.allergens.all()) != kwargs['old_allergens']):
update['allergens'] = True
if ('old_traces' in kwargs
and list(self.traces.all()) != kwargs['old_traces']):
update['traces'] = True
# Expiry date
update['expiry_date'] = (self.shelf_life != old_food.shelf_life
or self.creation_date != old_food.creation_date)
@@ -234,6 +263,7 @@ class TransformedFood(Food):
if ('old_ingredients' in kwargs
and list(self.ingredients.all()) != list(kwargs['old_ingredients'])):
update['allergens'] = True
update['traces'] = True
update['expiry_date'] = True
# it's preferable to keep a queryset but we allow list too
@@ -243,6 +273,8 @@ class TransformedFood(Food):
self.check_cycle(self.ingredients.all().difference(kwargs['old_ingredients']), self, [])
if update['allergens']:
self.update_allergens()
if update['traces']:
self.update_traces()
if update['expiry_date']:
self.update_expiry_date()
@@ -254,6 +286,7 @@ class TransformedFood(Food):
for child in self.ingredients.iterator():
self.allergens.set(self.allergens.union(child.allergens.all()))
self.traces.set(self.traces.union(child.traces.all()))
if not (child.polymorphic_ctype.model == 'basicfood' and child.date_type == 'DDM'):
self.expiry_date = min(self.expiry_date, child.expiry_date)
return super().save(force_insert=False, force_update=force_update, using=using, update_fields=update_fields)
@@ -363,6 +396,12 @@ class Supplement(models.Model):
return _("Supplement {food} for {dish}").format(
food=str(self.food), dish=str(self.dish))
def save(self, *args, **kwargs):
# Check the owner of the food
if self.food.owner != self.dish.main.owner:
raise ValidationError(_('You cannot select food that belongs to the same club than the main food.'))
return super().save(*args, **kwargs)
class Order(models.Model):
"""
@@ -438,6 +477,8 @@ class Order(models.Model):
user=str(self.user))
def save(self, *args, **kwargs):
if self.activity != self.dish.activity:
raise ValidationError(_('Activities must be the same.'))
created = self.pk is None
if created:
last_order = Order.objects.filter(activity=self.activity).last()
@@ -453,6 +494,7 @@ class Order(models.Model):
destination=self.activity.organizer.note,
amount=self.amount,
quantity=1,
reason=str(self.dish),
)
transaction.save()
else:
@@ -481,3 +523,48 @@ class FoodTransaction(Transaction):
def save(self, *args, **kwargs):
self.valid = self.order.served
super().save(*args, **kwargs)
class Recipe(models.Model):
"""
A recipe is a list of ingredients one can use to easily create a recurrent TransformedFood
"""
name = models.CharField(
verbose_name=_("name"),
max_length=255,
)
ingredients_json = models.TextField(
blank=True,
default="[]",
verbose_name=_("list of ingredients"),
help_text=_("Ingredients of the recipe, encoded in JSON")
)
creater = models.ForeignKey(
Club,
on_delete=models.CASCADE,
verbose_name=_("creater"),
)
class Meta:
verbose_name = _("Recipe")
verbose_name_plural = _("Recipes")
unique_together = ('name', 'creater',)
def __str__(self):
return "{name} ({creater})".format(name=self.name, creater=str(self.creater))
@property
def ingredients(self):
"""
Ingredients are stored in a JSON string
"""
return json.loads(self.ingredients_json)
@ingredients.setter
def ingredients(self, ingredients):
"""
Store ingredients as JSON string
"""
self.ingredients_json = json.dumps(ingredients, indent=2)

View File

@@ -7,7 +7,7 @@ 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, Dish, Order
from .models import Food, Dish, Order, Recipe
class FoodTable(tables.Table):
@@ -32,7 +32,7 @@ class FoodTable(tables.Table):
class Meta:
model = Food
template_name = 'django_tables2/bootstrap4.html'
fields = ('name', 'owner', 'qr_code_numbers', 'allergens', 'date', 'expiry_date')
fields = ('name', 'owner', 'qr_code_numbers', 'allergens', 'traces', 'date', 'expiry_date')
row_attrs = {
'class': 'table-row',
'data-href': lambda record: 'detail/' + str(record.pk),
@@ -115,3 +115,21 @@ class OrderTable(tables.Table):
'class': 'table-row',
'style': 'cursor:pointer',
}
class RecipeTable(tables.Table):
"""
List all recipes
"""
def render_ingredients(self, record):
return ", ".join(str(q) for q in record.ingredients)
class Meta:
model = Recipe
template_name = 'django_tables2/bootstrap4.html'
fields = ('name', 'creater', 'ingredients',)
row_attrs = {
'class': 'table-row',
'data-href': lambda record: str(record.pk),
'style': 'cursor:pointer',
}

View File

@@ -47,6 +47,11 @@ SPDX-License-Identifier: GPL-3.0-or-later
<a class="btn btn-sm btn-secondary" href="{% url "food:manage_ingredients" pk=food.pk %}">
{% trans "Manage ingredients" %}
</a>
{% if false %}
<a class="btn btn-sm btn-secondary" href="{% url "food:recipe_use" pk=food.pk %}">
{% trans "Use a recipe" %}
</a>
{% endif %}
{% endif %}
<a class="btn btn-sm btn-primary" href="{% url "food:food_list" %}">
{% trans "Return to the food list" %}

View File

@@ -70,11 +70,23 @@ SPDX-License-Identifier: GPL-3.0-or-later
{% trans "New meal" %}
</a>
{% endif %}
{% if false %}
{% if can_view_recipes %}
<a class="btn btn-sm btn-secondary" href="{% url 'food:recipe_list' %}">
{% trans "View recipes" %}
</a>
{% endif %}
{% if can_add_recipe %}
<a class="btn btn-sm btn-primary" href="{% url 'food:recipe_create' %}">
{% trans "New recipe" %}
</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 %}
{% endif %}
</div>
{% if served.data %}

View File

@@ -90,7 +90,7 @@ function delete_form_data (form_id) {
document.getElementById(prefix + "name").value = "";
document.getElementById(prefix + "qrcode_pk").value = "";
document.getElementById(prefix + "qrcode").value = "";
document.getElementById(prefix + "fully_used").checked = true;
document.getElementById(prefix + "fully_used").checked = false;
}
var form_count = {{ ingredients_count }} + 1;

View File

@@ -0,0 +1,31 @@
{% 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 }} {{ recipe.name }}
</h3>
<div class="card-body">
<ul>
<li> {% trans "Creater" %} : {{ recipe.creater }}</li>
<li> {% trans "Ingredients" %} :
{% for ingredient in ingredients %} {{ ingredient }}{% if not forloop.last %},{% endif %}{% endfor %}
</li>
</ul>
{% if update %}
<a class="btn btn-sm btn-secondary" href="{% url "food:recipe_update" pk=recipe.pk %}">
{% trans "Update" %}
</a>
{% endif %}
<a class="btn btn-sm btn-primary" href="{% url "food:recipe_list" %}">
{% trans "Return to recipe list" %}
</a>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,122 @@
{% 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="" id="recipe_form">
{% csrf_token %}
<div class="card-body">
{% crispy recipe_form %}
{# Keep all form elements in the same card-body for proper structure #}
{{ formset.management_form }}
<h3 class="text-center mt-4">{% trans "Add ingredients" %}</h3>
<table class="table table-condensed table-striped">
{% for form in formset %}
{% if forloop.first %}
<thead>
<tr>
<th>{{ form.name.label }}</th>
</tr>
</thead>
<tbody id="form_body">
{% endif %}
<tr class="row-formset ingredients">
<td>
{# Force prefix on the form fields #}
{{ form.name.as_widget }}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{# Display buttons to add and remove ingredients #}
<div class="card-body">
<div class="btn-group btn-block" role="group">
<button type="button" id="add_more" class="btn btn-success">{% trans "Add ingredient" %}</button>
<button type="button" id="remove_one" class="btn btn-danger">{% trans "Remove ingredient" %}</button>
</div>
<button class="btn btn-primary" type="submit" form="recipe_form">{% 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.name }}</td>
</tr>
</tbody>
</table>
</div>
{% endblock %}
{% block extrajavascript %}
<script>
/* script that handles add and remove lines */
$(document).ready(function() {
const totalFormsInput = $('input[name$="-TOTAL_FORMS"]');
const initialFormsInput = $('input[name$="-INITIAL_FORMS"]');
function updateTotalForms(n) {
if (totalFormsInput.length) {
totalFormsInput.val(n);
}
}
const initialCount = $('#form_body .row-formset').length;
updateTotalForms(initialCount);
const foods = {{ ingredients | safe }};
function prepopulate () {
for (var i = 0; i < {{ ingredients_count }}; i++) {
let prefix = 'id_form-' + parseInt(i) + '-';
document.getElementById(prefix + 'name').value = foods[i]['name'];
};
}
prepopulate();
$('#add_more').click(function() {
let formIdx = totalFormsInput.length ? parseInt(totalFormsInput.val(), 10) : $('#form_body .row-formset').length;
let newForm = $('#for_real').html().replace(/__prefix__/g, formIdx);
$('#form_body').append(newForm);
updateTotalForms(formIdx + 1);
});
$('#remove_one').click(function() {
let formIdx = totalFormsInput.length ? parseInt(totalFormsInput.val(), 10) : $('#form_body .row-formset').length;
if (formIdx > 1) {
$('#form_body tr.row-formset:last').remove();
updateTotalForms(formIdx - 1);
}
});
$('#recipe_form').on('submit', function() {
const totalInput = $('input[name$="-TOTAL_FORMS"]');
const prefix = totalInput.length ? totalInput.attr('name').replace(/-TOTAL_FORMS$/, '') : 'form';
$('#form_body tr.row-formset').each(function(i) {
const input = $(this).find('input,select,textarea').first();
if (input.length) {
const newName = `${prefix}-${i}-name`;
input.attr('name', newName).attr('id', `id_${newName}`).prop('disabled', false);
}
});
const visibleCount = $('#form_body tr.row-formset').length;
if (totalInput.length) totalInput.val(visibleCount);
});
});
</script>
{% endblock %}

View File

@@ -0,0 +1,32 @@
{% 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 }}
</h3>
{% render_table table %}
<div class="card-footer">
{% if can_add_recipe %}
<a class="btn btn-sm btn-success" href="{% url 'food:recipe_create' %}">{% trans "New recipe" %}</a>
{% endif %}
<a class="btn btn-sm btn-primary" href="{% url "food:food_list" %}">
{% trans "Return to the food list" %}
</a>
</div>
</div>
{% endblock %}
{% block extrajavascript %}
<script type="text/javascript">
$(".table-row").click(function () {
window.document.location = $(this).data("href");
});
</script>
{% endblock %}

View File

@@ -0,0 +1,80 @@
{% 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 }} {{ object.name }}
</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 %}
{% block extrajavascript %}
<script>
$(document).ready(function () {
function refreshIngredients() {
// 1⃣ on récupère l'id de la recette sélectionnée
let recipe_id = $("#id_recipe").val() || $("input[name='recipe']:checked").val();
if (!recipe_id) {
// 2⃣ rien sélectionné → on vide la zone d'ingrédients
$("#div_id_ingredients > div").empty().html("<em>Aucune recette sélectionnée</em>");
return;
}
// 3⃣ on interroge le serveur
$.getJSON("{% url 'food:get_ingredients' %}", { recipe_id: recipe_id })
.done(function (data) {
// 4⃣ on cible le bon conteneur
const $container = $("#div_id_ingredients > div");
$container.empty();
if (data.ingredients && data.ingredients.length > 0) {
// 5⃣ on crée les cases à cocher
data.ingredients.forEach(function (ing, i) {
const html = `
<div class="form-check">
<input type="checkbox"
name="ingredients"
value="${ing.id}"
id="id_ingredients_${i}"
class="form-check-input"
checked>
<label class="form-check-label" for="id_ingredients_${i}">
${ing.name} (${ing.qr_code_numbers})
</label>
</div>
`;
$container.append(html);
});
} else {
$container.html("<em>Aucun ingrédient trouvé</em>");
}
})
.fail(function (xhr) {
console.error("Erreur AJAX:", xhr);
$("#div_id_ingredients > div").html("<em>Erreur de chargement des ingrédients</em>");
});
}
// 6⃣ déclenche quand la recette change
$("#id_recipe, input[name='recipe']").change(refreshIngredients);
// 7⃣ initial
refreshIngredients();
});
</script>
{% endblock %}

View File

@@ -11,7 +11,7 @@ from member.models import Club
from ..api.views import AllergenViewSet, BasicFoodViewSet, TransformedFoodViewSet, QRCodeViewSet, \
DishViewSet, SupplementViewSet, OrderViewSet, FoodTransactionViewSet
from ..models import Allergen, BasicFood, TransformedFood, QRCode, Dish, Supplement, Order, FoodTransaction
from ..models import Allergen, BasicFood, TransformedFood, QRCode, Dish, Supplement, Order # TODO FoodTransaction
class TestFood(TestCase):
@@ -120,7 +120,7 @@ class TestFood(TestCase):
self.assertEqual(response.status_code, 200)
class TestFoodOrder(TestCase):
'''class TestFoodOrder(TestCase):
"""
Test Food Order
"""
@@ -337,7 +337,7 @@ class TestFoodOrder(TestCase):
self.assertEqual(response.status_code, 200)
self.assertTrue(Order.objects.filter(dish=self.dish, user=self.user, served=False).exists())
self.assertTrue(FoodTransaction.objects.filter(order=self.order, valid=False).exists())
self.assertTrue(FoodTransaction.objects.filter(order=self.order, valid=False).exists())'''
class TestFoodAPI(TestAPI):

View File

@@ -9,24 +9,30 @@ app_name = 'food'
urlpatterns = [
path('', views.FoodListView.as_view(), name='food_list'),
path('<int:slug>', views.QRCodeCreateView.as_view(), name='qrcode_create'),
path('<int:slug>/add/basic', views.BasicFoodCreateView.as_view(), name='basicfood_create'),
path('add/transformed', views.TransformedFoodCreateView.as_view(), name='transformedfood_create'),
path('update/<int:pk>', views.FoodUpdateView.as_view(), name='food_update'),
path('update/ingredients/<int:pk>', views.ManageIngredientsView.as_view(), name='manage_ingredients'),
path('detail/<int:pk>', views.FoodDetailView.as_view(), name='food_view'),
path('detail/basic/<int:pk>', views.BasicFoodDetailView.as_view(), name='basicfood_view'),
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('<int:slug>/', views.QRCodeCreateView.as_view(), name='qrcode_create'),
path('<int:slug>/add/basic/', views.BasicFoodCreateView.as_view(), name='basicfood_create'),
path('add/transformed/', views.TransformedFoodCreateView.as_view(), name='transformedfood_create'),
path('update/<int:pk>/', views.FoodUpdateView.as_view(), name='food_update'),
path('update/ingredients/<int:pk>/', views.ManageIngredientsView.as_view(), name='manage_ingredients'),
path('detail/<int:pk>/', views.FoodDetailView.as_view(), name='food_view'),
path('detail/basic/<int:pk>/', views.BasicFoodDetailView.as_view(), name='basicfood_view'),
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/<int:activity_pk>/kitchen/', views.KitchenView.as_view(), name='kitchen'),
# 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/<int:activity_pk>/kitchen/', views.KitchenView.as_view(), name='kitchen'),
# path('recipe/add/', views.RecipeCreateView.as_view(), name='recipe_create'),
# path('recipe/', views.RecipeListView.as_view(), name='recipe_list'),
# path('recipe/<int:pk>/', views.RecipeDetailView.as_view(), name='recipe_detail'),
# path('recipe/<int:pk>/update/', views.RecipeUpdateView.as_view(), name='recipe_update'),
# path('update/ingredients/<int:pk>/recipe/', views.UseRecipeView.as_view(), name='recipe_use'),
# path('ajax/get_ingredients/', views.get_ingredients_for_recipe, name='get_ingredients'),
]

View File

@@ -9,7 +9,8 @@ from django_tables2.views import SingleTableView, MultiTableMixin
from django.core.exceptions import PermissionDenied
from django.db import transaction
from django.db.models import Q, Count
from django.http import HttpResponseRedirect, Http404
from django.http import HttpResponseRedirect, Http404, JsonResponse
from django.views.decorators.http import require_GET
from django.views.generic import DetailView, UpdateView, CreateView
from django.views.generic.list import ListView
from django.views.generic.base import RedirectView
@@ -22,12 +23,13 @@ from activity.models import Activity
from permission.backends import PermissionBackend
from permission.views import ProtectQuerysetMixin, ProtectedCreateView, LoginRequiredMixin
from .models import Food, BasicFood, TransformedFood, QRCode, Order, Dish, Supplement
from .models import Food, BasicFood, TransformedFood, QRCode, Order, Dish, Supplement, Recipe
from .forms import QRCodeForms, BasicFoodForms, TransformedFoodForms, \
ManageIngredientsForm, ManageIngredientsFormSet, AddIngredientForms, \
BasicFoodUpdateForms, TransformedFoodUpdateForms, \
DishForm, SupplementFormSet, SupplementFormSetHelper, OrderForm
from .tables import FoodTable, DishTable, OrderTable
DishForm, SupplementFormSet, SupplementFormSetHelper, OrderForm, RecipeForm, \
RecipeIngredientsForm, RecipeIngredientsFormSet, UseRecipeForm
from .tables import FoodTable, DishTable, OrderTable, RecipeTable
from .utils import pretty_duration
@@ -79,11 +81,15 @@ class FoodListView(ProtectQuerysetMixin, LoginRequiredMixin, MultiTableMixin, Li
search_table = qs.filter(PermissionBackend.filter_queryset(self.request, Food, 'view'))
# table open
open_table = self.get_queryset().order_by('expiry_date').filter(
open_table = self.get_queryset().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(
@@ -118,6 +124,10 @@ class FoodListView(ProtectQuerysetMixin, LoginRequiredMixin, MultiTableMixin, Li
context['can_add_meal'] = PermissionBackend.check_perm(self.request, 'food.transformedfood_add')
context['can_add_recipe'] = PermissionBackend.check_perm(self.request, 'food.recipe_add')
context['can_view_recipes'] = PermissionBackend.check_perm(self.request, 'food.recipe_view')
context["open_activities"] = Activity.objects.filter(activity_type__name="Perm bouffe", open=True)
return context
@@ -235,6 +245,8 @@ class BasicFoodCreateView(ProtectQuerysetMixin, ProtectedCreateView):
for field in context['form'].fields:
if field == 'allergens':
context['form'].fields[field].initial = getattr(food, field).all()
elif field == 'traces':
context['form'].fields[field].initial = getattr(food, field).all()
else:
context['form'].fields[field].initial = getattr(food, field)
@@ -294,42 +306,42 @@ class ManageIngredientsView(LoginRequiredMixin, UpdateView):
def form_valid(self, form):
old_ingredients = list(self.object.ingredients.all()).copy()
old_allergens = list(self.object.allergens.all()).copy()
old_traces = list(self.object.traces.all()).copy()
self.object.ingredients.clear()
for i in range(self.object.ingredients.all().count() + 1 + MAX_FORMS):
prefix = 'form-' + str(i) + '-'
if form.data[prefix + 'qrcode'] not in ['0', '']:
ingredient = None
if form.data[prefix + 'qrcode'] not in ['0', '', 'NaN']:
ingredient = QRCode.objects.get(pk=form.data[prefix + 'qrcode']).food_container
elif form.data[prefix + 'name'] != '':
ingredient = Food.objects.get(pk=form.data[prefix + 'name'])
if form.data.get(prefix + 'add_all_same_name') == 'on':
ingredients = Food.objects.filter(name=ingredient.name, owner=ingredient.owner, end_of_life='')
else:
ingredients = [ingredient]
for ingredient in ingredients:
self.object.ingredients.add(ingredient)
if (prefix + 'fully_used') in form.data and form.data[prefix + 'fully_used'] == 'on':
ingredient.end_of_life = _('Fully used in {meal}'.format(
meal=self.object.name))
ingredient.save()
elif form.data[prefix + 'name'] != '':
ingredient = Food.objects.get(pk=form.data[prefix + 'name'])
if form.data.get(prefix + 'add_all_same_name') == 'on':
ingredients = Food.objects.filter(name=ingredient.name, owner=ingredient.owner, end_of_life='')
for ingredient in ingredients:
self.object.ingredients.add(ingredient)
if form.data.get(prefix + 'fully_used') == 'on':
ingredient.end_of_life = _('Fully used in {meal}'.format(meal=self.object.name))
ingredient.save()
else:
self.object.ingredients.add(ingredient)
if (prefix + 'fully_used') in form.data and form.data[prefix + 'fully_used'] == 'on':
ingredient.end_of_life = _('Fully used in {meal}'.format(
meal=self.object.name))
ingredient.save()
# We recalculate new expiry date and allergens
self.object.expiry_date = self.object.creation_date + self.object.shelf_life
self.object.allergens.clear()
self.object.traces.clear()
for ingredient in self.object.ingredients.iterator():
if not (ingredient.polymorphic_ctype.model == 'basicfood' and ingredient.date_type == 'DDM'):
self.object.expiry_date = min(self.object.expiry_date, ingredient.expiry_date)
self.object.allergens.set(self.object.allergens.union(ingredient.allergens.all()))
self.object.traces.set(self.object.traces.union(ingredient.traces.all()))
self.object.save(old_ingredients=old_ingredients, old_allergens=old_allergens)
self.object.save(old_ingredients=old_ingredients, old_allergens=old_allergens, old_traces=old_traces)
return HttpResponseRedirect(self.get_success_url())
def get_context_data(self, *args, **kwargs):
@@ -353,6 +365,7 @@ class ManageIngredientsView(LoginRequiredMixin, UpdateView):
'qr_number': '' if qr.count() == 0 else qr[0].qr_code_number,
'fully_used': 'true' if ingredient.end_of_life else '',
})
return context
def get_success_url(self, **kwargs):
@@ -381,13 +394,15 @@ class AddIngredientView(ProtectQuerysetMixin, LoginRequiredMixin, UpdateView):
for meal in meals:
old_ingredients = list(meal.ingredients.all()).copy()
old_allergens = list(meal.allergens.all()).copy()
old_traces = list(meal.traces.all()).copy()
meal.ingredients.add(self.object.pk)
# update allergen and expiry date if necessary
if not (self.object.polymorphic_ctype.model == 'basicfood'
and self.object.date_type == 'DDM'):
meal.expiry_date = min(meal.expiry_date, self.object.expiry_date)
meal.allergens.set(meal.allergens.union(self.object.allergens.all()))
meal.save(old_ingredients=old_ingredients, old_allergens=old_allergens)
meal.traces.set(meal.traces.union(self.object.traces.all()))
meal.save(old_ingredients=old_ingredients, old_allergens=old_allergens, old_traces=old_traces)
if 'fully_used' in form.data:
if not self.object.end_of_life:
self.object.end_of_life = _(f'Food fully used in : {meal.name}')
@@ -417,6 +432,7 @@ class FoodUpdateView(ProtectQuerysetMixin, LoginRequiredMixin, UpdateView):
form.instance.creater = self.request.user
food = Food.objects.get(pk=self.kwargs['pk'])
old_allergens = list(food.allergens.all()).copy()
old_traces = list(food.traces.all()).copy()
if food.polymorphic_ctype.model == 'transformedfood':
old_ingredients = food.ingredients.all()
@@ -430,7 +446,7 @@ class FoodUpdateView(ProtectQuerysetMixin, LoginRequiredMixin, UpdateView):
if food.polymorphic_ctype.model == 'transformedfood':
form.instance.save(old_ingredients=old_ingredients)
else:
form.instance.save(old_allergens=old_allergens)
form.instance.save(old_allergens=old_allergens, old_traces=old_traces)
return ans
def get_form_class(self, **kwargs):
@@ -463,7 +479,7 @@ class FoodDetailView(ProtectQuerysetMixin, LoginRequiredMixin, DetailView):
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
fields = ["name", "owner", "expiry_date", "allergens", "is_ready", "end_of_life", "order"]
fields = ["name", "owner", "expiry_date", "allergens", "traces", "is_ready", "end_of_life", "order"]
fields = dict([(field, getattr(self.object, field)) for field in fields])
if fields["is_ready"]:
@@ -472,6 +488,8 @@ class FoodDetailView(ProtectQuerysetMixin, LoginRequiredMixin, DetailView):
fields["is_ready"] = _("No")
fields["allergens"] = ", ".join(
allergen.name for allergen in fields["allergens"].all())
fields["traces"] = ", ".join(
trace.name for trace in fields["traces"].all())
context["fields"] = [(
Food._meta.get_field(field).verbose_name.capitalize(),
@@ -848,12 +866,12 @@ class KitchenView(ProtectQuerysetMixin, LoginRequiredMixin, SingleTableView):
extra_context = {'title': _('Kitchen')}
def get_queryset(self):
return super().get_queryset().filter(~Q(supplements__isnull=True, request=''), activity__pk=self.kwargs["activity_pk"])
return super().get_queryset().filter(~Q(supplements__isnull=True, request=''), activity__pk=self.kwargs["activity_pk"], served=False)
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
orders_count = Order.objects.values('dish__main__name').annotate(quantity=Count('id'))
orders_count = Order.objects.filter(activity__pk=self.kwargs["activity_pk"], served=False).values('dish__main__name').annotate(quantity=Count('id'))
context["orders"] = {o['dish__main__name']: o['quantity'] for o in orders_count}
@@ -867,3 +885,195 @@ class KitchenView(ProtectQuerysetMixin, LoginRequiredMixin, SingleTableView):
table.columns.hide(field)
return table
class RecipeCreateView(ProtectQuerysetMixin, ProtectedCreateView):
"""
Create a recipe
"""
model = Recipe
form_class = RecipeForm
extra_context = {"title": _("Create a recipe")}
def get_sample_object(self):
return Recipe(name='Sample recipe')
@transaction.atomic
def form_valid(self, form):
formset = RecipeIngredientsFormSet(self.request.POST)
if formset.is_valid():
ingredients = [f.cleaned_data['name'] for f in formset if f.cleaned_data.get('name')]
self.object = form.save(commit=False)
self.object.ingredients = ingredients
self.object.save()
return super().form_valid(form)
else:
return self.form_invalid(form)
def get_context_data(self, *args, **kwargs):
context = super().get_context_data(*args, **kwargs)
context['form'] = RecipeIngredientsForm()
context['recipe_form'] = self.get_form()
if self.request.POST:
context['formset'] = RecipeIngredientsFormSet(self.request.POST,)
else:
context['formset'] = RecipeIngredientsFormSet()
return context
def get_success_url(self):
return reverse_lazy('food:recipe_list')
class RecipeListView(ProtectQuerysetMixin, LoginRequiredMixin, SingleTableView):
"""
List all recipes
"""
model = Recipe
table_class = RecipeTable
extra_context = {"title": _('All recipes')}
def get_context_data(self, *args, **kwargs):
context = super().get_context_data(*args, **kwargs)
context['can_add_recipe'] = PermissionBackend.check_perm(self.request, 'food.recipe_add')
return context
class RecipeDetailView(ProtectQuerysetMixin, LoginRequiredMixin, DetailView):
"""
List all recipes
"""
model = Recipe
extra_context = {"title": _('Details of:')}
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context["ingredients"] = self.object.ingredients
context["update"] = PermissionBackend.check_perm(self.request, "food.change_recipe")
return context
class RecipeUpdateView(ProtectQuerysetMixin, LoginRequiredMixin, UpdateView):
"""
Create a recipe
"""
model = Recipe
form_class = RecipeForm
extra_context = {"title": _("Create a recipe")}
def get_sample_object(self):
return Recipe(name='Sample recipe')
@transaction.atomic
def form_valid(self, form):
formset = RecipeIngredientsFormSet(self.request.POST)
if formset.is_valid():
ingredients = [f.cleaned_data['name'] for f in formset if f.cleaned_data.get('name')]
self.object = form.save(commit=False)
self.object.ingredients = ingredients
self.object.save()
return super().form_valid(form)
else:
return self.form_invalid(form)
def get_context_data(self, *args, **kwargs):
context = super().get_context_data(*args, **kwargs)
context['form'] = RecipeIngredientsForm()
context['recipe_form'] = self.get_form()
if self.request.POST:
formset = RecipeIngredientsFormSet(self.request.POST,)
else:
formset = RecipeIngredientsFormSet()
ingredients = self.object.ingredients
context["ingredients_count"] = len(ingredients)
formset.extra += len(ingredients)
context["formset"] = formset
context["ingredients"] = []
for ingredient in ingredients:
context["ingredients"].append({"name": ingredient})
return context
def get_success_url(self):
return reverse_lazy('food:recipe_detail', kwargs={"pk": self.object.pk})
class UseRecipeView(LoginRequiredMixin, UpdateView):
"""
Add ingredients to a TransformedFood using a Recipe
"""
model = TransformedFood
fields = ('ingredients',)
template_name = 'food/use_recipe_form.html'
extra_context = {"title": _("Use a recipe for:")}
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context["form"] = UseRecipeForm()
return context
def form_valid(self, form):
old_ingredients = list(self.object.ingredients.all()).copy()
old_allergens = list(self.object.allergens.all()).copy()
old_traces = list(self.object.traces.all()).copy()
if "ingredients" in form.data:
ingredients_pk = form.data.getlist("ingredients")
ingredients = Food.objects.all().filter(pk__in=ingredients_pk)
for ingredient in ingredients:
self.object.ingredients.add(ingredient)
# We recalculate new expiry date and allergens
self.object.expiry_date = self.object.creation_date + self.object.shelf_life
self.object.allergens.clear()
self.object.traces.clear()
for ingredient in self.object.ingredients.iterator():
if not (ingredient.polymorphic_ctype.model == 'basicfood' and ingredient.date_type == 'DDM'):
self.object.expiry_date = min(self.object.expiry_date, ingredient.expiry_date)
self.object.allergens.set(self.object.allergens.union(ingredient.allergens.all()))
self.object.traces.set(self.object.traces.union(ingredient.traces.all()))
self.object.save(old_ingredients=old_ingredients, old_allergens=old_allergens, old_traces=old_traces)
return HttpResponseRedirect(self.get_success_url())
def get_success_url(self):
return reverse_lazy('food:transformedfood_view', kwargs={"pk": self.object.pk})
@require_GET
def get_ingredients_for_recipe(request):
recipe_id = request.GET.get('recipe_id')
if not recipe_id:
return JsonResponse({'error': 'Missing recipe_id'}, status=400)
try:
recipe = Recipe.objects.get(pk=recipe_id)
except Recipe.DoesNotExist:
return JsonResponse({'error': 'Recipe not found'}, status=404)
# 🔧 Supporte les deux cas : ManyToMany ou simple liste
ingredients_field = recipe.ingredients
if hasattr(ingredients_field, "values_list"):
# Cas ManyToManyField
ingredient_names = list(ingredients_field.values_list('name', flat=True))
elif isinstance(ingredients_field, (list, tuple)):
# Cas liste directe
ingredient_names = ingredients_field
else:
return JsonResponse({'error': 'Unsupported ingredients type'}, status=500)
# Union des Foods dont le nom commence par un nom dingrédient
query = Q()
for name in ingredient_names:
valid_regex = is_regex(name)
suffix = '__iregex' if valid_regex else '__istartswith'
prefix = '.*' if valid_regex else ''
query |= Q(**{f'name{suffix}': prefix + name}, end_of_life='')
qs = Food.objects.filter(query).distinct()
qs = qs.filter(PermissionBackend.filter_queryset(request, Food, 'view'))
data = [{'id': f.id, 'name': f.name, 'qr_code_numbers': ", ".join(str(q.qr_code_number) for q in f.QR_code.all())} for f in qs]
return JsonResponse({'ingredients': data})

View File

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

View File

@@ -50,6 +50,15 @@ 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):
"""

View File

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

View File

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

View File

@@ -26,24 +26,42 @@ class PermissionBackend(ModelBackend):
@staticmethod
@memoize
def get_raw_permissions(request, t):
def get_raw_permissions(request, t): # noqa: C901
"""
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
"""
if hasattr(request, 'auth') and request.auth is not None and hasattr(request.auth, 'scope'):
# Permission for auth
if hasattr(request, 'oauth2') and request.oauth2 is not None and 'scope' in request.oauth2:
# 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
@@ -77,7 +95,6 @@ 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

View File

@@ -927,7 +927,7 @@
"note",
"transactiontemplate"
],
"query": "{\"destination\": [\"club\", \"note\"]}",
"query": "[\"AND\", {\"destination\": [\"club\", \"note\"]}, {\"category__name\": \"Clubs\"}]",
"type": "view",
"mask": 2,
"field": "",
@@ -943,7 +943,7 @@
"note",
"transactiontemplate"
],
"query": "{\"destination\": [\"club\", \"note\"]}",
"query": "[\"AND\", {\"destination\": [\"club\", \"note\"]}, {\"category__name\": \"Clubs\"}]",
"type": "add",
"mask": 3,
"field": "",
@@ -959,7 +959,7 @@
"note",
"transactiontemplate"
],
"query": "{\"destination\": [\"club\", \"note\"]}",
"query": "[\"AND\", {\"destination\": [\"club\", \"note\"]}, {\"category__name\": \"Clubs\"}]",
"type": "change",
"mask": 3,
"field": "",
@@ -3484,7 +3484,23 @@
"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",
@@ -4718,6 +4734,201 @@
"description": "Voir l'adresse mail des membres de son club"
}
},
{
"model": "permission.permission",
"pk": 331,
"fields": {
"model": [
"food",
"dish"
],
"query": "{\"activity__organizer\": [\"club\"]}",
"type": "create",
"mask": 2,
"permanent": false,
"description": "Créer un plat vendu par son club"
}
},
{
"model": "permission.permission",
"pk": 332,
"fields": {
"model": [
"food",
"dish"
],
"query": "{\"activity__organizer\": [\"club\"]}",
"type": "change",
"mask": 2,
"permanent": false,
"description": "Modifier un plat vendu par son club"
}
},
{
"model": "permission.permission",
"pk": 333,
"fields": {
"model": [
"food",
"dish"
],
"query": "{\"activity__organizer\": [\"club\"]}",
"type": "view",
"mask": 2,
"permanent": false,
"description": "Voir les plats vendus par son club"
}
},
{
"model": "permission.permission",
"pk": 334,
"fields": {
"model": [
"food",
"dish"
],
"query": "[\"AND\", {\"activity__open\": true}, {\"available\": true}]",
"type": "view",
"mask": 1,
"permanent": false,
"description": "Voir les plats disponibles"
}
},
{
"model": "permission.permission",
"pk": 335,
"fields": {
"model": [
"food",
"supplement"
],
"query": "{\"dish__main__owner\": [\"club\"]}",
"type": "create",
"mask": 2,
"permanent": false,
"description": "Ajouter un supplément à un plat de son club"
}
},
{
"model": "permission.permission",
"pk": 336,
"fields": {
"model": [
"food",
"supplement"
],
"query": "{\"dish__main__owner\": [\"club\"]}",
"type": "change",
"mask": 2,
"permanent": false,
"description": "Modifier un supplément d'un plat de son club"
}
},
{
"model": "permission.permission",
"pk": 337,
"fields": {
"model": [
"food",
"supplement"
],
"query": "{\"dish__main__owner\": [\"club\"]}",
"type": "view",
"mask": 2,
"permanent": false,
"description": "Voir les suppléments des plats de son club"
}
},
{
"model": "permission.permission",
"pk": 337,
"fields": {
"model": [
"food",
"supplement"
],
"query": "[\"AND\", {\"dish__activity__open\": true}, {\"dish__available\": true}]",
"type": "view",
"mask": 1,
"permanent": false,
"description": "Voir les suppléments des plats disponibles"
}
},
{
"model": "permission.permission",
"pk": 338,
"fields": {
"model": [
"food",
"supplement"
],
"query": "{\"dish__main__owner\": [\"club\"]}",
"type": "delete",
"mask": 2,
"permanent": false,
"description": "Supprimer un supplément d'un plat de son club"
}
},
{
"model": "permission.permission",
"pk": 339,
"fields": {
"model": [
"food",
"order"
],
"query": "[\"AND\", {\"dish__activity__open\": true, \"dish__available\": true}, {\"user\": [\"user\"]}]",
"type": "create",
"mask": 1,
"permanent": false,
"description": "Commander un plat"
}
},
{
"model": "permission.permission",
"pk": 340,
"fields": {
"model": [
"food",
"order"
],
"query": "[\"AND\", {\"dish__activity__open\": true}, {\"user\": [\"user\"]}]",
"type": "view",
"mask": 1,
"permanent": false,
"description": "Voir ses commandes pour les activités ouvertes"
}
},
{
"model": "permission.permission",
"pk": 341,
"fields": {
"model": [
"food",
"order"
],
"query": "{\"activity__open\": true, \"activity__organizer\": [\"club\"]}",
"type": "view",
"mask": 2,
"permanent": false,
"description": "Voir toutes les commandes pour les activités ouvertes de son club"
}
},
{
"model": "permission.permission",
"pk": 342,
"fields": {
"model": [
"food",
"order"
],
"query": "{\"activity__open\": true, \"activity__organizer\": [\"club\"]}",
"type": "change",
"mask": 2,
"permanent": false,
"description": "Modifier un commande non servie d'une activité de son club"
}
},
{
"model": "permission.role",
"pk": 1,
@@ -4896,7 +5107,6 @@
19,
20,
21,
27,
59,
60,
61,
@@ -4907,6 +5117,7 @@
182,
184,
185,
223,
239,
240,
241
@@ -5271,6 +5482,12 @@
176,
177,
197,
211,
212,
213,
214,
215,
216,
311,
319
]

View File

@@ -10,6 +10,8 @@ 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):
"""
@@ -23,7 +25,9 @@ class PermissionScopes(BaseScopes):
if 'scopes' in kwargs:
for scope in kwargs['scopes']:
if scope == 'openid':
scopes['openid'] = "OpenID Connect"
scopes['openid'] = _("OpenID Connect (username and email)")
elif scope == '0_0':
scopes['0_0'] = _("Useless scope which do nothing")
else:
p = Permission.objects.get(id=scope.split('_')[0])
club = Club.objects.get(id=scope.split('_')[1])
@@ -32,7 +36,8 @@ 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"
scopes['openid'] = _("OpenID Connect (username and email)")
scopes['0_0'] = _("Useless scope which do nothing")
return scopes
def get_available_scopes(self, application=None, request=None, *args, **kwargs):
@@ -41,7 +46,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('openid')
scopes.append('0_0') # always available
return scopes
def get_default_scopes(self, application=None, request=None, *args, **kwargs):
@@ -49,7 +54,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.append('openid')
scopes = ['0_0'] # always default
return scopes
@@ -67,10 +72,77 @@ 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,
@@ -79,17 +151,35 @@ 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(get_current_request(), t[0]):
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)
if 'openid' in scopes:
# We grant openid scope if user is active
if 'openid' in scopes and req.user.is_active:
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

View File

@@ -21,6 +21,7 @@ class OAuth2TestCase(TestCase):
def setUp(self):
self.user = User.objects.create(
username="toto",
password="toto1234",
)
self.application = Application.objects.create(
name="Test",
@@ -92,3 +93,40 @@ 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)

View File

@@ -0,0 +1,444 @@
# 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)

View File

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

View File

@@ -308,6 +308,12 @@ 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")
@@ -332,7 +338,7 @@ class SogeCredit(models.Model):
last_name=self.user.last_name,
first_name=self.user.first_name,
bank="Société générale",
valid=False,
valid=True,
)
credit_transaction._force_save = True
credit_transaction.save()
@@ -346,12 +352,12 @@ class SogeCredit(models.Model):
return super().save(*args, **kwargs)
@property
def valid(self):
def valid_legacy(self):
return self.credit_transaction and self.credit_transaction.valid
@property
def amount(self):
if self.valid:
if self.valid_legacy:
return self.credit_transaction.total
amount = 0
transactions_wei = self.transactions.filter(membership__club__weiclub__isnull=False)
@@ -397,7 +403,7 @@ class SogeCredit(models.Model):
self.transactions.add(m.transaction)
for tr in self.transactions.all():
tr.valid = False
tr.valid = True
tr.save()
def invalidate(self):
@@ -422,6 +428,7 @@ 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: 284 KiB

After

Width:  |  Height:  |  Size: 104 KiB

View File

@@ -56,6 +56,7 @@ 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):

View File

@@ -417,7 +417,7 @@ class SogeCreditListView(LoginRequiredMixin, ProtectQuerysetMixin, SingleTableVi
)
if "valid" not in self.request.GET or not self.request.GET["valid"]:
qs = qs.filter(credit_transaction__valid=False)
qs = qs.filter(valid=False)
return qs

View File

@@ -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.assertFalse(membership.transaction.valid)
self.assertTrue(membership.transaction.valid)
# Check that if the WEI is started, we can't update a wei
self.wei.date_start = date(2000, 1, 1)

View File

@@ -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.

View File

@@ -18,11 +18,21 @@ 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.
Deux protocoles d'authentification sont implémentées :
Trois protocoles d'authentification sont implémentées :
* `CAS <cas>`_
* `OAuth2 <oauth2>`_
* Open ID Connect
À 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.
À 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...

View File

@@ -47,7 +47,6 @@ 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``,
@@ -99,7 +98,7 @@ du format renvoyé.
.. warning::
Un petit mot sur les scopes : tel qu'implémenté, une scope est une permission unitaire
Un petit mot sur les scopes : tel qu'implémenté, un 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.
@@ -113,6 +112,9 @@ 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.
@@ -134,6 +136,11 @@ 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
###################
@@ -142,6 +149,10 @@ 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
@@ -195,6 +206,20 @@ 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>`_.
@@ -223,6 +248,8 @@ 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
@@ -283,4 +310,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 pour plus d'informations.
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.

View File

@@ -4366,6 +4366,14 @@ 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."

View File

@@ -4281,6 +4281,14 @@ 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

View File

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

View File

@@ -0,0 +1,50 @@
<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="&lt;Group&gt;">
<g id="_Group_2" data-name="&lt;Group&gt;">
<g id="_Group_3" data-name="&lt;Group&gt;">
<path id="_Path_" data-name="&lt;Path&gt;" 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="&lt;Path&gt;" 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="&lt;Group&gt;">
<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>

After

Width:  |  Height:  |  Size: 12 KiB

View File

@@ -0,0 +1,57 @@
<?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>

After

Width:  |  Height:  |  Size: 8.2 KiB

View File

@@ -0,0 +1,63 @@
<?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>

After

Width:  |  Height:  |  Size: 5.8 KiB

View File

@@ -0,0 +1,66 @@
<?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>

After

Width:  |  Height:  |  Size: 6.4 KiB

View File

@@ -39,6 +39,23 @@ 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 %}

View File

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