1
0
mirror of https://gitlab.crans.org/bde/nk20 synced 2025-09-05 18:50:14 +02:00

Compare commits

...

9 Commits

Author SHA1 Message Date
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
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
10 changed files with 199 additions and 16 deletions

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

@@ -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) {
console.error("Input phone_number ou form introuvable.");
}
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,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) {
console.error("Input phone_number ou form introuvable.");
}
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

@@ -770,7 +770,7 @@ msgstr "Créer une famille ou un défi"
#: apps/family/templates/family/manage.html:96 #: apps/family/templates/family/manage.html:96
msgid "Add a family" msgid "Add a family"
msgstr "Ajouter une famille" msgstr "Fonder une famille"
#: apps/family/templates/family/manage.html:101 #: apps/family/templates/family/manage.html:101
msgid "Add a challenge" msgid "Add a challenge"

View File

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

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

@@ -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) {
console.error("Input phone_number ou form introuvable.");
}
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 %}