1
0
mirror of https://gitlab.crans.org/bde/nk20 synced 2025-11-16 03:27:43 +01:00

Compare commits

...

50 Commits

Author SHA1 Message Date
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
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
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
ehouarn
d4cb464169 Merge branch 'small_features' into 'main'
Export activity guests

See merge request bde/nk20!353
2025-09-28 21:34:21 +02:00
Ehouarn
27a1f36183 Export club members 2025-09-28 21:15:01 +02:00
Ehouarn
83c8b9a3d0 Export activity guests 2025-09-28 21:12:48 +02:00
ehouarn
cb3b34f874 Merge branch 'small_features' into 'main'
Small features

See merge request bde/nk20!352
2025-09-27 13:39:34 +02:00
Ehouarn
0962a3735e Better Food search 2025-09-27 13:19:48 +02:00
Ehouarn
9907cfbd86 Autocomplete Credit reason with 'Rechargement note' 2025-09-27 01:17:33 +02:00
Ehouarn
ad90887691 Search activities 2025-09-26 22:58:30 +02:00
Ehouarn
47d2476b51 Allow to view activity entries on Activity tab 2025-09-25 00:08:56 +02:00
Ehouarn
5d8720cf46 Phone input without permission fixed 2025-09-24 22:22:23 +02:00
Ehouarn
8700144dea Permissions 2025-09-24 21:48:56 +02:00
ehouarn
d17ab26f2f Merge branch 'phone_input' into 'main'
Phone input

See merge request bde/nk20!351
2025-09-03 18:40:26 +02:00
ehouarn
297f289d7e Merge branch 'wei' into 'main'
New informative questions

See merge request bde/nk20!350
2025-08-31 22:25:57 +02:00
Ehouarn
034ad9a4ce tests 2025-08-31 22:04:45 +02:00
Ehouarn
897d37f74d New informative questions 2025-08-31 21:45:09 +02:00
sable
42fb0aa2d6 Merge branch 'translations' into 'main'
minor translate

See merge request bde/nk20!349
2025-08-31 13:36:58 +02:00
sable
4bc43ec3cb minor translate 2025-08-31 13:19:27 +02:00
ehouarn
00737da69f Merge branch 'family' into 'main'
minor fixe

See merge request bde/nk20!348
2025-08-31 13:02:29 +02:00
sable
6eb192b823 minor fixe 2025-08-31 12:43:37 +02:00
Ehouarn
0934b8fa34 Patch 2025-08-30 16:15:55 +02:00
Ehouarn
7633c9ab4b Better phone input (no invalid number) 2025-08-29 18:36:18 +02:00
48 changed files with 1727 additions and 291 deletions

View File

@@ -37,6 +37,11 @@ SPDX-License-Identifier: GPL-3.0-or-later
<div id="guests_table"> <div id="guests_table">
{% render_table guests %} {% render_table guests %}
</div> </div>
<div class="card-footer text-center">
<button class="btn btn-block btn-primary mb-3" onclick="window.location.href='?_export=1&table=guests'">
{% trans "Export to CSV" %}
</button>
</div>
</div> </div>
{% endif %} {% endif %}
{% endblock %} {% endblock %}

View File

@@ -1,4 +1,4 @@
{% extends "base.html" %} {% extends "base_search.html" %}
{% comment %} {% comment %}
SPDX-License-Identifier: GPL-3.0-or-later SPDX-License-Identifier: GPL-3.0-or-later
{% endcomment %} {% endcomment %}
@@ -44,6 +44,8 @@ SPDX-License-Identifier: GPL-3.0-or-later
<h3 class="card-header text-center"> <h3 class="card-header text-center">
{% trans "All activities" %} {% trans "All activities" %}
</h3> </h3>
{% render_table table %} {% render_table all %}
</div> </div>
{{ block.super }}
{% endblock %} {% endblock %}

View File

@@ -1,7 +1,7 @@
{% comment %} {% comment %}
SPDX-License-Identifier: GPL-3.0-or-later SPDX-License-Identifier: GPL-3.0-or-later
{% endcomment %} {% endcomment %}
{% load i18n perms pretty_money %} {% load i18n perms pretty_money dict_get %}
{% url 'activity:activity_detail' activity.pk as activity_detail_url %} {% url 'activity:activity_detail' activity.pk as activity_detail_url %}
<div id="activity_info" class="card bg-light shadow mb-3"> <div id="activity_info" class="card bg-light shadow mb-3">
@@ -53,6 +53,12 @@ SPDX-License-Identifier: GPL-3.0-or-later
<dt class="col-xl-6">{% trans 'opened'|capfirst %}</dt> <dt class="col-xl-6">{% trans 'opened'|capfirst %}</dt>
<dd class="col-xl-6">{{ activity.open|yesno }}</dd> <dd class="col-xl-6">{{ activity.open|yesno }}</dd>
</dl> </dl>
{% if show_entries|dict_get:activity %}
<h2 class="text-center">
{{ entries_count|dict_get:activity }}
{% if entries_count|dict_get:activity >= 2 %}{% trans "entries" %}{% else %}{% trans "entry" %}{% endif %}
</h2>
{% endif %}
</div> </div>
<div class="card-footer text-center"> <div class="card-footer text-center">

View File

View File

@@ -0,0 +1,12 @@
# Copyright (C) 2018-2025 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later
from django import template
def dict_get(d, key):
return d.get(key)
register = template.Library()
register.filter('dict_get', dict_get)

View File

@@ -67,32 +67,65 @@ class ActivityListView(ProtectQuerysetMixin, LoginRequiredMixin, MultiTableMixin
tables = [ tables = [
lambda data: ActivityTable(data, prefix="all-"), lambda data: ActivityTable(data, prefix="all-"),
lambda data: ActivityTable(data, prefix="upcoming-"), lambda data: ActivityTable(data, prefix="upcoming-"),
lambda data: ActivityTable(data, prefix="search-"),
] ]
extra_context = {"title": _("Activities")} extra_context = {"title": _("Activities")}
def get_queryset(self, **kwargs): def get_queryset(self, **kwargs):
return super().get_queryset(**kwargs).distinct() """
Filter the user list with the given pattern.
"""
return super().get_queryset().distinct()
def get_tables_data(self): def get_tables_data(self):
# first table = all activities, second table = upcoming # first table = all activities, second table = upcoming, third table = search
# table search
qs = self.get_queryset().order_by('-date_start')
if "search" in self.request.GET and self.request.GET['search']:
pattern = self.request.GET['search']
# check regex
valid_regex = is_regex(pattern)
suffix = '__iregex' if valid_regex else '__istartswith'
prefix = '^' if valid_regex else ''
qs = qs.filter(Q(**{f'name{suffix}': prefix + pattern})
| Q(**{f'organizer__name{suffix}': prefix + pattern})
| Q(**{f'organizer__note__alias__name{suffix}': prefix + pattern}))
else:
qs = qs.none()
search_table = qs.filter(PermissionBackend.filter_queryset(self.request, Activity, 'view'))
return [ return [
self.get_queryset().order_by("-date_start"), self.get_queryset().order_by("-date_start"),
Activity.objects.filter(date_end__gt=timezone.now()) Activity.objects.filter(date_end__gt=timezone.now())
.filter(PermissionBackend.filter_queryset(self.request, Activity, "view")) .filter(PermissionBackend.filter_queryset(self.request, Activity, "view"))
.distinct() .distinct()
.order_by("date_start") .order_by("date_start"),
search_table,
] ]
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs) context = super().get_context_data(**kwargs)
tables = context["tables"] tables = context["tables"]
for name, table in zip(["table", "upcoming"], tables): for name, table in zip(["all", "upcoming", "table"], tables):
context[name] = table context[name] = table
started_activities = self.get_queryset().filter(open=True, valid=True).distinct().all() started_activities = self.get_queryset().filter(open=True, valid=True).distinct().all()
context["started_activities"] = started_activities context["started_activities"] = started_activities
entries_count = {}
show_entries = {}
for activity in started_activities:
if activity.activity_type.manage_entries:
entries = Entry.objects.filter(activity=activity)
entries_count[activity] = entries.count()
show_entries[activity] = True
context["entries_count"] = entries_count
context["show_entries"] = show_entries
return context return context
@@ -103,20 +136,74 @@ class ActivityDetailView(ProtectQuerysetMixin, LoginRequiredMixin, MultiTableMix
model = Activity model = Activity
context_object_name = "activity" context_object_name = "activity"
extra_context = {"title": _("Activity detail")} extra_context = {"title": _("Activity detail")}
export_formats = ["csv"]
tables = [ tables = [
lambda data: GuestTable(data, prefix="guests-"), GuestTable,
lambda data: OpenerTable(data, prefix="opener-"), OpenerTable,
] ]
def get_tables(self):
tables = super().get_tables()
tables[0].prefix = "guests"
tables[1].prefix = "opener"
return tables
def get_tables_data(self): def get_tables_data(self):
return [ return [
Guest.objects.filter(activity=self.object) 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) 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):
"""
Gère l'export CSV manuel pour MultiTableMixin.
"""
if "_export" in self.request.GET:
import tablib
table_name = self.request.GET.get("table")
if table_name:
tables = self.get_tables()
data_list = self.get_tables_data()
for t, d in zip(tables, data_list):
if t.prefix == table_name:
# Préparer le CSV
dataset = tablib.Dataset()
columns = list(t.base_columns) # noms des colonnes
dataset.headers = columns
for row in d:
values = []
for col in columns:
try:
val = getattr(row, col, "")
# Gestion spéciale pour la colonne 'entry'
if col == "entry":
if getattr(row, "has_entry", False):
val = timezone.localtime(row.entry.time).strftime("%Y-%m-%d %H:%M:%S")
else:
val = ""
values.append(str(val) if val is not None else "")
except Exception: # RelatedObjectDoesNotExist ou autre
values.append("")
dataset.append(values)
csv_bytes = dataset.export("csv")
if isinstance(csv_bytes, str):
csv_bytes = csv_bytes.encode("utf-8")
response = HttpResponse(csv_bytes, content_type="text/csv")
response["Content-Disposition"] = f'attachment; filename="{table_name}.csv"'
return response
# Sinon rendu normal
return super().render_to_response(context, **response_kwargs)
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
context = super().get_context_data() context = super().get_context_data()
@@ -137,6 +224,14 @@ class ActivityDetailView(ProtectQuerysetMixin, LoginRequiredMixin, MultiTableMix
"placeholder": "" "placeholder": ""
} }
} }
if self.object.activity_type.manage_entries:
entries = Entry.objects.filter(activity=self.object)
context["entries_count"] = {self.object: entries.count()}
context["show_entries"] = {self.object: timezone.now() > timezone.localtime(self.object.date_start)}
else:
context["entries_count"] = {self.object: 0}
context["show_entries"] = {self.object: False}
return context return context
@@ -216,7 +311,7 @@ class ActivityInviteView(ProtectQuerysetMixin, ProtectedCreateView):
@transaction.atomic @transaction.atomic
def form_valid(self, form): def form_valid(self, form):
form.instance.activity = Activity.objects\ 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) return super().form_valid(form)
def get_success_url(self, **kwargs): def get_success_url(self, **kwargs):

View File

@@ -2,6 +2,7 @@
# SPDX-License-Identifier: GPL-3.0-or-later # SPDX-License-Identifier: GPL-3.0-or-later
import django_tables2 as tables import django_tables2 as tables
from django.utils.translation import gettext_lazy as _
from .models import Food from .models import Food
@@ -10,10 +11,25 @@ class FoodTable(tables.Table):
""" """
List all foods. List all foods.
""" """
qr_code_numbers = tables.Column(empty_values=(), verbose_name=_("QR Codes"), orderable=False)
date = tables.Column(empty_values=(), verbose_name=_("Arrival/creation date"), orderable=False)
def render_date(self, record):
if record.__class__.__name__ == "BasicFood":
return record.arrival_date.strftime("%d/%m/%Y %H:%M")
elif record.__class__.__name__ == "TransformedFood":
return record.creation_date.strftime("%d/%m/%Y %H:%M")
else:
return "--"
def render_qr_code_numbers(self, record):
return ", ".join(str(q.qr_code_number) for q in record.QR_code.all())
class Meta: class Meta:
model = Food model = Food
template_name = 'django_tables2/bootstrap4.html' template_name = 'django_tables2/bootstrap4.html'
fields = ('name', 'owner', 'allergens', 'expiry_date') fields = ('name', 'owner', 'qr_code_numbers', 'allergens', 'date', 'expiry_date')
row_attrs = { row_attrs = {
'class': 'table-row', 'class': 'table-row',
'data-href': lambda record: 'detail/' + str(record.pk), 'data-href': lambda record: 'detail/' + str(record.pk),

View File

@@ -34,6 +34,12 @@ SPDX-License-Identifier: GPL-3.0-or-later
</div> </div>
</div> </div>
<div class="card-body"> <div class="card-body">
<div class="form-check">
<label for="stock_only" class="form-check-label">
<input id="stock_only" name="stock_only" type="checkbox" class="checkboxinput form-check-input" checked>
{% trans "Filter with only food in stock" %}
</label>
</div>
<input id="searchbar" type="text" class="form-control" <input id="searchbar" type="text" class="form-control"
placeholder="{% trans "Search by attribute such as name..." %}"> placeholder="{% trans "Search by attribute such as name..." %}">
</div> </div>
@@ -114,7 +120,26 @@ SPDX-License-Identifier: GPL-3.0-or-later
{% endif %} {% endif %}
</div> </div>
<script type="text/javascript">
let old_pattern = null;
let searchbar_obj = $("#searchbar");
let stock_only_obj = $("#stock_only");
function reloadTable() {
let pattern = searchbar_obj.val();
$("#dynamic-table").load(location.pathname + "?search=" + pattern.replace(" ", "%20") + (
stock_only_obj.is(':checked') ? "" : "&stock=1") + " #dynamic-table");
}
searchbar_obj.keyup(reloadTable);
stock_only_obj.change(reloadTable);
$(document).on("click", ".table-row", function () {
window.document.location = $(this).data("href");
});
</script>
<script> <script>
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function() {
document.getElementById('goButton').addEventListener('click', function(event) { document.getElementById('goButton').addEventListener('click', function(event) {

View File

@@ -65,16 +65,24 @@ class FoodListView(ProtectQuerysetMixin, LoginRequiredMixin, MultiTableMixin, Li
suffix = '__iregex' if valid_regex else '__istartswith' suffix = '__iregex' if valid_regex else '__istartswith'
prefix = '^' if valid_regex else '' prefix = '^' if valid_regex else ''
qs = qs.filter(Q(**{f'name{suffix}': prefix + pattern}) qs = qs.filter(Q(**{f'name{suffix}': prefix + pattern})
| Q(**{f'owner__name{suffix}': prefix + pattern})) | Q(**{f'owner__name{suffix}': prefix + pattern})
| Q(**{f'owner__note__alias__name{suffix}': prefix + pattern}))
else: else:
qs = qs.none() qs = qs.none()
if "stock" not in self.request.GET or not self.request.GET["stock"] == '1':
qs = qs.filter(end_of_life='')
search_table = qs.filter(PermissionBackend.filter_queryset(self.request, Food, 'view')) search_table = qs.filter(PermissionBackend.filter_queryset(self.request, Food, 'view'))
# table open # 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='transformedfood')
| Q(polymorphic_ctype__model='basicfood', basicfood__date_type='DLC')).filter( | Q(polymorphic_ctype__model='basicfood', basicfood__date_type='DLC')).filter(
expiry_date__lt=timezone.now(), end_of_life='').filter( expiry_date__lt=timezone.now(), end_of_life='').filter(
PermissionBackend.filter_queryset(self.request, Food, 'view')) 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 # table served
served_table = self.get_queryset().order_by('-pk').filter( served_table = self.get_queryset().order_by('-pk').filter(
end_of_life='', is_ready=True).exclude( end_of_life='', is_ready=True).exclude(
@@ -95,6 +103,7 @@ class FoodListView(ProtectQuerysetMixin, LoginRequiredMixin, MultiTableMixin, Li
owner=club, end_of_life='').filter( owner=club, end_of_life='').filter(
PermissionBackend.filter_queryset(self.request, Food, 'view') PermissionBackend.filter_queryset(self.request, Food, 'view')
)) ))
return [search_table, open_table, served_table] + club_table return [search_table, open_table, served_table] + club_table
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
@@ -218,7 +227,7 @@ class BasicFoodCreateView(ProtectQuerysetMixin, ProtectedCreateView):
copy = self.request.GET.get('copy', None) copy = self.request.GET.get('copy', None)
if copy is not None: if copy is not None:
food = BasicFood.objects.get(pk=copy) food = BasicFood.objects.get(pk=copy)
print(context['form'].fields)
for field in context['form'].fields: for field in context['form'].fields:
if field == 'allergens': if field == 'allergens':
context['form'].fields[field].initial = getattr(food, field).all() context['form'].fields[field].initial = getattr(food, field).all()

View File

@@ -10,6 +10,7 @@ from django.contrib.auth.forms import AuthenticationForm
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.db import transaction from django.db import transaction
from django.forms import CheckboxSelectMultiple from django.forms import CheckboxSelectMultiple
from phonenumber_field.formfields import PhoneNumberField
from django.utils import timezone from django.utils import timezone
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from note.models import NoteSpecial, Alias from note.models import NoteSpecial, Alias
@@ -45,6 +46,11 @@ class ProfileForm(forms.ModelForm):
A form for the extras field provided by the :model:`member.Profile` model. A form for the extras field provided by the :model:`member.Profile` model.
""" """
# Remove widget=forms.HiddenInput() if you want to use report frequency. # Remove widget=forms.HiddenInput() if you want to use report frequency.
phone_number = PhoneNumberField(
widget=forms.TextInput(attrs={"type": "tel", "class": "form-control"}),
required=False
)
report_frequency = forms.IntegerField(required=False, initial=0, label=_("Report frequency")) report_frequency = forms.IntegerField(required=False, initial=0, label=_("Report frequency"))
last_report = forms.DateTimeField(required=False, disabled=True, label=_("Last report date")) last_report = forms.DateTimeField(required=False, disabled=True, label=_("Last report date"))
@@ -72,7 +78,12 @@ class ProfileForm(forms.ModelForm):
if not self.instance.section or (("department" in self.changed_data if not self.instance.section or (("department" in self.changed_data
or "promotion" in self.changed_data) and "section" not in self.changed_data): or "promotion" in self.changed_data) and "section" not in self.changed_data):
self.instance.section = self.instance.section_generated self.instance.section = self.instance.section_generated
return super().save(commit) instance = super().save(commit=False)
if instance.phone_number:
instance.phone_number = instance.phone_number.as_e164
if commit:
instance.save()
return instance
class Meta: class Meta:
model = Profile model = Profile

View File

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

View File

@@ -92,6 +92,20 @@ class MembershipTable(tables.Table):
} }
) )
user_email = tables.Column(
verbose_name="Email",
accessor="user.email",
orderable=False,
visible=False,
)
user_full_name = tables.Column(
verbose_name=_("Full name"),
accessor="user.get_full_name",
orderable=False,
visible=False,
)
def render_user(self, value): def render_user(self, value):
# If the user has the right, link the displayed user with the page of its detail. # If the user has the right, link the displayed user with the page of its detail.
s = value.username s = value.username
@@ -149,6 +163,16 @@ class MembershipTable(tables.Table):
+ "'>" + s + "</a>") + "'>" + s + "</a>")
return s return s
def value_user(self, record):
return record.user.username if record.user else ""
def value_club(self, record):
return record.club.name if record.club else ""
def value_roles(self, record):
roles = record.roles.all()
return ", ".join(str(role) for role in roles)
class Meta: class Meta:
attrs = { attrs = {
'class': 'table table-condensed table-striped', 'class': 'table table-condensed table-striped',

View File

@@ -36,7 +36,13 @@ SPDX-License-Identifier: GPL-3.0-or-later
{% trans "There is no membership found with this pattern." %} {% trans "There is no membership found with this pattern." %}
</div> </div>
{% endif %} {% endif %}
<div class="card-footer text-center">
<button class="btn btn-block btn-primary mb-3" onclick="window.location.href='?_export=csv'">
{% trans "Export to CSV" %}
</button>
</div>
</div> </div>
</div> </div>
{% endblock %} {% endblock %}

View File

@@ -11,9 +11,8 @@
<dt class="col-xl-6">{% trans 'family'|capfirst %}</dt> <dt class="col-xl-6">{% trans 'family'|capfirst %}</dt>
<dd class="col-xl-6"> <dd class="col-xl-6">
{% if families %} {% if families %}
test
{% for fam in families %} {% for fam in families %}
<a href="{% url 'family:family_detail' fam.pk %}">asfafs{{ fam.name }}</a>{% if not forloop.last %}, {% endif %} <a href="{% url 'family:family_detail' fam.pk %}">{{ fam.name }}</a>{% if not forloop.last %}, {% endif %}
{% endfor %} {% endfor %}
{% else %} {% else %}
<span class="text-muted">Aucune</span> <span class="text-muted">Aucune</span>

View File

@@ -10,7 +10,7 @@ SPDX-License-Identifier: GPL-3.0-or-later
{{ title }} {{ title }}
</h3> </h3>
<div class="card-body"> <div class="card-body">
<form method="post"> <form method="post" id="profile-form">
{% csrf_token %} {% csrf_token %}
{{ form | crispy }} {{ form | crispy }}
{{ profile_form | crispy }} {{ profile_form | crispy }}
@@ -20,4 +20,46 @@ SPDX-License-Identifier: GPL-3.0-or-later
</form> </form>
</div> </div>
</div> </div>
{% endblock %}
{% block extrajavascript %}
<!-- intl-tel-input CSS/JS -->
<script>
(() => {
const input = document.querySelector("input[name='phone_number']");
const form = document.querySelector("#profile-form");
if (!input || !form || input.type === "hidden" || input.disabled || input.readOnly) {
return;
}
const iti = window.intlTelInput(input, {
initialCountry: "auto",
nationalMode: false,
autoPlaceholder: "off",
geoIpLookup: callback => {
fetch("https://ipapi.co/json")
.then(res => res.json())
.then(data => callback(data.country_code))
.catch(() => callback("fr"));
},
loadUtils: () => import("https://cdn.jsdelivr.net/npm/intl-tel-input@25.5.2/build/js/utils.js"),
});
form.addEventListener("submit", function(e){
if (!input.value.trim()) {
return;
}
const number = iti.getNumber(intlTelInput.utils.numberFormat.E164);
if (number) {
input.value = number;
form.submit();
} else {
e.preventDefault();
input.focus();
}
});
})();
</script>
{% endblock %} {% endblock %}

View File

@@ -17,6 +17,7 @@ from django.utils.translation import gettext_lazy as _
from django.views.generic import DetailView, UpdateView, TemplateView from django.views.generic import DetailView, UpdateView, TemplateView
from django.views.generic.edit import FormMixin from django.views.generic.edit import FormMixin
from django_tables2.views import MultiTableMixin, SingleTableMixin, SingleTableView from django_tables2.views import MultiTableMixin, SingleTableMixin, SingleTableView
from django_tables2.export.views import ExportMixin
from rest_framework.authtoken.models import Token from rest_framework.authtoken.models import Token
from api.viewsets import is_regex from api.viewsets import is_regex
from note.models import Alias, NoteClub, NoteUser, Trust from note.models import Alias, NoteClub, NoteUser, Trust
@@ -49,6 +50,15 @@ class CustomLoginView(LoginView):
self.request.session['permission_mask'] = form.cleaned_data['permission_mask'].rank self.request.session['permission_mask'] = form.cleaned_data['permission_mask'].rank
return super().form_valid(form) 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): class UserUpdateView(ProtectQuerysetMixin, LoginRequiredMixin, UpdateView):
""" """
@@ -950,11 +960,12 @@ class ClubManageRolesView(ProtectQuerysetMixin, LoginRequiredMixin, UpdateView):
return reverse_lazy('member:user_detail', kwargs={'pk': self.object.user.id}) return reverse_lazy('member:user_detail', kwargs={'pk': self.object.user.id})
class ClubMembersListView(ProtectQuerysetMixin, LoginRequiredMixin, SingleTableView): class ClubMembersListView(ExportMixin, ProtectQuerysetMixin, LoginRequiredMixin, SingleTableView):
model = Membership model = Membership
table_class = MembershipTable table_class = MembershipTable
template_name = "member/club_members.html" template_name = "member/club_members.html"
extra_context = {"title": _("Members of the club")} extra_context = {"title": _("Members of the club")}
export_formats = ["csv"]
def get_queryset(self, **kwargs): def get_queryset(self, **kwargs):
qs = super().get_queryset().filter(club_id=self.kwargs["pk"]) qs = super().get_queryset().filter(club_id=self.kwargs["pk"])
@@ -986,6 +997,14 @@ class ClubMembersListView(ProtectQuerysetMixin, LoginRequiredMixin, SingleTableV
return qs.distinct() return qs.distinct()
def get_export_filename(self, export_format):
return "members.csv"
def get_export_content_type(self, export_format):
if export_format == "csv":
return "text/csv"
return super().get_export_content_type(export_format)
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs) context = super().get_context_data(**kwargs)
club = Club.objects.filter( club = Club.objects.filter(

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, ' + addMsg(interpolate(gettext('Warning, the transaction from the note %s succeed, ' +
'but the emitter note %s is negative.'), [source_alias, source_alias]), 'warning', 30000) '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]), addMsg(interpolate(gettext('Warning, the emitter note %s is no more a BDE member.'), [source_alias]),
'danger', 30000) 'danger', 30000)
} }

View File

@@ -66,6 +66,8 @@ $(document).ready(function () {
arr.push(last) arr.push(last)
last.quantity = 1 last.quantity = 1
if (last.note.club) { if (last.note.club) {
$('#last_name').val(last.note.name) $('#last_name').val(last.note.name)
@@ -111,7 +113,8 @@ $(document).ready(function () {
dest.removeClass('d-none') dest.removeClass('d-none')
$('#dest_note_list').removeClass('d-none') $('#dest_note_list').removeClass('d-none')
$('#debit_type').addClass('d-none') $('#debit_type').addClass('d-none')
$('#reason').val('')
$('#source_note_label').text(select_emitters_label) $('#source_note_label').text(select_emitters_label)
$('#dest_note_label').text(select_receveirs_label) $('#dest_note_label').text(select_receveirs_label)
@@ -134,6 +137,7 @@ $(document).ready(function () {
dest.val('') dest.val('')
dest.tooltip('hide') dest.tooltip('hide')
$('#debit_type').addClass('d-none') $('#debit_type').addClass('d-none')
$('#reason').val('Rechargement note')
$('#source_note_label').text(transfer_type_label) $('#source_note_label').text(transfer_type_label)
$('#dest_note_label').text(select_receveir_label) $('#dest_note_label').text(select_receveir_label)
@@ -162,6 +166,7 @@ $(document).ready(function () {
dest.addClass('d-none') dest.addClass('d-none')
dest.tooltip('hide') dest.tooltip('hide')
$('#debit_type').removeClass('d-none') $('#debit_type').removeClass('d-none')
$('#reason').val('')
$('#source_note_label').text(select_emitter_label) $('#source_note_label').text(select_emitter_label)
$('#dest_note_label').text(transfer_type_label) $('#dest_note_label').text(transfer_type_label)
@@ -305,10 +310,10 @@ $('#btn_transfer').click(function () {
destination: dest.note.id, destination: dest.note.id,
destination_alias: dest.name destination_alias: dest.name
}).done(function () { }).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) 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) addMsg(interpolate(gettext('Warning, the destination note %s is no more a BDE member.'), [dest.name]), 'danger', 30000)
} }
@@ -409,7 +414,7 @@ $('#btn_transfer').click(function () {
bank: $('#bank').val() bank: $('#bank').val()
}).done(function () { }).done(function () {
addMsg(gettext('Credit/debit succeed!'), 'success', 10000) 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() reset()
}).fail(function (err) { }).fail(function (err) {
const errObj = JSON.parse(err.responseText) const errObj = JSON.parse(err.responseText)

View File

@@ -26,24 +26,42 @@ class PermissionBackend(ModelBackend):
@staticmethod @staticmethod
@memoize @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. Query permissions of a certain type for a user, then memoize it.
:param request: The current request :param request: The current request
:param t: The type of the permissions: view, change, add or delete :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 :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 # 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 user = request.auth.user
def permission_filter(membership_obj): def permission_filter(membership_obj):
query = Q(pk=-1) query = Q(pk=-1)
for scope in request.auth.scope.split(' '): for scope in request.auth.scope.split(' '):
if scope == "openid" or scope == "0_0":
continue
permission_id, club_id = scope.split('_') permission_id, club_id = scope.split('_')
if int(club_id) == membership_obj.club_id: if int(club_id) == membership_obj.club_id:
query |= Q(pk=permission_id) query |= Q(pk=permission_id)
return query return query
else: else:
user = request.user user = request.user
@@ -77,7 +95,6 @@ class PermissionBackend(ModelBackend):
:param type: The type of the permissions: view, change, add or delete :param type: The type of the permissions: view, change, add or delete
:return: A generator of the requested permissions :return: A generator of the requested permissions
""" """
if hasattr(request, 'auth') and request.auth is not None and hasattr(request.auth, 'scope'): if hasattr(request, 'auth') and request.auth is not None and hasattr(request.auth, 'scope'):
# OAuth2 Authentication # OAuth2 Authentication
user = request.auth.user user = request.auth.user

View File

@@ -927,7 +927,7 @@
"note", "note",
"transactiontemplate" "transactiontemplate"
], ],
"query": "{\"destination\": [\"club\", \"note\"]}", "query": "[\"AND\", {\"destination\": [\"club\", \"note\"]}, {\"category__name\": \"Clubs\"}]",
"type": "view", "type": "view",
"mask": 2, "mask": 2,
"field": "", "field": "",
@@ -943,7 +943,7 @@
"note", "note",
"transactiontemplate" "transactiontemplate"
], ],
"query": "{\"destination\": [\"club\", \"note\"]}", "query": "[\"AND\", {\"destination\": [\"club\", \"note\"]}, {\"category__name\": \"Clubs\"}]",
"type": "add", "type": "add",
"mask": 3, "mask": 3,
"field": "", "field": "",
@@ -959,7 +959,7 @@
"note", "note",
"transactiontemplate" "transactiontemplate"
], ],
"query": "{\"destination\": [\"club\", \"note\"]}", "query": "[\"AND\", {\"destination\": [\"club\", \"note\"]}, {\"category__name\": \"Clubs\"}]",
"type": "change", "type": "change",
"mask": 3, "mask": 3,
"field": "", "field": "",
@@ -3484,7 +3484,23 @@
"mask": 1, "mask": 1,
"permanent": false, "permanent": false,
"description": "Voir la bouffe servie" "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", "model": "permission.permission",
@@ -4430,6 +4446,22 @@
"description": "Modifier le type de caution de mon inscription WEI tant qu'elle n'est pas validée" "description": "Modifier le type de caution de mon inscription WEI tant qu'elle n'est pas validée"
} }
}, },
{
"model": "permission.permission",
"pk": 298,
"fields": {
"model": [
"wei",
"bus"
],
"query": "{\"pk\": [\"membership\", \"weimembership\", \"bus\", \"pk\"], \"wei__date_end__gte\": [\"today\"]}",
"type": "change",
"mask": 2,
"field": "information_json",
"permanent": false,
"description": "Modifier les informations du bus"
}
},
{ {
"model": "permission.permission", "model": "permission.permission",
"pk": 311, "pk": 311,
@@ -4686,6 +4718,22 @@
"description": "Supprimer un succès" "description": "Supprimer un succès"
} }
}, },
{
"model": "permission.permission",
"pk": 330,
"fields": {
"model": [
"auth",
"user"
],
"query": "{\"memberships__club\": [\"club\"]}",
"type": "view",
"mask": 2,
"field": "email",
"permanent": false,
"description": "Voir l'adresse mail des membres de son club"
}
},
{ {
"model": "permission.role", "model": "permission.role",
"pk": 1, "pk": 1,
@@ -4833,7 +4881,11 @@
221, 221,
247, 247,
258, 258,
259 259,
260,
263,
265,
330
] ]
} }
}, },
@@ -4845,7 +4897,6 @@
"name": "Pr\u00e9sident\u22c5e de club", "name": "Pr\u00e9sident\u22c5e de club",
"permissions": [ "permissions": [
62, 62,
135,
142 142
] ]
} }
@@ -4861,7 +4912,6 @@
19, 19,
20, 20,
21, 21,
27,
59, 59,
60, 60,
61, 61,
@@ -4872,6 +4922,7 @@
182, 182,
184, 184,
185, 185,
223,
239, 239,
240, 240,
241 241
@@ -5122,7 +5173,8 @@
289, 289,
290, 290,
291, 291,
293 293,
298
] ]
} }
}, },
@@ -5182,6 +5234,7 @@
"permissions": [ "permissions": [
37, 37,
41, 41,
42,
53, 53,
54, 54,
55, 55,
@@ -5233,7 +5286,15 @@
168, 168,
176, 176,
177, 177,
197 197,
211,
212,
213,
214,
215,
216,
311,
319
] ]
} }
}, },
@@ -5313,7 +5374,8 @@
289, 289,
290, 290,
291, 291,
293 293,
298
] ]
} }
}, },

View File

@@ -10,6 +10,8 @@ from note_kfet.middlewares import get_current_request
from .backends import PermissionBackend from .backends import PermissionBackend
from .models import Permission from .models import Permission
from django.utils.translation import gettext_lazy as _
class PermissionScopes(BaseScopes): class PermissionScopes(BaseScopes):
""" """
@@ -23,7 +25,9 @@ class PermissionScopes(BaseScopes):
if 'scopes' in kwargs: if 'scopes' in kwargs:
for scope in kwargs['scopes']: for scope in kwargs['scopes']:
if scope == 'openid': 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: else:
p = Permission.objects.get(id=scope.split('_')[0]) p = Permission.objects.get(id=scope.split('_')[0])
club = Club.objects.get(id=scope.split('_')[1]) 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})" scopes = {f"{p.id}_{club.id}": f"{p.description} (club {club.name})"
for p in Permission.objects.all() for club in Club.objects.all()} 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 return scopes
def get_available_scopes(self, application=None, request=None, *args, **kwargs): 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}" scopes = [f"{p.id}_{p.membership.club.id}"
for t in Permission.PERMISSION_TYPES 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(get_current_request(), t[0])]
scopes.append('openid') scopes.append('0_0') # always available
return scopes return scopes
def get_default_scopes(self, application=None, request=None, *args, **kwargs): def get_default_scopes(self, application=None, request=None, *args, **kwargs):
@@ -49,7 +54,7 @@ class PermissionScopes(BaseScopes):
return [] return []
scopes = [f"{p.id}_{p.membership.club.id}" scopes = [f"{p.id}_{p.membership.club.id}"
for p in PermissionBackend.get_raw_permissions(get_current_request(), 'view')] for p in PermissionBackend.get_raw_permissions(get_current_request(), 'view')]
scopes.append('openid') scopes = ['0_0'] # always default
return scopes return scopes
@@ -67,10 +72,77 @@ class PermissionOAuth2Validator(OAuth2Validator):
"email": request.user.email, "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): def get_discovery_claims(self, request):
claims = super().get_discovery_claims(self) claims = super().get_discovery_claims(self)
return claims + ["name", "normalized_name", "email"] 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): def validate_scopes(self, client_id, scopes, client, request, *args, **kwargs):
""" """
User can request as many scope as he wants, including invalid scopes, 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 This allows clients to request more permission to get finally a
subset of permissions. 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() 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 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}" scope = f"{p.id}_{p.membership.club.id}"
if scope in scopes: if scope in scopes:
valid_scopes.add(scope) 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') valid_scopes.add('openid')
# Always give one scope to generate token
if not valid_scopes:
valid_scopes.add('0_0')
request.scopes = valid_scopes request.scopes = valid_scopes
return valid_scopes return valid_scopes

View File

@@ -21,6 +21,7 @@ class OAuth2TestCase(TestCase):
def setUp(self): def setUp(self):
self.user = User.objects.create( self.user = User.objects.create(
username="toto", username="toto",
password="toto1234",
) )
self.application = Application.objects.create( self.application = Application.objects.create(
name="Test", name="Test",
@@ -92,3 +93,40 @@ class OAuth2TestCase(TestCase):
self.assertEqual(resp.status_code, 200) self.assertEqual(resp.status_code, 200)
self.assertIn(self.application, resp.context['scopes']) self.assertIn(self.application, resp.context['scopes'])
self.assertIn('1_1', resp.context['scopes'][self.application]) # Now the user has this permission 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, null=True,
) )
valid = models.BooleanField(
default=False,
verbose_name=_("Valid"),
blank=True,
)
class Meta: class Meta:
verbose_name = _("Credit from the Société générale") verbose_name = _("Credit from the Société générale")
verbose_name_plural = _("Credits 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, last_name=self.user.last_name,
first_name=self.user.first_name, first_name=self.user.first_name,
bank="Société générale", bank="Société générale",
valid=False, valid=True,
) )
credit_transaction._force_save = True credit_transaction._force_save = True
credit_transaction.save() credit_transaction.save()
@@ -346,12 +352,12 @@ class SogeCredit(models.Model):
return super().save(*args, **kwargs) return super().save(*args, **kwargs)
@property @property
def valid(self): def valid_legacy(self):
return self.credit_transaction and self.credit_transaction.valid return self.credit_transaction and self.credit_transaction.valid
@property @property
def amount(self): def amount(self):
if self.valid: if self.valid_legacy:
return self.credit_transaction.total return self.credit_transaction.total
amount = 0 amount = 0
transactions_wei = self.transactions.filter(membership__club__weiclub__isnull=False) transactions_wei = self.transactions.filter(membership__club__weiclub__isnull=False)
@@ -397,7 +403,7 @@ class SogeCredit(models.Model):
self.transactions.add(m.transaction) self.transactions.add(m.transaction)
for tr in self.transactions.all(): for tr in self.transactions.all():
tr.valid = False tr.valid = True
tr.save() tr.save()
def invalidate(self): def invalidate(self):
@@ -422,6 +428,7 @@ class SogeCredit(models.Model):
self.invalidate() self.invalidate()
# Refresh credit amount # Refresh credit amount
self.save() self.save()
self.valid = True
self.credit_transaction.valid = True self.credit_transaction.valid = True
self.credit_transaction._force_save = True self.credit_transaction._force_save = True
self.credit_transaction.save() 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 model = Invoice
template_name = 'django_tables2/bootstrap4.html' template_name = 'django_tables2/bootstrap4.html'
fields = ('id', 'name', 'object', 'acquitted', 'invoice',) fields = ('id', 'name', 'object', 'acquitted', 'invoice',)
order_by = ('-id',)
class RemittanceTable(tables.Table): 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"]: 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 return qs

View File

@@ -17,7 +17,7 @@ from ...models import WEIMembership, Bus
WORDS = { WORDS = {
'list': [ 'list': [
'Fiesta', 'Graillance', 'Move it move it', 'Calme', 'Nert et geek', 'Jeux de rôles et danse rock', 'Fiesta', 'Graillance', 'Move it move it', 'Calme', 'Nerd et geek', 'Jeux de rôles et danse rock',
'Strass et paillettes', 'Spectaculaire', 'Splendide', 'Flow inégalable', 'Rap', 'Battles légendaires', 'Strass et paillettes', 'Spectaculaire', 'Splendide', 'Flow inégalable', 'Rap', 'Battles légendaires',
'Techno', 'Alcool', 'Kiffeur·euse', 'Rugby', 'Médiéval', 'Festif', 'Techno', 'Alcool', 'Kiffeur·euse', 'Rugby', 'Médiéval', 'Festif',
'Stylé', 'Chipie', 'Rétro', 'Vache', 'Farfadet', 'Fanfare', 'Stylé', 'Chipie', 'Rétro', 'Vache', 'Farfadet', 'Fanfare',
@@ -57,7 +57,7 @@ WORDS = {
42: "Un burgouzz de valouzz", 42: "Un burgouzz de valouzz",
47: "Un ocarina (pour me téléporter hors de ce bourbier)", 47: "Un ocarina (pour me téléporter hors de ce bourbier)",
48: "Des paillettes, un micro de karaoké et une enceinte bluetooth", 48: "Des paillettes, un micro de karaoké et une enceinte bluetooth",
45: "", 45: "Un kebab",
44: "Une 86 et un caisson pour taper du pied", 44: "Une 86 et un caisson pour taper du pied",
46: "Une épée, un ballon et une tireuse", 46: "Une épée, un ballon et une tireuse",
43: "Des lunettes de soleil", 43: "Des lunettes de soleil",
@@ -176,7 +176,33 @@ WORDS = {
49: "Soirée raclette !" 49: "Soirée raclette !"
} }
] ]
} },
'stats': [
{
"question": """Le WEI est structuré par bus, et au sein de chaque bus, par équipes.
Pour toi, être dans une équipe où tout le monde reste sobre (primo-entrants comme encadrants) c'est :""",
"answers": [
(1, "Inenvisageable"),
(2, "À contre cœur"),
(3, "Pourquoi pas"),
(4, "Souhaitable"),
(5, "Nécessaire"),
],
"help_text": "(De toute façon aucun alcool n'est consommé pendant les trajets du bus, ni aller, ni retour.)",
},
{
"question": "Faire partie d'un bus qui n'apporte pas de boisson alcoolisée pour ses membres, pour toi c'est :",
"answers": [
(1, "Inenvisageable"),
(2, "À contre cœur"),
(3, "Pourquoi pas"),
(4, "Souhaitable"),
(5, "Nécessaire"),
],
"help_text": """(Tout les bus apportent de l'alcool cette année, cette question sert à l'organisation pour l'année prochaine.
De plus il y aura de toute façon de l'alcool commun au WEI et aucun alcool n'est consommé pendant les trajets en bus.)""",
},
]
} }
IMAGES = { IMAGES = {
@@ -235,7 +261,7 @@ class WEISurveyForm2025(forms.Form):
all_preferred_words = WORDS['list'] all_preferred_words = WORDS['list']
rng.shuffle(all_preferred_words) rng.shuffle(all_preferred_words)
self.fields["words"].choices = [(w, w) for w in all_preferred_words] self.fields["words"].choices = [(w, w) for w in all_preferred_words]
else: elif information.step <= len(WORDS['questions']):
questions = list(WORDS['questions'].items()) questions = list(WORDS['questions'].items())
idx = information.step - 1 idx = information.step - 1
if idx < len(questions): if idx < len(questions):
@@ -251,6 +277,15 @@ class WEISurveyForm2025(forms.Form):
widget=OptionalImageRadioSelect(images=IMAGES.get(q, {})), widget=OptionalImageRadioSelect(images=IMAGES.get(q, {})),
required=True, required=True,
) )
elif information.step == len(WORDS['questions']) + 1:
for i, v in enumerate(WORDS['stats']):
self.fields[f'stat_{i}'] = forms.ChoiceField(
label=v['question'],
choices=v['answers'],
widget=forms.RadioSelect(),
required=False,
help_text=_(v.get('help_text', ''))
)
def clean_words(self): def clean_words(self):
data = self.cleaned_data['words'] data = self.cleaned_data['words']
@@ -377,7 +412,7 @@ class WEISurvey2025(WEISurvey):
setattr(self.information, "word" + str(i), word) setattr(self.information, "word" + str(i), word)
self.information.step += 1 self.information.step += 1
self.save() self.save()
else: elif 1 <= self.information.step <= len(WORDS['questions']):
questions = list(WORDS['questions'].keys()) questions = list(WORDS['questions'].keys())
idx = self.information.step - 1 idx = self.information.step - 1
if idx < len(questions): if idx < len(questions):
@@ -385,6 +420,13 @@ class WEISurvey2025(WEISurvey):
setattr(self.information, q, form.cleaned_data[q]) setattr(self.information, q, form.cleaned_data[q])
self.information.step += 1 self.information.step += 1
self.save() self.save()
else:
for i, __ in enumerate(WORDS['stats']):
ans = form.cleaned_data.get(f'stat_{i}')
if ans is not None:
setattr(self.information, f'stat_{i}', ans)
self.information.step += 1
self.save()
@classmethod @classmethod
def get_algorithm_class(cls): def get_algorithm_class(cls):
@@ -394,7 +436,7 @@ class WEISurvey2025(WEISurvey):
""" """
The survey is complete once the bus is chosen. The survey is complete once the bus is chosen.
""" """
return self.information.step > len(WORDS['questions']) return self.information.step > len(WORDS['questions']) + 1
@classmethod @classmethod
@lru_cache() @lru_cache()

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

View File

@@ -11,7 +11,7 @@ SPDX-License-Identifier: GPL-3.0-or-later
{{ title }} {{ title }}
</h3> </h3>
<div class="card-body"> <div class="card-body">
<form method="post"> <form id="registration-form" method="post">
{% csrf_token %} {% csrf_token %}
{{ form|crispy }} {{ form|crispy }}
{{ membership_form|crispy }} {{ membership_form|crispy }}
@@ -22,6 +22,46 @@ SPDX-License-Identifier: GPL-3.0-or-later
{% endblock %} {% endblock %}
{% block extrajavascript %} {% block extrajavascript %}
<!-- intl-tel-input CSS/JS -->
<script>
(() => {
const input = document.querySelector("input[name='emergency_contact_phone']");
const form = document.querySelector("#registration-form");
if (!input || !form || input.type === "hidden" || input.disabled || input.readOnly) {
return;
}
const iti = window.intlTelInput(input, {
initialCountry: "auto",
nationalMode: false,
autoPlaceholder: "off",
geoIpLookup: callback => {
fetch("https://ipapi.co/json")
.then(res => res.json())
.then(data => callback(data.country_code))
.catch(() => callback("fr"));
},
loadUtils: () => import("https://cdn.jsdelivr.net/npm/intl-tel-input@25.5.2/build/js/utils.js"),
});
form.addEventListener("submit", function(e){
if (!input.value.trim()) {
return;
}
const number = iti.getNumber(intlTelInput.utils.numberFormat.E164);
if (number) {
input.value = number;
form.submit();
} else {
e.preventDefault();
input.focus();
}
});
})();
</script>
{% if not object.membership %} {% if not object.membership %}
<script> <script>
$(document).ready(function () { $(document).ready(function () {

View File

@@ -53,9 +53,11 @@ class TestWEIAlgorithm(TestCase):
birth_date='2000-01-01', birth_date='2000-01-01',
) )
information = WEISurveyInformation2025(registration) information = WEISurveyInformation2025(registration)
for j in range(1, 21): for j in range(1, 1 + NB_WORDS):
setattr(information, f'word{j}', random.choice(WORDS['list'])) setattr(information, f'word{j}', random.choice(WORDS['list']))
information.step = 20 for q in WORDS['questions']:
setattr(information, q, random.choice(list(WORDS['questions'][q][1].keys())))
information.step = len(WORDS['questions']) + 2
information.save(registration) information.save(registration)
registration.save() registration.save()
@@ -87,7 +89,7 @@ class TestWEIAlgorithm(TestCase):
setattr(information, f'word{j}', random.choice(WORDS['list'])) setattr(information, f'word{j}', random.choice(WORDS['list']))
for q in WORDS['questions']: for q in WORDS['questions']:
setattr(information, q, random.choice(list(WORDS['questions'][q][1].keys()))) setattr(information, q, random.choice(list(WORDS['questions'][q][1].keys())))
information.step = len(WORDS['questions']) + 1 information.step = len(WORDS['questions']) + 2
information.save(registration) information.save(registration)
registration.save() registration.save()
survey = WEISurvey2025(registration) survey = WEISurvey2025(registration)

View File

@@ -680,7 +680,7 @@ class TestWEIRegistration(TestCase):
self.assertTrue(soge_credit.exists()) self.assertTrue(soge_credit.exists())
soge_credit = soge_credit.get() soge_credit = soge_credit.get()
self.assertTrue(membership.transaction in soge_credit.transactions.all()) 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 # Check that if the WEI is started, we can't update a wei
self.wei.date_start = date(2000, 1, 1) 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 wrapped·s va/vont être généré·s
ou regé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 * ``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 * ``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 globales si nécessaire, pour chaque note on souhaite avoir un json avec toutes les données qui
seront dans le wrapped. 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 et récupérer leurs adhésions, leur nom de note afin d'éventuellement faire des transferts
via l'API. via l'API.
Deux protocoles d'authentification sont implémentées : Trois protocoles d'authentification sont implémentées :
* `CAS <cas>`_ * `CAS <cas>`_
* `OAuth2 <oauth2>`_ * `OAuth2 <oauth2>`_
* Open ID Connect
À ce jour, il n'y a pas encore d'exemple d'utilisation d'application qui utilise ce À ce jour, ce mécanisme est notamment utilisé par :
mécanisme, mais on peut imaginer par exemple que la Mediatek ou l'AMAP implémentent * Le `serveur photo <https://photos.crans.org>`_
ces protocoles pour récupérer leurs adhérent⋅es. * 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_ENABLED': True,
'OIDC_RSA_PRIVATE_KEY': 'OIDC_RSA_PRIVATE_KEY':
os.getenv('OIDC_RSA_PRIVATE_KEY', '/var/secrets/oidc.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``, Cela a pour effet d'avoir des scopes sous la forme ``PERMISSION_CLUB``,
@@ -99,7 +98,7 @@ du format renvoyé.
.. warning:: .. 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 (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 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. 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 Vous pouvez donc contrôler le plus finement possible les permissions octroyées à vos
jetons. jetons.
Deux scopes sont un peu particulier, le scope "0_0" qui ne donne aucune permission
et le scope "openid" pour l'OIDC.
.. danger:: .. danger::
Demander des scopes n'implique pas de les avoir. 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 uniquement dans le cas où l'utilisateur⋅rice connecté⋅e
possède la permission problématique. 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 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 `<https://gitlab.crans.org/bde/allauth-note-kfet>`_. Pour l'installer, vous
pouvez simplement faire : 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 .. code:: bash
$ pip3 install git+https://gitlab.crans.org/bde/allauth-note-kfet.git $ 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 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. 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>`_. 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/>`_. 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 * ``state`` : optionnel, peut être utilisé pour permettre au client de détecter des requêtes
provenant d'autres sites. 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 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 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. Le serveur vous fournira alors une nouvelle paire de jetons, comme précédemment.
À noter qu'un jeton de rafraîchissement est à usage unique. À 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?" msgid "Forgotten your password or username?"
msgstr "Passwort oder Username vergessen?" 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 #: note_kfet/templates/registration/password_change_done.html:13
msgid "Your password was changed." msgid "Your password was changed."
msgstr "Ihr Passwort wurde geändert." msgstr "Ihr Passwort wurde geändert."

View File

@@ -4281,6 +4281,14 @@ msgstr ""
msgid "Forgotten your password or username?" msgid "Forgotten your password or username?"
msgstr "¿ Contraseña o nombre de usuario olvidado ?" 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 #: note_kfet/templates/registration/password_change_done.html:13
msgid "Your password was changed." msgid "Your password was changed."
msgstr "Su contraseña fue cambiada con éxito." 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), 'REFRESH_TOKEN_EXPIRE_SECONDS': timedelta(days=14),
'PKCE_REQUIRED': False, # PKCE (fix a breaking change of django-oauth-toolkit 2.0.0) 'PKCE_REQUIRED': False, # PKCE (fix a breaking change of django-oauth-toolkit 2.0.0)
'OIDC_ENABLED': True, 'OIDC_ENABLED': True,
'OIDC_RP_INITIATED_LOGOUT_ENABLED': False,
'OIDC_RSA_PRIVATE_KEY': 'OIDC_RSA_PRIVATE_KEY':
os.getenv('OIDC_RSA_PRIVATE_KEY', 'CHANGE_ME_IN_ENV_SETTINGS').replace('\\n', '\n'), # for multilines 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 # Take control on how widget templates are sourced
@@ -306,8 +306,8 @@ PIC_WIDTH = 200
PIC_RATIO = 1 PIC_RATIO = 1
# Custom phone number format # Custom phone number format
PHONENUMBER_DB_FORMAT = 'NATIONAL' PHONENUMBER_DB_FORMAT = 'E164'
PHONENUMBER_DEFAULT_REGION = 'FR' PHONENUMBER_DEFAULT_REGION = None
# We add custom information to CAS, in order to give a normalized name to other services # We add custom information to CAS, in order to give a normalized name to other services
CAS_AUTH_CLASS = 'member.auth.CustomAuthUser' CAS_AUTH_CLASS = 'member.auth.CustomAuthUser'

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

@@ -29,6 +29,8 @@ SPDX-License-Identifier: GPL-3.0-or-later
<link rel="stylesheet" href="{% static "bootstrap4/css/bootstrap.min.css" %}"> <link rel="stylesheet" href="{% static "bootstrap4/css/bootstrap.min.css" %}">
<link rel="stylesheet" href="{% static "font-awesome/css/font-awesome.min.css" %}"> <link rel="stylesheet" href="{% static "font-awesome/css/font-awesome.min.css" %}">
<link rel="stylesheet" href="{% static "css/custom.css" %}"> <link rel="stylesheet" href="{% static "css/custom.css" %}">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/intl-tel-input@25.5.2/build/css/intlTelInput.css">
{# JQuery, Bootstrap and Turbolinks JavaScript #} {# JQuery, Bootstrap and Turbolinks JavaScript #}
<script src="{% static "jquery/jquery.min.js" %}"></script> <script src="{% static "jquery/jquery.min.js" %}"></script>
@@ -41,6 +43,8 @@ SPDX-License-Identifier: GPL-3.0-or-later
{# Translation in javascript files #} {# Translation in javascript files #}
<script src="{% static "js/jsi18n/"|add:LANGUAGE_CODE|add:".js" %}"></script> <script src="{% static "js/jsi18n/"|add:LANGUAGE_CODE|add:".js" %}"></script>
<script src="https://cdn.jsdelivr.net/npm/intl-tel-input@25.5.2/build/js/intlTelInput.min.js"></script>
{# If extra ressources are needed for a form, load here #} {# If extra ressources are needed for a form, load here #}
{% if form.media %} {% if form.media %}
{{ form.media }} {{ form.media }}

View File

@@ -39,6 +39,23 @@ SPDX-License-Identifier: GPL-2.0-or-later
<a href="{% url 'password_reset' %}" <a href="{% url 'password_reset' %}"
class="badge badge-light">{% trans 'Forgotten your password or username?' %}</a> class="badge badge-light">{% trans 'Forgotten your password or username?' %}</a>
</form> </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>
</div> </div>
{% endblock %} {% endblock %}

View File

@@ -19,7 +19,7 @@ SPDX-License-Identifier: GPL-3.0-or-later
{% endblocktrans %} {% endblocktrans %}
</div> </div>
<form method="post"> <form method="post" id="profile_form">
{% csrf_token %} {% csrf_token %}
{{ form|crispy }} {{ form|crispy }}
{{ profile_form|crispy }} {{ profile_form|crispy }}
@@ -31,3 +31,45 @@ SPDX-License-Identifier: GPL-3.0-or-later
</div> </div>
</div> </div>
{% endblock %} {% endblock %}
{% block extrajavascript %}
<!-- intl-tel-input CSS/JS -->
<script>
(() => {
const input = document.querySelector("input[name='phone_number']");
const form = document.querySelector("#profile_form");
if (!input || !form || input.type === "hidden" || input.disabled || input.readOnly) {
return;
}
const iti = window.intlTelInput(input, {
initialCountry: "auto",
nationalMode: false,
autoPlaceholder: "off",
geoIpLookup: callback => {
fetch("https://ipapi.co/json")
.then(res => res.json())
.then(data => callback(data.country_code))
.catch(() => callback("fr"));
},
loadUtils: () => import("https://cdn.jsdelivr.net/npm/intl-tel-input@25.5.2/build/js/utils.js"),
});
form.addEventListener("submit", function(e){
if (!input.value.trim()) {
return;
}
const number = iti.getNumber(intlTelInput.utils.numberFormat.E164);
if (number) {
input.value = number;
form.submit();
} else {
e.preventDefault();
input.focus();
}
});
})();
</script>
{% endblock %}

View File

@@ -12,10 +12,11 @@ django-filter~=25.1
django-mailer~=2.3.2 django-mailer~=2.3.2
django-oauth-toolkit~=3.0.1 django-oauth-toolkit~=3.0.1
django-phonenumber-field~=8.1.0 django-phonenumber-field~=8.1.0
django-polymorphic~=3.1.0 django-polymorphic~=4.1.0
djangorestframework~=3.16.0 djangorestframework~=3.16.0
django-rest-polymorphic~=0.1.10 django-rest-polymorphic~=0.1.10
django-tables2~=2.7.5 django-tables2~=2.7.5
python-memcached~=1.62 python-memcached~=1.62
phonenumbers~=9.0.8 phonenumbers~=9.0.8
tablib~=3.8.0
Pillow>=11.3.0 Pillow>=11.3.0