Merge pull request #932 from ae-utbm/fix-subscriptions

Rework the subscription page
This commit is contained in:
thomas girod 2024-12-03 19:45:26 +01:00 committed by GitHub
commit 95f8e7517c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
21 changed files with 948 additions and 478 deletions

View File

@ -529,13 +529,15 @@ class User(AbstractBaseUser):
return False return False
@cached_property @cached_property
def can_create_subscription(self): def can_create_subscription(self) -> bool:
from club.models import Club from club.models import Membership
for club in Club.objects.filter(id__in=settings.SITH_CAN_CREATE_SUBSCRIPTIONS): return (
if club in self.clubs_with_rights: Membership.objects.board()
return True .ongoing()
return False .filter(club_id__in=settings.SITH_CAN_CREATE_SUBSCRIPTIONS)
.exists()
)
@cached_property @cached_property
def is_launderette_manager(self): def is_launderette_manager(self):

View File

@ -0,0 +1,89 @@
@import "colors";
/**
* Style related to forms
*/
a.button,
button,
input[type="button"],
input[type="submit"],
input[type="reset"],
input[type="file"] {
border: none;
text-decoration: none;
background-color: $background-button-color;
padding: 0.4em;
margin: 0.1em;
font-size: 1.2em;
border-radius: 5px;
color: black;
&:hover {
background: hsl(0, 0%, 83%);
}
}
a.button,
input[type="button"],
input[type="submit"],
input[type="reset"],
input[type="file"] {
font-weight: bold;
}
a.button:not(:disabled),
button:not(:disabled),
input[type="button"]:not(:disabled),
input[type="submit"]:not(:disabled),
input[type="reset"]:not(:disabled),
input[type="checkbox"]:not(:disabled),
input[type="file"]:not(:disabled) {
cursor: pointer;
}
input,
textarea[type="text"],
[type="number"] {
border: none;
text-decoration: none;
background-color: $background-button-color;
padding: 0.4em;
margin: 0.1em;
font-size: 1.2em;
border-radius: 5px;
max-width: 95%;
}
textarea {
border: none;
text-decoration: none;
background-color: $background-button-color;
padding: 7px;
font-size: 1.2em;
border-radius: 5px;
font-family: sans-serif;
}
select {
border: none;
text-decoration: none;
font-size: 1.2em;
background-color: $background-button-color;
padding: 10px;
border-radius: 5px;
cursor: pointer;
}
a:not(.button) {
text-decoration: none;
color: $primary-dark-color;
&:hover {
color: $primary-light-color;
}
&:active {
color: $primary-color;
}
}

View File

@ -1,4 +1,5 @@
@import "colors"; @import "colors";
@import "forms";
/*--------------------------MEDIA QUERY HELPERS------------------------*/ /*--------------------------MEDIA QUERY HELPERS------------------------*/
$small-devices: 576px; $small-devices: 576px;
@ -13,91 +14,6 @@ body {
font-family: sans-serif; font-family: sans-serif;
} }
a.button,
button,
input[type="button"],
input[type="submit"],
input[type="reset"],
input[type="file"] {
border: none;
text-decoration: none;
background-color: $background-button-color;
padding: 0.4em;
margin: 0.1em;
font-size: 1.2em;
border-radius: 5px;
color: black;
&:hover {
background: hsl(0, 0%, 83%);
}
}
a.button,
input[type="button"],
input[type="submit"],
input[type="reset"],
input[type="file"] {
font-weight: bold;
}
a.button:not(:disabled),
button:not(:disabled),
input[type="button"]:not(:disabled),
input[type="submit"]:not(:disabled),
input[type="reset"]:not(:disabled),
input[type="checkbox"]:not(:disabled),
input[type="file"]:not(:disabled) {
cursor: pointer;
}
input,
textarea[type="text"],
[type="number"] {
border: none;
text-decoration: none;
background-color: $background-button-color;
padding: 0.4em;
margin: 0.1em;
font-size: 1.2em;
border-radius: 5px;
max-width: 95%;
}
textarea {
border: none;
text-decoration: none;
background-color: $background-button-color;
padding: 7px;
font-size: 1.2em;
border-radius: 5px;
font-family: sans-serif;
}
select {
border: none;
text-decoration: none;
font-size: 1.2em;
background-color: $background-button-color;
padding: 10px;
border-radius: 5px;
cursor: pointer;
}
a:not(.button) {
text-decoration: none;
color: $primary-dark-color;
&:hover {
color: $primary-light-color;
}
&:active {
color: $primary-color;
}
}
[aria-busy] { [aria-busy] {
--loading-size: 50px; --loading-size: 50px;
--loading-stroke: 5px; --loading-stroke: 5px;
@ -262,8 +178,10 @@ a:not(.button) {
font-weight: normal; font-weight: normal;
color: white; color: white;
padding: 9px 13px; padding: 9px 13px;
margin: 3px;
border: none; border: none;
text-decoration: none; text-decoration: none;
text-align: center;
border-radius: 5px; border-radius: 5px;
&.btn-blue { &.btn-blue {
@ -367,6 +285,49 @@ a:not(.button) {
.alert-aside { .alert-aside {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 5px;
}
}
.tabs {
border-radius: 5px;
.tab-headers {
display: flex;
flex-flow: row wrap;
background-color: $primary-neutral-light-color;
padding: 3px 12px 12px;
column-gap: 20px;
border-top-left-radius: 5px;
border-top-right-radius: 5px;
.tab-header {
border: none;
padding-right: 0;
padding-left: 0;
font-size: 120%;
background-color: unset;
position: relative;
&:after {
content: '';
position: absolute;
bottom: 0;
left: 0;
width: 100%;
border-bottom: 4px solid darken($primary-neutral-light-color, 10%);
border-radius: 2px;
transition: all 0.2s ease-in-out;
}
&:hover:after {
border-bottom-color: darken($primary-neutral-light-color, 20%);
}
&.active:after {
border-bottom-color: $primary-dark-color;
}
}
}
section {
padding: 20px;
} }
} }
@ -1246,26 +1207,26 @@ u,
/*-----------------------------USER PROFILE----------------------------*/ /*-----------------------------USER PROFILE----------------------------*/
.user_mini_profile { .user_mini_profile {
height: 100%; --gap-size: 1em;
width: 100%; max-height: 100%;
max-width: 100%;
display: flex;
flex-direction: column;
gap: var(--gap-size);
img { img {
max-width: 100%;
max-height: 100%; max-height: 100%;
max-width: 100%;
} }
.user_mini_profile_infos { .user_mini_profile_infos {
padding: 0.2em; padding: 0.2em;
height: 20%; max-height: 20%;
display: flex; display: flex;
flex-wrap: nowrap; flex-wrap: nowrap;
justify-content: space-around; justify-content: space-around;
font-size: 0.9em; font-size: 0.9em;
div {
max-height: 100%;
}
.user_mini_profile_infos_text { .user_mini_profile_infos_text {
text-align: center; text-align: center;
@ -1276,10 +1237,10 @@ u,
} }
.user_mini_profile_picture { .user_mini_profile_picture {
height: 80%; max-height: calc(80% - var(--gap-size));
display: flex; max-width: 100%;
justify-content: center; display: block;
align-items: center; margin: auto;
} }
} }

View File

@ -66,7 +66,12 @@
</div> </div>
{% if user.promo and user.promo_has_logo() %} {% if user.promo and user.promo_has_logo() %}
<div class="user_mini_profile_promo"> <div class="user_mini_profile_promo">
<img src="{{ static('core/img/promo_%02d.png' % user.promo) }}" title="Promo {{ user.promo }}" alt="Promo {{ user.promo }}" class="promo_pict" /> <img
src="{{ static('core/img/promo_%02d.png' % user.promo) }}"
title="Promo {{ user.promo }}"
alt="Promo {{ user.promo }}"
class="promo_pict"
/>
</div> </div>
{% endif %} {% endif %}
</div> </div>
@ -74,8 +79,11 @@
{% if user.profile_pict %} {% if user.profile_pict %}
<img src="{{ user.profile_pict.get_download_url() }}" alt="{% trans %}Profile{% endtrans %}" /> <img src="{{ user.profile_pict.get_download_url() }}" alt="{% trans %}Profile{% endtrans %}" />
{% else %} {% else %}
<img src="{{ static('core/img/unknown.jpg') }}" alt="{% trans %}Profile{% endtrans %}" <img
title="{% trans %}Profile{% endtrans %}" /> src="{{ static('core/img/unknown.jpg') }}"
alt="{% trans %}Profile{% endtrans %}"
title="{% trans %}Profile{% endtrans %}"
/>
{% endif %} {% endif %}
</div> </div>
</div> </div>
@ -170,12 +178,12 @@
{% endmacro %} {% endmacro %}
{% macro paginate_htmx(current_page, paginator) %} {% macro paginate_htmx(current_page, paginator) %}
{# Add pagination buttons for pages without Alpine but supporting framgents. {# Add pagination buttons for pages without Alpine but supporting fragments.
This must be coupled with a view that handles pagination This must be coupled with a view that handles pagination
with the Django Paginator object and supports framgents. with the Django Paginator object and supports fragments.
The relpaced fragment will be #content so make sure you are calling this macro inside your content block. The replaced fragment will be #content so make sure you are calling this macro inside your content block.
Parameters: Parameters:
current_page (django.core.paginator.Page): the current page object current_page (django.core.paginator.Page): the current page object
@ -247,9 +255,9 @@
{% macro select_all_checkbox(form_id) %} {% macro select_all_checkbox(form_id) %}
<script type="text/javascript"> <script type="text/javascript">
function checkbox_{{form_id}}(value) { function checkbox_{{form_id}}(value) {
list = document.getElementById("{{ form_id }}").getElementsByTagName("input"); const inputs = document.getElementById("{{ form_id }}").getElementsByTagName("input");
for (let element of list){ for (let element of inputs){
if (element.type == "checkbox"){ if (element.type === "checkbox"){
element.checked = value; element.checked = value;
} }
} }
@ -258,3 +266,65 @@
<button type="button" onclick="checkbox_{{form_id}}(true);">{% trans %}Select All{% endtrans %}</button> <button type="button" onclick="checkbox_{{form_id}}(true);">{% trans %}Select All{% endtrans %}</button>
<button type="button" onclick="checkbox_{{form_id}}(false);">{% trans %}Unselect All{% endtrans %}</button> <button type="button" onclick="checkbox_{{form_id}}(false);">{% trans %}Unselect All{% endtrans %}</button>
{% endmacro %} {% endmacro %}
{% macro tabs(tab_list, attrs = "") %}
{# Tab component
Parameters:
tab_list: list[tuple[str, str]] The list of tabs to display.
Each element of the list is a tuple which first element
is the title of the tab and the second element its content
attrs: str Additional attributes to put on the enclosing div
Example:
A basic usage would be as follow :
{{ tabs([("title 1", "content 1"), ("title 2", "content 2")]) }}
If you want to display more complex logic, you can define macros
and use those macros in parameters :
{{ tabs([("title", my_macro())]) }}
It's also possible to get and set the currently selected tab using Alpine.
Here, the title of the currently selected tab will be displayed.
Moreover, on page load, the tab will be opened on "tab 2".
<div x-data="{current_tab: 'tab 2'}">
<p x-text="current_tab"></p>
{{ tabs([("tab 1", "Hello"), ("tab 2", "World")], "x-model=current_tab") }}
</div>
If you want to have translated tab titles, you can enclose the macro call
in a with block :
{% with title=_("title"), content=_("Content") %}
{{ tabs([(tab1, content)]) }}
{% endwith %}
#}
<div
class="tabs shadow"
x-data="{selected: '{{ tab_list[0][0] }}'}"
x-modelable="selected"
{{ attrs }}
>
<div class="tab-headers">
{% for title, _ in tab_list %}
<button
class="tab-header clickable"
:class="{active: selected === '{{ title }}'}"
@click="selected = '{{ title }}'"
>
{{ title }}
</button>
{% endfor %}
</div>
<div class="tab-content">
{% for title, content in tab_list %}
<section x-show="selected === '{{ title }}'">
{{ content }}
</section>
{% endfor %}
</div>
</div>
{% endmacro %}

View File

@ -6,7 +6,7 @@
msgid "" msgid ""
msgstr "" msgstr ""
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2024-11-19 00:47+0100\n" "POT-Creation-Date: 2024-11-29 18:04+0100\n"
"PO-Revision-Date: 2016-07-18\n" "PO-Revision-Date: 2016-07-18\n"
"Last-Translator: Maréchal <thomas.girod@utbm.fr\n" "Last-Translator: Maréchal <thomas.girod@utbm.fr\n"
"Language-Team: AE info <ae.info@utbm.fr>\n" "Language-Team: AE info <ae.info@utbm.fr>\n"
@ -40,7 +40,7 @@ msgstr "code postal"
msgid "country" msgid "country"
msgstr "pays" msgstr "pays"
#: accounting/models.py:67 core/models.py:390 #: accounting/models.py:67 core/models.py:391
msgid "phone" msgid "phone"
msgstr "téléphone" msgstr "téléphone"
@ -126,8 +126,8 @@ msgstr "numéro"
msgid "journal" msgid "journal"
msgstr "classeur" msgstr "classeur"
#: accounting/models.py:256 core/models.py:945 core/models.py:1456 #: accounting/models.py:256 core/models.py:956 core/models.py:1467
#: core/models.py:1501 core/models.py:1530 core/models.py:1554 #: core/models.py:1512 core/models.py:1541 core/models.py:1565
#: counter/models.py:689 counter/models.py:793 counter/models.py:997 #: counter/models.py:689 counter/models.py:793 counter/models.py:997
#: eboutic/models.py:57 eboutic/models.py:193 forum/models.py:312 #: eboutic/models.py:57 eboutic/models.py:193 forum/models.py:312
#: forum/models.py:413 #: forum/models.py:413
@ -165,7 +165,7 @@ msgid "accounting type"
msgstr "type comptable" msgstr "type comptable"
#: accounting/models.py:294 accounting/models.py:429 accounting/models.py:460 #: accounting/models.py:294 accounting/models.py:429 accounting/models.py:460
#: accounting/models.py:492 core/models.py:1529 core/models.py:1555 #: accounting/models.py:492 core/models.py:1540 core/models.py:1566
#: counter/models.py:759 #: counter/models.py:759
msgid "label" msgid "label"
msgstr "étiquette" msgstr "étiquette"
@ -218,7 +218,7 @@ msgstr "Compte"
msgid "Company" msgid "Company"
msgstr "Entreprise" msgstr "Entreprise"
#: accounting/models.py:307 core/models.py:337 sith/settings.py:421 #: accounting/models.py:307 core/models.py:338 sith/settings.py:421
msgid "Other" msgid "Other"
msgstr "Autre" msgstr "Autre"
@ -362,8 +362,8 @@ msgstr "Compte en banque : "
#: core/templates/core/file_detail.jinja:62 #: core/templates/core/file_detail.jinja:62
#: core/templates/core/file_moderation.jinja:48 #: core/templates/core/file_moderation.jinja:48
#: core/templates/core/group_detail.jinja:26 #: core/templates/core/group_detail.jinja:26
#: core/templates/core/group_list.jinja:25 core/templates/core/macros.jinja:96 #: core/templates/core/group_list.jinja:25 core/templates/core/macros.jinja:104
#: core/templates/core/macros.jinja:115 core/templates/core/page_prop.jinja:14 #: core/templates/core/macros.jinja:123 core/templates/core/page_prop.jinja:14
#: core/templates/core/user_account_detail.jinja:41 #: core/templates/core/user_account_detail.jinja:41
#: core/templates/core/user_account_detail.jinja:77 #: core/templates/core/user_account_detail.jinja:77
#: core/templates/core/user_clubs.jinja:34 #: core/templates/core/user_clubs.jinja:34
@ -372,7 +372,7 @@ msgstr "Compte en banque : "
#: core/templates/core/user_preferences.jinja:48 #: core/templates/core/user_preferences.jinja:48
#: counter/templates/counter/last_ops.jinja:35 #: counter/templates/counter/last_ops.jinja:35
#: counter/templates/counter/last_ops.jinja:65 #: counter/templates/counter/last_ops.jinja:65
#: election/templates/election/election_detail.jinja:187 #: election/templates/election/election_detail.jinja:191
#: forum/templates/forum/macros.jinja:21 #: forum/templates/forum/macros.jinja:21
#: launderette/templates/launderette/launderette_admin.jinja:16 #: launderette/templates/launderette/launderette_admin.jinja:16
#: launderette/views.py:210 pedagogy/templates/pedagogy/guide.jinja:99 #: launderette/views.py:210 pedagogy/templates/pedagogy/guide.jinja:99
@ -424,7 +424,7 @@ msgstr "Nouveau compte club"
#: counter/templates/counter/counter_list.jinja:17 #: counter/templates/counter/counter_list.jinja:17
#: counter/templates/counter/counter_list.jinja:33 #: counter/templates/counter/counter_list.jinja:33
#: counter/templates/counter/counter_list.jinja:49 #: counter/templates/counter/counter_list.jinja:49
#: election/templates/election/election_detail.jinja:184 #: election/templates/election/election_detail.jinja:188
#: forum/templates/forum/macros.jinja:20 forum/templates/forum/macros.jinja:62 #: forum/templates/forum/macros.jinja:20 forum/templates/forum/macros.jinja:62
#: launderette/templates/launderette/launderette_list.jinja:16 #: launderette/templates/launderette/launderette_list.jinja:16
#: pedagogy/templates/pedagogy/guide.jinja:98 #: pedagogy/templates/pedagogy/guide.jinja:98
@ -774,7 +774,7 @@ msgstr "Opération liée : "
#: core/templates/core/user_preferences.jinja:65 #: core/templates/core/user_preferences.jinja:65
#: counter/templates/counter/cash_register_summary.jinja:28 #: counter/templates/counter/cash_register_summary.jinja:28
#: forum/templates/forum/reply.jinja:39 #: forum/templates/forum/reply.jinja:39
#: subscription/templates/subscription/subscription.jinja:25 #: subscription/templates/subscription/fragments/creation_form.jinja:9
#: trombi/templates/trombi/comment.jinja:26 #: trombi/templates/trombi/comment.jinja:26
#: trombi/templates/trombi/edit_profile.jinja:13 #: trombi/templates/trombi/edit_profile.jinja:13
#: trombi/templates/trombi/user_tools.jinja:13 #: trombi/templates/trombi/user_tools.jinja:13
@ -956,7 +956,7 @@ msgid "Begin date"
msgstr "Date de début" msgstr "Date de début"
#: club/forms.py:152 com/views.py:84 com/views.py:202 counter/forms.py:206 #: club/forms.py:152 com/views.py:84 com/views.py:202 counter/forms.py:206
#: election/views.py:170 subscription/views.py:38 #: election/views.py:170 subscription/forms.py:21
msgid "End date" msgid "End date"
msgstr "Date de fin" msgstr "Date de fin"
@ -1025,11 +1025,11 @@ msgstr "actif"
msgid "short description" msgid "short description"
msgstr "description courte" msgstr "description courte"
#: club/models.py:81 core/models.py:392 #: club/models.py:81 core/models.py:393
msgid "address" msgid "address"
msgstr "Adresse" msgstr "Adresse"
#: club/models.py:98 core/models.py:303 #: club/models.py:98 core/models.py:304
msgid "home" msgid "home"
msgstr "home" msgstr "home"
@ -1048,12 +1048,12 @@ msgstr "Un club avec ce nom UNIX existe déjà."
msgid "user" msgid "user"
msgstr "nom d'utilisateur" msgstr "nom d'utilisateur"
#: club/models.py:354 core/models.py:356 election/models.py:178 #: club/models.py:354 core/models.py:357 election/models.py:178
#: election/models.py:212 trombi/models.py:210 #: election/models.py:212 trombi/models.py:210
msgid "role" msgid "role"
msgstr "rôle" msgstr "rôle"
#: club/models.py:359 core/models.py:89 counter/models.py:298 #: club/models.py:359 core/models.py:90 counter/models.py:298
#: counter/models.py:329 election/models.py:13 election/models.py:115 #: counter/models.py:329 election/models.py:13 election/models.py:115
#: election/models.py:188 forum/models.py:61 forum/models.py:245 #: election/models.py:188 forum/models.py:61 forum/models.py:245
msgid "description" msgid "description"
@ -1068,7 +1068,7 @@ msgid "Enter a valid address. Only the root of the address is needed."
msgstr "" msgstr ""
"Entrez une adresse valide. Seule la racine de l'adresse est nécessaire." "Entrez une adresse valide. Seule la racine de l'adresse est nécessaire."
#: club/models.py:427 com/models.py:82 com/models.py:309 core/models.py:946 #: club/models.py:427 com/models.py:82 com/models.py:309 core/models.py:957
msgid "is moderated" msgid "is moderated"
msgstr "est modéré" msgstr "est modéré"
@ -1334,6 +1334,7 @@ msgid "No mailing list existing for this club"
msgstr "Aucune mailing liste n'existe pour ce club" msgstr "Aucune mailing liste n'existe pour ce club"
#: club/templates/club/mailing.jinja:72 #: club/templates/club/mailing.jinja:72
#: subscription/templates/subscription/subscription.jinja:39
msgid "New member" msgid "New member"
msgstr "Nouveau membre" msgstr "Nouveau membre"
@ -1439,7 +1440,7 @@ msgstr "résumé"
msgid "content" msgid "content"
msgstr "contenu" msgstr "contenu"
#: com/models.py:71 core/models.py:1499 launderette/models.py:88 #: com/models.py:71 core/models.py:1510 launderette/models.py:88
#: launderette/models.py:124 launderette/models.py:167 #: launderette/models.py:124 launderette/models.py:167
msgid "type" msgid "type"
msgstr "type" msgstr "type"
@ -1489,7 +1490,7 @@ msgstr "weekmail"
msgid "rank" msgid "rank"
msgstr "rang" msgstr "rang"
#: com/models.py:295 core/models.py:911 core/models.py:961 #: com/models.py:295 core/models.py:922 core/models.py:972
msgid "file" msgid "file"
msgstr "fichier" msgstr "fichier"
@ -1917,7 +1918,7 @@ msgid "Format: 16:9 | Resolution: 1920x1080"
msgstr "Format : 16:9 | Résolution : 1920x1080" msgstr "Format : 16:9 | Résolution : 1920x1080"
#: com/views.py:78 com/views.py:199 election/views.py:167 #: com/views.py:78 com/views.py:199 election/views.py:167
#: subscription/views.py:35 #: subscription/forms.py:18
msgid "Start date" msgid "Start date"
msgstr "Date de début" msgstr "Date de début"
@ -1972,30 +1973,30 @@ msgstr ""
"Vous devez êtres un membre du bureau du club sélectionné pour poster dans le " "Vous devez êtres un membre du bureau du club sélectionné pour poster dans le "
"Weekmail." "Weekmail."
#: core/models.py:84 #: core/models.py:85
msgid "meta group status" msgid "meta group status"
msgstr "status du meta-groupe" msgstr "status du meta-groupe"
#: core/models.py:86 #: core/models.py:87
msgid "Whether a group is a meta group or not" msgid "Whether a group is a meta group or not"
msgstr "Si un groupe est un meta-groupe ou pas" msgstr "Si un groupe est un meta-groupe ou pas"
#: core/models.py:172 #: core/models.py:173
#, python-format #, python-format
msgid "%(value)s is not a valid promo (between 0 and %(end)s)" msgid "%(value)s is not a valid promo (between 0 and %(end)s)"
msgstr "%(value)s n'est pas une promo valide (doit être entre 0 et %(end)s)" msgstr "%(value)s n'est pas une promo valide (doit être entre 0 et %(end)s)"
#: core/models.py:256 #: core/models.py:257
msgid "username" msgid "username"
msgstr "nom d'utilisateur" msgstr "nom d'utilisateur"
#: core/models.py:260 #: core/models.py:261
msgid "Required. 254 characters or fewer. Letters, digits and ./+/-/_ only." msgid "Required. 254 characters or fewer. Letters, digits and ./+/-/_ only."
msgstr "" msgstr ""
"Requis. Pas plus de 254 caractères. Uniquement des lettres, numéros, et ./" "Requis. Pas plus de 254 caractères. Uniquement des lettres, numéros, et ./"
"+/-/_" "+/-/_"
#: core/models.py:266 #: core/models.py:267
msgid "" msgid ""
"Enter a valid username. This value may contain only letters, numbers and ./" "Enter a valid username. This value may contain only letters, numbers and ./"
"+/-/_ characters." "+/-/_ characters."
@ -2003,43 +2004,43 @@ msgstr ""
"Entrez un nom d'utilisateur correct. Uniquement des lettres, numéros, et ./" "Entrez un nom d'utilisateur correct. Uniquement des lettres, numéros, et ./"
"+/-/_" "+/-/_"
#: core/models.py:272 #: core/models.py:273
msgid "A user with that username already exists." msgid "A user with that username already exists."
msgstr "Un utilisateur de ce nom existe déjà" msgstr "Un utilisateur de ce nom existe déjà"
#: core/models.py:274 #: core/models.py:275
msgid "first name" msgid "first name"
msgstr "Prénom" msgstr "Prénom"
#: core/models.py:275 #: core/models.py:276
msgid "last name" msgid "last name"
msgstr "Nom" msgstr "Nom"
#: core/models.py:276 #: core/models.py:277
msgid "email address" msgid "email address"
msgstr "adresse email" msgstr "adresse email"
#: core/models.py:277 #: core/models.py:278
msgid "date of birth" msgid "date of birth"
msgstr "date de naissance" msgstr "date de naissance"
#: core/models.py:278 #: core/models.py:279
msgid "nick name" msgid "nick name"
msgstr "surnom" msgstr "surnom"
#: core/models.py:280 #: core/models.py:281
msgid "staff status" msgid "staff status"
msgstr "status \"staff\"" msgstr "status \"staff\""
#: core/models.py:282 #: core/models.py:283
msgid "Designates whether the user can log into this admin site." msgid "Designates whether the user can log into this admin site."
msgstr "Est-ce que l'utilisateur peut se logger à la partie admin du site." msgstr "Est-ce que l'utilisateur peut se logger à la partie admin du site."
#: core/models.py:285 #: core/models.py:286
msgid "active" msgid "active"
msgstr "actif" msgstr "actif"
#: core/models.py:288 #: core/models.py:289
msgid "" msgid ""
"Designates whether this user should be treated as active. Unselect this " "Designates whether this user should be treated as active. Unselect this "
"instead of deleting accounts." "instead of deleting accounts."
@ -2047,164 +2048,164 @@ msgstr ""
"Est-ce que l'utilisateur doit être traité comme actif. Désélectionnez au " "Est-ce que l'utilisateur doit être traité comme actif. Désélectionnez au "
"lieu de supprimer les comptes." "lieu de supprimer les comptes."
#: core/models.py:292 #: core/models.py:293
msgid "date joined" msgid "date joined"
msgstr "date d'inscription" msgstr "date d'inscription"
#: core/models.py:293 #: core/models.py:294
msgid "last update" msgid "last update"
msgstr "dernière mise à jour" msgstr "dernière mise à jour"
#: core/models.py:295 #: core/models.py:296
msgid "superuser" msgid "superuser"
msgstr "super-utilisateur" msgstr "super-utilisateur"
#: core/models.py:297 #: core/models.py:298
msgid "Designates whether this user is a superuser. " msgid "Designates whether this user is a superuser. "
msgstr "Est-ce que l'utilisateur est super-utilisateur." msgstr "Est-ce que l'utilisateur est super-utilisateur."
#: core/models.py:311 #: core/models.py:312
msgid "profile" msgid "profile"
msgstr "profil" msgstr "profil"
#: core/models.py:319 #: core/models.py:320
msgid "avatar" msgid "avatar"
msgstr "avatar" msgstr "avatar"
#: core/models.py:327 #: core/models.py:328
msgid "scrub" msgid "scrub"
msgstr "blouse" msgstr "blouse"
#: core/models.py:333 #: core/models.py:334
msgid "sex" msgid "sex"
msgstr "Genre" msgstr "Genre"
#: core/models.py:337 #: core/models.py:338
msgid "Man" msgid "Man"
msgstr "Homme" msgstr "Homme"
#: core/models.py:337 #: core/models.py:338
msgid "Woman" msgid "Woman"
msgstr "Femme" msgstr "Femme"
#: core/models.py:339 #: core/models.py:340
msgid "pronouns" msgid "pronouns"
msgstr "pronoms" msgstr "pronoms"
#: core/models.py:341 #: core/models.py:342
msgid "tshirt size" msgid "tshirt size"
msgstr "taille de t-shirt" msgstr "taille de t-shirt"
#: core/models.py:344 #: core/models.py:345
msgid "-" msgid "-"
msgstr "-" msgstr "-"
#: core/models.py:345 #: core/models.py:346
msgid "XS" msgid "XS"
msgstr "XS" msgstr "XS"
#: core/models.py:346 #: core/models.py:347
msgid "S" msgid "S"
msgstr "S" msgstr "S"
#: core/models.py:347 #: core/models.py:348
msgid "M" msgid "M"
msgstr "M" msgstr "M"
#: core/models.py:348 #: core/models.py:349
msgid "L" msgid "L"
msgstr "L" msgstr "L"
#: core/models.py:349 #: core/models.py:350
msgid "XL" msgid "XL"
msgstr "XL" msgstr "XL"
#: core/models.py:350 #: core/models.py:351
msgid "XXL" msgid "XXL"
msgstr "XXL" msgstr "XXL"
#: core/models.py:351 #: core/models.py:352
msgid "XXXL" msgid "XXXL"
msgstr "XXXL" msgstr "XXXL"
#: core/models.py:359 #: core/models.py:360
msgid "Student" msgid "Student"
msgstr "Étudiant" msgstr "Étudiant"
#: core/models.py:360 #: core/models.py:361
msgid "Administrative agent" msgid "Administrative agent"
msgstr "Personnel administratif" msgstr "Personnel administratif"
#: core/models.py:361 #: core/models.py:362
msgid "Teacher" msgid "Teacher"
msgstr "Enseignant" msgstr "Enseignant"
#: core/models.py:362 #: core/models.py:363
msgid "Agent" msgid "Agent"
msgstr "Personnel" msgstr "Personnel"
#: core/models.py:363 #: core/models.py:364
msgid "Doctor" msgid "Doctor"
msgstr "Doctorant" msgstr "Doctorant"
#: core/models.py:364 #: core/models.py:365
msgid "Former student" msgid "Former student"
msgstr "Ancien étudiant" msgstr "Ancien étudiant"
#: core/models.py:365 #: core/models.py:366
msgid "Service" msgid "Service"
msgstr "Service" msgstr "Service"
#: core/models.py:371 #: core/models.py:372
msgid "department" msgid "department"
msgstr "département" msgstr "département"
#: core/models.py:378 #: core/models.py:379
msgid "dpt option" msgid "dpt option"
msgstr "Filière" msgstr "Filière"
#: core/models.py:380 pedagogy/models.py:70 pedagogy/models.py:294 #: core/models.py:381 pedagogy/models.py:70 pedagogy/models.py:294
msgid "semester" msgid "semester"
msgstr "semestre" msgstr "semestre"
#: core/models.py:381 #: core/models.py:382
msgid "quote" msgid "quote"
msgstr "citation" msgstr "citation"
#: core/models.py:382 #: core/models.py:383
msgid "school" msgid "school"
msgstr "école" msgstr "école"
#: core/models.py:384 #: core/models.py:385
msgid "promo" msgid "promo"
msgstr "promo" msgstr "promo"
#: core/models.py:387 #: core/models.py:388
msgid "forum signature" msgid "forum signature"
msgstr "signature du forum" msgstr "signature du forum"
#: core/models.py:389 #: core/models.py:390
msgid "second email address" msgid "second email address"
msgstr "adresse email secondaire" msgstr "adresse email secondaire"
#: core/models.py:391 #: core/models.py:392
msgid "parent phone" msgid "parent phone"
msgstr "téléphone des parents" msgstr "téléphone des parents"
#: core/models.py:394 #: core/models.py:395
msgid "parent address" msgid "parent address"
msgstr "adresse des parents" msgstr "adresse des parents"
#: core/models.py:397 #: core/models.py:398
msgid "is subscriber viewable" msgid "is subscriber viewable"
msgstr "profil visible par les cotisants" msgstr "profil visible par les cotisants"
#: core/models.py:591 #: core/models.py:594
msgid "A user with that username already exists" msgid "A user with that username already exists"
msgstr "Un utilisateur de ce nom d'utilisateur existe déjà" msgstr "Un utilisateur de ce nom d'utilisateur existe déjà"
#: core/models.py:750 core/templates/core/macros.jinja:75 #: core/models.py:761 core/templates/core/macros.jinja:80
#: core/templates/core/macros.jinja:77 core/templates/core/macros.jinja:78 #: core/templates/core/macros.jinja:84 core/templates/core/macros.jinja:85
#: core/templates/core/user_detail.jinja:100 #: core/templates/core/user_detail.jinja:100
#: core/templates/core/user_detail.jinja:101 #: core/templates/core/user_detail.jinja:101
#: core/templates/core/user_detail.jinja:103 #: core/templates/core/user_detail.jinja:103
@ -2214,8 +2215,8 @@ msgstr "Un utilisateur de ce nom d'utilisateur existe déjà"
#: core/templates/core/user_detail.jinja:112 #: core/templates/core/user_detail.jinja:112
#: core/templates/core/user_detail.jinja:113 #: core/templates/core/user_detail.jinja:113
#: core/templates/core/user_edit.jinja:21 #: core/templates/core/user_edit.jinja:21
#: election/templates/election/election_detail.jinja:132 #: election/templates/election/election_detail.jinja:136
#: election/templates/election/election_detail.jinja:134 #: election/templates/election/election_detail.jinja:138
#: forum/templates/forum/macros.jinja:105 #: forum/templates/forum/macros.jinja:105
#: forum/templates/forum/macros.jinja:107 #: forum/templates/forum/macros.jinja:107
#: forum/templates/forum/macros.jinja:109 #: forum/templates/forum/macros.jinja:109
@ -2223,101 +2224,101 @@ msgstr "Un utilisateur de ce nom d'utilisateur existe déjà"
msgid "Profile" msgid "Profile"
msgstr "Profil" msgstr "Profil"
#: core/models.py:861 #: core/models.py:872
msgid "Visitor" msgid "Visitor"
msgstr "Visiteur" msgstr "Visiteur"
#: core/models.py:868 #: core/models.py:879
msgid "receive the Weekmail" msgid "receive the Weekmail"
msgstr "recevoir le Weekmail" msgstr "recevoir le Weekmail"
#: core/models.py:869 #: core/models.py:880
msgid "show your stats to others" msgid "show your stats to others"
msgstr "montrez vos statistiques aux autres" msgstr "montrez vos statistiques aux autres"
#: core/models.py:871 #: core/models.py:882
msgid "get a notification for every click" msgid "get a notification for every click"
msgstr "avoir une notification pour chaque click" msgstr "avoir une notification pour chaque click"
#: core/models.py:874 #: core/models.py:885
msgid "get a notification for every refilling" msgid "get a notification for every refilling"
msgstr "avoir une notification pour chaque rechargement" msgstr "avoir une notification pour chaque rechargement"
#: core/models.py:900 sas/forms.py:81 #: core/models.py:911 sas/forms.py:81
msgid "file name" msgid "file name"
msgstr "nom du fichier" msgstr "nom du fichier"
#: core/models.py:904 core/models.py:1257 #: core/models.py:915 core/models.py:1268
msgid "parent" msgid "parent"
msgstr "parent" msgstr "parent"
#: core/models.py:918 #: core/models.py:929
msgid "compressed file" msgid "compressed file"
msgstr "version allégée" msgstr "version allégée"
#: core/models.py:925 #: core/models.py:936
msgid "thumbnail" msgid "thumbnail"
msgstr "miniature" msgstr "miniature"
#: core/models.py:933 core/models.py:950 #: core/models.py:944 core/models.py:961
msgid "owner" msgid "owner"
msgstr "propriétaire" msgstr "propriétaire"
#: core/models.py:937 core/models.py:1274 #: core/models.py:948 core/models.py:1285
msgid "edit group" msgid "edit group"
msgstr "groupe d'édition" msgstr "groupe d'édition"
#: core/models.py:940 core/models.py:1277 #: core/models.py:951 core/models.py:1288
msgid "view group" msgid "view group"
msgstr "groupe de vue" msgstr "groupe de vue"
#: core/models.py:942 #: core/models.py:953
msgid "is folder" msgid "is folder"
msgstr "est un dossier" msgstr "est un dossier"
#: core/models.py:943 #: core/models.py:954
msgid "mime type" msgid "mime type"
msgstr "type mime" msgstr "type mime"
#: core/models.py:944 #: core/models.py:955
msgid "size" msgid "size"
msgstr "taille" msgstr "taille"
#: core/models.py:955 #: core/models.py:966
msgid "asked for removal" msgid "asked for removal"
msgstr "retrait demandé" msgstr "retrait demandé"
#: core/models.py:957 #: core/models.py:968
msgid "is in the SAS" msgid "is in the SAS"
msgstr "est dans le SAS" msgstr "est dans le SAS"
#: core/models.py:1026 #: core/models.py:1037
msgid "Character '/' not authorized in name" msgid "Character '/' not authorized in name"
msgstr "Le caractère '/' n'est pas autorisé dans les noms de fichier" msgstr "Le caractère '/' n'est pas autorisé dans les noms de fichier"
#: core/models.py:1028 core/models.py:1032 #: core/models.py:1039 core/models.py:1043
msgid "Loop in folder tree" msgid "Loop in folder tree"
msgstr "Boucle dans l'arborescence des dossiers" msgstr "Boucle dans l'arborescence des dossiers"
#: core/models.py:1035 #: core/models.py:1046
msgid "You can not make a file be a children of a non folder file" msgid "You can not make a file be a children of a non folder file"
msgstr "" msgstr ""
"Vous ne pouvez pas mettre un fichier enfant de quelque chose qui n'est pas " "Vous ne pouvez pas mettre un fichier enfant de quelque chose qui n'est pas "
"un dossier" "un dossier"
#: core/models.py:1046 #: core/models.py:1057
msgid "Duplicate file" msgid "Duplicate file"
msgstr "Un fichier de ce nom existe déjà" msgstr "Un fichier de ce nom existe déjà"
#: core/models.py:1063 #: core/models.py:1074
msgid "You must provide a file" msgid "You must provide a file"
msgstr "Vous devez fournir un fichier" msgstr "Vous devez fournir un fichier"
#: core/models.py:1240 #: core/models.py:1251
msgid "page unix name" msgid "page unix name"
msgstr "nom unix de la page" msgstr "nom unix de la page"
#: core/models.py:1246 #: core/models.py:1257
msgid "" msgid ""
"Enter a valid page name. This value may contain only unaccented letters, " "Enter a valid page name. This value may contain only unaccented letters, "
"numbers and ./+/-/_ characters." "numbers and ./+/-/_ characters."
@ -2325,55 +2326,55 @@ msgstr ""
"Entrez un nom de page correct. Uniquement des lettres non accentuées, " "Entrez un nom de page correct. Uniquement des lettres non accentuées, "
"numéros, et ./+/-/_" "numéros, et ./+/-/_"
#: core/models.py:1264 #: core/models.py:1275
msgid "page name" msgid "page name"
msgstr "nom de la page" msgstr "nom de la page"
#: core/models.py:1269 #: core/models.py:1280
msgid "owner group" msgid "owner group"
msgstr "groupe propriétaire" msgstr "groupe propriétaire"
#: core/models.py:1282 #: core/models.py:1293
msgid "lock user" msgid "lock user"
msgstr "utilisateur bloquant" msgstr "utilisateur bloquant"
#: core/models.py:1289 #: core/models.py:1300
msgid "lock_timeout" msgid "lock_timeout"
msgstr "décompte du déblocage" msgstr "décompte du déblocage"
#: core/models.py:1339 #: core/models.py:1350
msgid "Duplicate page" msgid "Duplicate page"
msgstr "Une page de ce nom existe déjà" msgstr "Une page de ce nom existe déjà"
#: core/models.py:1342 #: core/models.py:1353
msgid "Loop in page tree" msgid "Loop in page tree"
msgstr "Boucle dans l'arborescence des pages" msgstr "Boucle dans l'arborescence des pages"
#: core/models.py:1453 #: core/models.py:1464
msgid "revision" msgid "revision"
msgstr "révision" msgstr "révision"
#: core/models.py:1454 #: core/models.py:1465
msgid "page title" msgid "page title"
msgstr "titre de la page" msgstr "titre de la page"
#: core/models.py:1455 #: core/models.py:1466
msgid "page content" msgid "page content"
msgstr "contenu de la page" msgstr "contenu de la page"
#: core/models.py:1496 #: core/models.py:1507
msgid "url" msgid "url"
msgstr "url" msgstr "url"
#: core/models.py:1497 #: core/models.py:1508
msgid "param" msgid "param"
msgstr "param" msgstr "param"
#: core/models.py:1502 #: core/models.py:1513
msgid "viewed" msgid "viewed"
msgstr "vue" msgstr "vue"
#: core/models.py:1560 #: core/models.py:1571
msgid "operation type" msgid "operation type"
msgstr "type d'opération" msgstr "type d'opération"
@ -2393,27 +2394,27 @@ msgstr "500, Erreur Serveur"
msgid "Welcome!" msgid "Welcome!"
msgstr "Bienvenue !" msgstr "Bienvenue !"
#: core/templates/core/base.jinja:104 core/templates/core/base/navbar.jinja:43 #: core/templates/core/base.jinja:105 core/templates/core/base/navbar.jinja:43
msgid "Contacts" msgid "Contacts"
msgstr "Contacts" msgstr "Contacts"
#: core/templates/core/base.jinja:105 #: core/templates/core/base.jinja:106
msgid "Legal notices" msgid "Legal notices"
msgstr "Mentions légales" msgstr "Mentions légales"
#: core/templates/core/base.jinja:106 #: core/templates/core/base.jinja:107
msgid "Intellectual property" msgid "Intellectual property"
msgstr "Propriété intellectuelle" msgstr "Propriété intellectuelle"
#: core/templates/core/base.jinja:107 #: core/templates/core/base.jinja:108
msgid "Help & Documentation" msgid "Help & Documentation"
msgstr "Aide & Documentation" msgstr "Aide & Documentation"
#: core/templates/core/base.jinja:108 #: core/templates/core/base.jinja:109
msgid "R&D" msgid "R&D"
msgstr "R&D" msgstr "R&D"
#: core/templates/core/base.jinja:111 #: core/templates/core/base.jinja:112
msgid "Site created by the IT Department of the AE" msgid "Site created by the IT Department of the AE"
msgstr "Site réalisé par le Pôle Informatique de l'AE" msgstr "Site réalisé par le Pôle Informatique de l'AE"
@ -2752,29 +2753,29 @@ msgstr "Partager sur Facebook"
msgid "Tweet" msgid "Tweet"
msgstr "Tweeter" msgstr "Tweeter"
#: core/templates/core/macros.jinja:85 #: core/templates/core/macros.jinja:93
#, python-format #, python-format
msgid "Subscribed until %(subscription_end)s" msgid "Subscribed until %(subscription_end)s"
msgstr "Cotisant jusqu'au %(subscription_end)s" msgstr "Cotisant jusqu'au %(subscription_end)s"
#: core/templates/core/macros.jinja:86 #: core/templates/core/macros.jinja:94
msgid "Account number: " msgid "Account number: "
msgstr "Numéro de compte : " msgstr "Numéro de compte : "
#: core/templates/core/macros.jinja:91 launderette/models.py:188 #: core/templates/core/macros.jinja:99 launderette/models.py:188
msgid "Slot" msgid "Slot"
msgstr "Créneau" msgstr "Créneau"
#: core/templates/core/macros.jinja:104 #: core/templates/core/macros.jinja:112
#: launderette/templates/launderette/launderette_admin.jinja:20 #: launderette/templates/launderette/launderette_admin.jinja:20
msgid "Tokens" msgid "Tokens"
msgstr "Jetons" msgstr "Jetons"
#: core/templates/core/macros.jinja:258 #: core/templates/core/macros.jinja:266
msgid "Select All" msgid "Select All"
msgstr "Tout sélectionner" msgstr "Tout sélectionner"
#: core/templates/core/macros.jinja:259 #: core/templates/core/macros.jinja:267
msgid "Unselect All" msgid "Unselect All"
msgstr "Tout désélectionner" msgstr "Tout désélectionner"
@ -3135,8 +3136,8 @@ msgid "Not subscribed"
msgstr "Non cotisant" msgstr "Non cotisant"
#: core/templates/core/user_detail.jinja:162 #: core/templates/core/user_detail.jinja:162
#: subscription/templates/subscription/subscription.jinja:4 #: subscription/templates/subscription/subscription.jinja:6
#: subscription/templates/subscription/subscription.jinja:8 #: subscription/templates/subscription/subscription.jinja:37
msgid "New subscription" msgid "New subscription"
msgstr "Nouvelle cotisation" msgstr "Nouvelle cotisation"
@ -4512,7 +4513,7 @@ msgstr "candidature"
#: election/templates/election/candidate_form.jinja:4 #: election/templates/election/candidate_form.jinja:4
#: election/templates/election/candidate_form.jinja:13 #: election/templates/election/candidate_form.jinja:13
#: election/templates/election/election_detail.jinja:175 #: election/templates/election/election_detail.jinja:179
msgid "Candidate" msgid "Candidate"
msgstr "Candidater" msgstr "Candidater"
@ -4520,20 +4521,20 @@ msgstr "Candidater"
msgid "Candidature are closed for this election" msgid "Candidature are closed for this election"
msgstr "Les candidatures sont fermées pour cette élection" msgstr "Les candidatures sont fermées pour cette élection"
#: election/templates/election/election_detail.jinja:19 #: election/templates/election/election_detail.jinja:23
msgid "Polls close " msgid "Polls close "
msgstr "Votes fermés" msgstr "Votes fermés"
#: election/templates/election/election_detail.jinja:21 #: election/templates/election/election_detail.jinja:25
msgid "Polls closed " msgid "Polls closed "
msgstr "Votes fermés" msgstr "Votes fermés"
#: election/templates/election/election_detail.jinja:23 #: election/templates/election/election_detail.jinja:27
msgid "Polls will open " msgid "Polls will open "
msgstr "Les votes ouvriront " msgstr "Les votes ouvriront "
#: election/templates/election/election_detail.jinja:25
#: election/templates/election/election_detail.jinja:29 #: election/templates/election/election_detail.jinja:29
#: election/templates/election/election_detail.jinja:33
#: election/templates/election/election_list.jinja:32 #: election/templates/election/election_list.jinja:32
#: election/templates/election/election_list.jinja:35 #: election/templates/election/election_list.jinja:35
#: election/templates/election/election_list.jinja:40 #: election/templates/election/election_list.jinja:40
@ -4542,58 +4543,58 @@ msgstr "Les votes ouvriront "
msgid " at " msgid " at "
msgstr " à " msgstr " à "
#: election/templates/election/election_detail.jinja:26 #: election/templates/election/election_detail.jinja:30
msgid "and will close " msgid "and will close "
msgstr "et fermeront" msgstr "et fermeront"
#: election/templates/election/election_detail.jinja:34 #: election/templates/election/election_detail.jinja:38
msgid "You already have submitted your vote." msgid "You already have submitted your vote."
msgstr "Vous avez déjà soumis votre vote." msgstr "Vous avez déjà soumis votre vote."
#: election/templates/election/election_detail.jinja:36 #: election/templates/election/election_detail.jinja:40
msgid "You have voted in this election." msgid "You have voted in this election."
msgstr "Vous avez déjà voté pour cette élection." msgstr "Vous avez déjà voté pour cette élection."
#: election/templates/election/election_detail.jinja:49 election/views.py:98 #: election/templates/election/election_detail.jinja:53 election/views.py:98
msgid "Blank vote" msgid "Blank vote"
msgstr "Vote blanc" msgstr "Vote blanc"
#: election/templates/election/election_detail.jinja:71 #: election/templates/election/election_detail.jinja:75
msgid "You may choose up to" msgid "You may choose up to"
msgstr "Vous pouvez choisir jusqu'à" msgstr "Vous pouvez choisir jusqu'à"
#: election/templates/election/election_detail.jinja:71 #: election/templates/election/election_detail.jinja:75
msgid "people." msgid "people."
msgstr "personne(s)" msgstr "personne(s)"
#: election/templates/election/election_detail.jinja:108 #: election/templates/election/election_detail.jinja:112
msgid "Choose blank vote" msgid "Choose blank vote"
msgstr "Choisir de voter blanc" msgstr "Choisir de voter blanc"
#: election/templates/election/election_detail.jinja:116 #: election/templates/election/election_detail.jinja:120
#: election/templates/election/election_detail.jinja:159 #: election/templates/election/election_detail.jinja:163
msgid "votes" msgid "votes"
msgstr "votes" msgstr "votes"
#: election/templates/election/election_detail.jinja:178 #: election/templates/election/election_detail.jinja:182
msgid "Add a new list" msgid "Add a new list"
msgstr "Ajouter une nouvelle liste" msgstr "Ajouter une nouvelle liste"
#: election/templates/election/election_detail.jinja:182 #: election/templates/election/election_detail.jinja:186
msgid "Add a new role" msgid "Add a new role"
msgstr "Ajouter un nouveau rôle" msgstr "Ajouter un nouveau rôle"
#: election/templates/election/election_detail.jinja:192 #: election/templates/election/election_detail.jinja:196
msgid "Submit the vote !" msgid "Submit the vote !"
msgstr "Envoyer le vote !" msgstr "Envoyer le vote !"
#: election/templates/election/election_detail.jinja:201 #: election/templates/election/election_detail.jinja:205
#: election/templates/election/election_detail.jinja:206 #: election/templates/election/election_detail.jinja:210
msgid "Show more" msgid "Show more"
msgstr "Montrer plus" msgstr "Montrer plus"
#: election/templates/election/election_detail.jinja:202 #: election/templates/election/election_detail.jinja:206
#: election/templates/election/election_detail.jinja:207 #: election/templates/election/election_detail.jinja:211
msgid "Show less" msgid "Show less"
msgstr "Montrer moins" msgstr "Montrer moins"
@ -5790,6 +5791,10 @@ msgstr "Weekmail envoyé avec succès"
msgid "AE tee-shirt" msgid "AE tee-shirt"
msgstr "Tee-shirt AE" msgstr "Tee-shirt AE"
#: subscription/forms.py:93
msgid "A user with that email address already exists"
msgstr "Un utilisateur avec cette adresse email existe déjà"
#: subscription/models.py:34 #: subscription/models.py:34
msgid "Bad subscription type" msgid "Bad subscription type"
msgstr "Mauvais type de cotisation" msgstr "Mauvais type de cotisation"
@ -5814,10 +5819,36 @@ msgstr "fin de la cotisation"
msgid "location" msgid "location"
msgstr "lieu" msgstr "lieu"
#: subscription/models.py:106 #: subscription/models.py:107
msgid "You can not subscribe many time for the same period" msgid "You can not subscribe many time for the same period"
msgstr "Vous ne pouvez pas cotiser plusieurs fois pour la même période" msgstr "Vous ne pouvez pas cotiser plusieurs fois pour la même période"
#: subscription/templates/subscription/fragments/creation_success.jinja:4
#, python-format
msgid "Subscription created for %(user)s"
msgstr "Cotisation créée pour %(user)s"
#: subscription/templates/subscription/fragments/creation_success.jinja:8
#, python-format
msgid ""
"%(user)s received its new %(type)s subscription. It will be active until "
"%(end)s included."
msgstr ""
"%(user)s a reçu sa nouvelle cotisaton %(type)s. Elle sert active jusqu'au "
"%(end)s inclu."
#: subscription/templates/subscription/fragments/creation_success.jinja:16
msgid "Go to user profile"
msgstr "Voir le profil de l'utilisateur"
#: subscription/templates/subscription/fragments/creation_success.jinja:24
msgid "Create another subscription"
msgstr "Créer une nouvelle cotisation"
#: subscription/templates/subscription/subscription.jinja
msgid "Existing member"
msgstr "Membre existant"
#: subscription/templates/subscription/stats.jinja:27 #: subscription/templates/subscription/stats.jinja:27
msgid "Total subscriptions" msgid "Total subscriptions"
msgstr "Cotisations totales" msgstr "Cotisations totales"
@ -5826,20 +5857,6 @@ msgstr "Cotisations totales"
msgid "Subscriptions by type" msgid "Subscriptions by type"
msgstr "Cotisations par type" msgstr "Cotisations par type"
#: subscription/templates/subscription/subscription.jinja:23
msgid "Eboutic is reserved to specific users. In doubt, don't use it."
msgstr ""
"Eboutic est réservé à des cas particuliers. Dans le doute, ne l'utilisez pas."
#: subscription/views.py:78
msgid "A user with that email address already exists"
msgstr "Un utilisateur avec cette adresse email existe déjà"
#: subscription/views.py:102
msgid "You must either choose an existing user or create a new one properly"
msgstr ""
"Vous devez soit choisir un utilisateur existant, soit en créer un proprement"
#: trombi/models.py:55 #: trombi/models.py:55
msgid "subscription deadline" msgid "subscription deadline"
msgstr "fin des inscriptions" msgstr "fin des inscriptions"

View File

@ -1,9 +1,6 @@
from pydantic import TypeAdapter from pydantic import TypeAdapter
from core.views.widgets.select import ( from core.views.widgets.select import AutoCompleteSelect, AutoCompleteSelectMultiple
AutoCompleteSelect,
AutoCompleteSelectMultiple,
)
from sas.models import Album from sas.models import Album
from sas.schemas import AlbumSchema from sas.schemas import AlbumSchema

124
subscription/forms.py Normal file
View File

@ -0,0 +1,124 @@
import secrets
from typing import Any
from django import forms
from django.core.exceptions import ValidationError
from django.utils.translation import gettext_lazy as _
from core.models import User
from core.views.forms import SelectDate, SelectDateTime
from core.views.widgets.select import AutoCompleteSelectUser
from subscription.models import Subscription
class SelectionDateForm(forms.Form):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields["start_date"] = forms.DateTimeField(
label=_("Start date"), widget=SelectDateTime, required=True
)
self.fields["end_date"] = forms.DateTimeField(
label=_("End date"), widget=SelectDateTime, required=True
)
class SubscriptionForm(forms.ModelForm):
def __init__(self, *args, **kwargs):
initial = kwargs.pop("initial", {})
if "subscription_type" not in initial:
initial["subscription_type"] = "deux-semestres"
if "payment_method" not in initial:
initial["payment_method"] = "CARD"
super().__init__(*args, initial=initial, **kwargs)
def save(self, *args, **kwargs):
if self.errors:
# let django deal with the error messages
return super().save(*args, **kwargs)
duration, user = self.instance.semester_duration, self.instance.member
self.instance.subscription_start = self.instance.compute_start(
duration=duration, user=user
)
self.instance.subscription_end = self.instance.compute_end(
duration=duration, start=self.instance.subscription_start, user=user
)
return super().save(*args, **kwargs)
class SubscriptionNewUserForm(SubscriptionForm):
"""Form to create subscriptions with the user they belong to.
Examples:
```py
assert not User.objects.filter(email=request.POST.get("email")).exists()
form = SubscriptionNewUserForm(request.POST)
if form.is_valid():
form.save()
# now the user exists and is subscribed
user = User.objects.get(email=request.POST.get("email"))
assert user.is_subscribed
"""
template_name = "subscription/forms/create_new_user.html"
__user_fields = forms.fields_for_model(
User,
["first_name", "last_name", "email", "date_of_birth"],
widgets={"date_of_birth": SelectDate},
)
first_name = __user_fields["first_name"]
last_name = __user_fields["last_name"]
email = __user_fields["email"]
date_of_birth = __user_fields["date_of_birth"]
class Meta:
model = Subscription
fields = ["subscription_type", "payment_method", "location"]
field_order = [
"first_name",
"last_name",
"email",
"date_of_birth",
"subscription_type",
"payment_method",
"location",
]
def clean_email(self):
email = self.cleaned_data["email"]
if User.objects.filter(email=email).exists():
raise ValidationError(_("A user with that email address already exists"))
return email
def clean(self) -> dict[str, Any]:
member = User(
first_name=self.cleaned_data.get("first_name"),
last_name=self.cleaned_data.get("last_name"),
email=self.cleaned_data.get("email"),
date_of_birth=self.cleaned_data.get("date_of_birth"),
)
member.generate_username()
member.set_password(secrets.token_urlsafe(nbytes=10))
self.instance.member = member
return super().clean()
def save(self, *args, **kwargs):
if self.errors:
# let django deal with the error messages
return super().save(*args, **kwargs)
self.instance.member.save()
return super().save(*args, **kwargs)
class SubscriptionExistingUserForm(SubscriptionForm):
"""Form to add a subscription to an existing user."""
template_name = "subscription/forms/create_existing_user.html"
class Meta:
model = Subscription
fields = ["member", "subscription_type", "payment_method", "location"]
widgets = {"member": AutoCompleteSelectUser}

View File

@ -93,22 +93,23 @@ class Subscription(models.Model):
def clean(self): def clean(self):
today = localdate() today = localdate()
active_subscriptions = Subscription.objects.exclude(pk=self.pk).filter( threshold = timedelta(weeks=settings.SITH_SUBSCRIPTION_END)
subscription_start__gte=today, subscription_end__lte=today # a user may subscribe if :
# - he/she is not currently subscribed
# - its current subscription ends in less than a few weeks
overlapping_subscriptions = Subscription.objects.exclude(pk=self.pk).filter(
member=self.member,
subscription_start__lte=today,
subscription_end__gte=today + threshold,
) )
for s in active_subscriptions: if overlapping_subscriptions.exists():
if ( raise ValidationError(
s.is_valid_now() _("You can not subscribe many time for the same period")
and s.subscription_end - timedelta(weeks=settings.SITH_SUBSCRIPTION_END) )
> date.today()
):
raise ValidationError(
_("You can not subscribe many time for the same period")
)
@staticmethod @staticmethod
def compute_start( def compute_start(
d: date | None = None, duration: int = 1, user: User | None = None d: date | None = None, duration: int | float = 1, user: User | None = None
) -> date: ) -> date:
"""Computes the start date of the subscription. """Computes the start date of the subscription.
@ -132,7 +133,7 @@ class Subscription(models.Model):
@staticmethod @staticmethod
def compute_end( def compute_end(
duration: int, start: date | None = None, user: User | None = None duration: int | float, start: date | None = None, user: User | None = None
) -> date: ) -> date:
"""Compute the end date of the subscription. """Compute the end date of the subscription.
@ -163,3 +164,19 @@ class Subscription(models.Model):
def is_valid_now(self): def is_valid_now(self):
return self.subscription_start <= date.today() <= self.subscription_end return self.subscription_start <= date.today() <= self.subscription_end
@property
def semester_duration(self) -> float:
"""Duration of this subscription, in number of semester.
Notes:
The `Subscription` object doesn't have to actually exist
in the database to access this property
Examples:
```py
subscription = Subscription(subscription_type="deux-semestres")
assert subscription.semester_duration == 2.0
```
"""
return settings.SITH_SUBSCRIPTIONS[self.subscription_type]["duration"]

View File

@ -0,0 +1,25 @@
document.addEventListener("alpine:init", () => {
Alpine.data("existing_user_subscription_form", () => ({
loading: false,
profileFragment: "" as string,
async init() {
const userSelect = document.getElementById("id_member") as HTMLSelectElement;
userSelect.addEventListener("change", async () => {
await this.loadProfile(Number.parseInt(userSelect.value));
});
await this.loadProfile(Number.parseInt(userSelect.value));
},
async loadProfile(userId: number) {
if (!Number.isInteger(userId)) {
this.profileFragment = "";
return;
}
this.loading = true;
const response = await fetch(`/user/${userId}/mini/`);
this.profileFragment = await response.text();
this.loading = false;
},
}));
});

View File

@ -0,0 +1,28 @@
#subscription-form form {
.form-content.existing-user {
max-height: 100%;
display: flex;
flex: 1 1 auto;
flex-direction: row;
@media screen and (max-width: 700px) {
flex-direction: column-reverse;
}
/* Make the form fields take exactly the space they need,
* then display the user profile right in the middle of the remaining space. */
fieldset {
flex: 0 1 auto;
}
#subscription-form-user-mini-profile {
display: flex;
flex: 1 1 auto;
justify-content: center;
}
.user_mini_profile {
height: 300px;
}
}
}

View File

@ -0,0 +1,14 @@
{% load static %}
{% load i18n %}
<div x-data="existing_user_subscription_form" class="form-content existing-user">
<fieldset>
{{ form.as_p }}
</fieldset>
<div
id="subscription-form-user-mini-profile"
x-html="profileFragment"
:aria-busy="loading"
></div>
</div>

View File

@ -0,0 +1 @@
{{ form.as_p }}

View File

@ -0,0 +1,10 @@
<form
hx-post="{{ post_url }}"
hx-target="this"
hx-disabled-elt="find input[type='submit']"
hx-swap="outerHTML"
>
{% csrf_token %}
{{ form }}
<input type="submit" value="{% trans %}Save{% endtrans %}">
</form>

View File

@ -0,0 +1,30 @@
<div class="alert alert-green">
<div class="alert-main">
<h3 class="alert-title">
{% trans user=subscription.member %}Subscription created for {{ user }}{% endtrans %}
</h3>
<p>
{% trans trimmed
user=subscription.member.get_short_name(),
type=subscription.subscription_type,
end=subscription.subscription_end
%}
{{ user }} received its new {{ type }} subscription.
It will be active until {{ end }} included.
{% endtrans %}
</p>
</div>
<div class="alert-aside">
<a class="btn btn-blue" href="{{ subscription.member.get_absolute_url() }}">
{% trans %}Go to user profile{% endtrans %}
</a>
<a class="btn btn-grey" href="{{ url("subscription:subscription") }}">
{# We don't know if this fragment is displayed after creating a subscription
for a previously existing user or for a newly created one.
Thus, we don't know which form should be used to create another subscription
in this place.
Therefore, we reload the entire page. It just works. #}
{% trans %}Create another subscription{% endtrans %}
</a>
</div>
</div>

View File

@ -1,62 +1,45 @@
{% extends "core/base.jinja" %} {% extends "core/base.jinja" %}
{% from "core/macros.jinja" import tabs %}
{% block title %} {% block title %}
{% trans %}New subscription{% endtrans %} {% trans %}New subscription{% endtrans %}
{% endblock %} {% endblock %}
{# The following statics are bundled with our autocomplete select.
However, if one tries to swap a form by another, then the urls in script-once
and link-once disappear.
So we give them here.
If the aforementioned bug is resolved, you can remove this. #}
{% block additional_js %}
<script type="module" src="{{ static("bundled/core/components/ajax-select-index.ts") }}"></script>
<script
type="module"
src="{{ static("bundled/subscription/creation-form-existing-user-index.ts") }}"
></script>
{% endblock %}
{% block additional_css %}
<link rel="stylesheet" href="{{ static("bundled/core/components/ajax-select-index.css") }}">
<link rel="stylesheet" href="{{ static("core/components/ajax-select.scss") }}">
<link rel="stylesheet" href="{{ static("subscription/css/subscription.scss") }}">
{% endblock %}
{% macro form_fragment(form_object, post_url) %}
{# Include the form fragment inside a with block,
in order to inject the right form in the right place #}
{% with form=form_object, post_url=post_url %}
{% include "subscription/fragments/creation_form.jinja" %}
{% endwith %}
{% endmacro %}
{% block content %} {% block content %}
<h3>{% trans %}New subscription{% endtrans %}</h3> <h3>{% trans %}New subscription{% endtrans %}</h3>
<div id="user_info"></div> <div id="subscription-form">
<form action="" method="post" id="subscription_form"> {% with title1=_("Existing member"), title2=_("New member") %}
{% csrf_token %} {{ tabs([
{{ form.non_field_errors() }} (title1, form_fragment(existing_user_form, existing_user_post_url)),
<p>{{ form.member.errors }}<label for="{{ form.member.name }}">{{ form.member.label }}</label> {{ form.member }}</p> (title2, form_fragment(new_user_form, new_user_post_url)),
<div id="new_member"> ]) }}
<p>{{ form.first_name.errors }}<label for="{{ form.first_name.name }}">{{ form.first_name.label }}</label> {{ form.first_name }}</p> {% endwith %}
<p>{{ form.last_name.errors }}<label for="{{ form.last_name.name }}">{{ form.last_name.label }}</label> {{ form.last_name }}</p> </div>
<p>{{ form.email.errors }}<label for="{{ form.email.name }}">{{ form.email.label }}</label> {{ form.email }}</p>
<p>{{ form.date_of_birth.errors }}<label for="{{ form.date_of_birth.name }}">{{ form.date_of_birth.label}}</label> {{ form.date_of_birth }}</p>
</div>
<p>{{ form.subscription_type.errors }}<label for="{{ form.subscription_type.name }}">{{ form.subscription_type.label }}</label> {{ form.subscription_type }}</p>
<p>{{ form.payment_method.errors }}<label for="{{ form.payment_method.name }}">{{ form.payment_method.label }}</label> {{
form.payment_method }}</p>
<p>{% trans %}Eboutic is reserved to specific users. In doubt, don't use it.{% endtrans %}</p>
<p>{{ form.location.errors }}<label for="{{ form.location.name }}">{{ form.location.label }}</label> {{ form.location }}</p>
<p><input type="submit" value="{% trans %}Save{% endtrans %}" /></p>
</form>
{% endblock %}
{% block script %}
{{ super() }}
<script type="text/javascript" charset="utf-8">
$( function() {
select = $("#id_member");
member_block = $("#subscription_form #new_member");
user_info = $("#user_info");
function display_new_member() {
if (select.val()) {
member_block.hide();
member_block.children().each(function() {
$(this).children().each(function() {
$(this).removeAttr('required');
});
});
user_info.load("/user/"+select.val()+"/mini");
user_info.show();
} else {
member_block.show();
member_block.children().each(function() {
$(this).children().each(function() {
$(this).prop('required', true);
});
});
user_info.empty();
user_info.hide();
}
}
select.on("change", display_new_member);
display_new_member();
} );
</script>
{% endblock %} {% endblock %}

View File

View File

@ -12,6 +12,8 @@
# OR WITHIN THE LOCAL FILE "LICENSE" # OR WITHIN THE LOCAL FILE "LICENSE"
# #
# #
"""Tests focused on the computing of subscription end, start and duration"""
from datetime import date from datetime import date
import freezegun import freezegun

View File

@ -0,0 +1,151 @@
"""Tests focused on testing subscription creation"""
from datetime import timedelta
from typing import Callable
import pytest
from dateutil.relativedelta import relativedelta
from django.test import Client
from django.urls import reverse
from django.utils.timezone import localdate
from model_bakery import baker
from pytest_django.asserts import assertRedirects
from pytest_django.fixtures import SettingsWrapper
from core.baker_recipes import board_user, old_subscriber_user, subscriber_user
from core.models import User
from subscription.forms import SubscriptionExistingUserForm, SubscriptionNewUserForm
@pytest.mark.django_db
@pytest.mark.parametrize(
"user_factory",
[old_subscriber_user.make, lambda: baker.make(User)],
)
def test_form_existing_user_valid(
user_factory: Callable[[], User], settings: SettingsWrapper
):
"""Test `SubscriptionExistingUserForm`"""
user = user_factory()
data = {
"member": user,
"subscription_type": "deux-semestres",
"location": settings.SITH_SUBSCRIPTION_LOCATIONS[0][0],
"payment_method": settings.SITH_SUBSCRIPTION_PAYMENT_METHOD[0][0],
}
form = SubscriptionExistingUserForm(data)
assert form.is_valid()
form.save()
user.refresh_from_db()
assert user.is_subscribed
@pytest.mark.django_db
def test_form_existing_user_invalid(settings: SettingsWrapper):
"""Test `SubscriptionExistingUserForm`, with users that shouldn't subscribe."""
user = subscriber_user.make()
# make sure the current subscription will end in a long time
last_sub = user.subscriptions.order_by("subscription_end").last()
last_sub.subscription_end = localdate() + timedelta(weeks=50)
last_sub.save()
data = {
"member": user,
"subscription_type": "deux-semestres",
"location": settings.SITH_SUBSCRIPTION_LOCATIONS[0][0],
"payment_method": settings.SITH_SUBSCRIPTION_PAYMENT_METHOD[0][0],
}
form = SubscriptionExistingUserForm(data)
assert not form.is_valid()
with pytest.raises(ValueError):
form.save()
@pytest.mark.django_db
def test_form_new_user(settings: SettingsWrapper):
data = {
"first_name": "John",
"last_name": "Doe",
"email": "jdoe@utbm.fr",
"date_of_birth": localdate() - relativedelta(years=18),
"subscription_type": "deux-semestres",
"location": settings.SITH_SUBSCRIPTION_LOCATIONS[0][0],
"payment_method": settings.SITH_SUBSCRIPTION_PAYMENT_METHOD[0][0],
}
form = SubscriptionNewUserForm(data)
assert form.is_valid()
form.save()
user = User.objects.get(email="jdoe@utbm.fr")
assert user.username == "jdoe"
assert user.is_subscribed
# if trying to instantiate a new form with the same email,
# it should fail
form = SubscriptionNewUserForm(data)
assert not form.is_valid()
with pytest.raises(ValueError):
form.save()
@pytest.mark.django_db
@pytest.mark.parametrize(
"user_factory", [lambda: baker.make(User, is_superuser=True), board_user.make]
)
def test_load_page(client: Client, user_factory: Callable[[], User]):
"""Just check the page doesn't crash."""
client.force_login(user_factory())
res = client.get(reverse("subscription:subscription"))
assert res.status_code == 200
@pytest.mark.django_db
def test_submit_form_existing_user(client: Client, settings: SettingsWrapper):
client.force_login(board_user.make())
user = old_subscriber_user.make()
response = client.post(
reverse("subscription:fragment-existing-user"),
{
"member": user.id,
"subscription_type": "deux-semestres",
"location": settings.SITH_SUBSCRIPTION_LOCATIONS[0][0],
"payment_method": settings.SITH_SUBSCRIPTION_PAYMENT_METHOD[0][0],
},
)
user.refresh_from_db()
assert user.is_subscribed
current_subscription = user.subscriptions.order_by("-subscription_start").first()
assertRedirects(
response,
reverse(
"subscription:creation-success",
kwargs={"subscription_id": current_subscription.id},
),
)
@pytest.mark.django_db
def test_submit_form_new_user(client: Client, settings: SettingsWrapper):
client.force_login(board_user.make())
response = client.post(
reverse("subscription:fragment-new-user"),
{
"first_name": "John",
"last_name": "Doe",
"email": "jdoe@utbm.fr",
"date_of_birth": localdate() - relativedelta(years=18),
"subscription_type": "deux-semestres",
"location": settings.SITH_SUBSCRIPTION_LOCATIONS[0][0],
"payment_method": settings.SITH_SUBSCRIPTION_PAYMENT_METHOD[0][0],
},
)
user = User.objects.get(email="jdoe@utbm.fr")
assert user.is_subscribed
current_subscription = user.subscriptions.order_by("-subscription_start").first()
assertRedirects(
response,
reverse(
"subscription:creation-success",
kwargs={"subscription_id": current_subscription.id},
),
)

View File

@ -15,10 +15,31 @@
from django.urls import path from django.urls import path
from subscription.views import NewSubscription, SubscriptionsStatsView from subscription.views import (
CreateSubscriptionExistingUserFragment,
CreateSubscriptionNewUserFragment,
NewSubscription,
SubscriptionCreatedFragment,
SubscriptionsStatsView,
)
urlpatterns = [ urlpatterns = [
# Subscription views # Subscription views
path("", NewSubscription.as_view(), name="subscription"), path("", NewSubscription.as_view(), name="subscription"),
path(
"fragment/existing-user/",
CreateSubscriptionExistingUserFragment.as_view(),
name="fragment-existing-user",
),
path(
"fragment/new-user/",
CreateSubscriptionNewUserFragment.as_view(),
name="fragment-new-user",
),
path(
"fragment/<int:subscription_id>/creation-success",
SubscriptionCreatedFragment.as_view(),
name="creation-success",
),
path("stats/", SubscriptionsStatsView.as_view(), name="stats"), path("stats/", SubscriptionsStatsView.as_view(), name="stats"),
] ]

View File

@ -13,166 +13,96 @@
# #
# #
import secrets
from django import forms
from django.conf import settings from django.conf import settings
from django.core.exceptions import PermissionDenied, ValidationError from django.contrib.auth.mixins import UserPassesTestMixin
from django.urls import reverse_lazy from django.core.exceptions import PermissionDenied
from django.utils.translation import gettext_lazy as _ from django.urls import reverse, reverse_lazy
from django.views.generic.edit import CreateView, FormView from django.utils.timezone import localdate
from django.views.generic import CreateView, DetailView, TemplateView
from django.views.generic.edit import FormView
from core.models import User from subscription.forms import (
from core.views.forms import SelectDate, SelectDateTime SelectionDateForm,
from core.views.widgets.select import AutoCompleteSelectUser SubscriptionExistingUserForm,
SubscriptionNewUserForm,
)
from subscription.models import Subscription from subscription.models import Subscription
class SelectionDateForm(forms.Form): class CanCreateSubscriptionMixin(UserPassesTestMixin):
def __init__(self, *args, **kwargs): def test_func(self):
super().__init__(*args, **kwargs) return self.request.user.can_create_subscription
self.fields["start_date"] = forms.DateTimeField(
label=_("Start date"), widget=SelectDateTime, required=True
)
self.fields["end_date"] = forms.DateTimeField(
label=_("End date"), widget=SelectDateTime, required=True
)
class SubscriptionForm(forms.ModelForm): class NewSubscription(CanCreateSubscriptionMixin, TemplateView):
class Meta:
model = Subscription
fields = ["member", "subscription_type", "payment_method", "location"]
widgets = {"member": AutoCompleteSelectUser}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields["member"].required = False
self.fields |= forms.fields_for_model(
User,
fields=["first_name", "last_name", "email", "date_of_birth"],
widgets={"date_of_birth": SelectDate},
)
def clean_member(self):
subscriber = self.cleaned_data.get("member")
if subscriber:
subscriber = User.objects.filter(id=subscriber.id).first()
return subscriber
def clean(self):
cleaned_data = super().clean()
if (
cleaned_data.get("member") is None
and "last_name" not in self.errors.as_data()
and "first_name" not in self.errors.as_data()
and "email" not in self.errors.as_data()
and "date_of_birth" not in self.errors.as_data()
):
self.errors.pop("member", None)
if self.errors:
return cleaned_data
if User.objects.filter(email=cleaned_data.get("email")).first() is not None:
self.add_error(
"email",
ValidationError(_("A user with that email address already exists")),
)
else:
u = User(
last_name=self.cleaned_data.get("last_name"),
first_name=self.cleaned_data.get("first_name"),
email=self.cleaned_data.get("email"),
date_of_birth=self.cleaned_data.get("date_of_birth"),
)
u.generate_username()
u.set_password(secrets.token_urlsafe(nbytes=10))
u.save()
cleaned_data["member"] = u
elif cleaned_data.get("member") is not None:
self.errors.pop("last_name", None)
self.errors.pop("first_name", None)
self.errors.pop("email", None)
self.errors.pop("date_of_birth", None)
if cleaned_data.get("member") is None:
# This should be handled here,
# but it is done in the Subscription model's clean method
# TODO investigate why!
raise ValidationError(
_(
"You must either choose an existing "
"user or create a new one properly"
)
)
return cleaned_data
class NewSubscription(CreateView):
template_name = "subscription/subscription.jinja" template_name = "subscription/subscription.jinja"
form_class = SubscriptionForm
def dispatch(self, request, *arg, **kwargs): def get_context_data(self, **kwargs):
if request.user.can_create_subscription: return super().get_context_data(**kwargs) | {
return super().dispatch(request, *arg, **kwargs) "existing_user_form": SubscriptionExistingUserForm(
raise PermissionDenied initial={"member": self.request.GET.get("member")}
),
"new_user_form": SubscriptionNewUserForm(),
"existing_user_post_url": reverse("subscription:fragment-existing-user"),
"new_user_post_url": reverse("subscription:fragment-new-user"),
}
def get_initial(self):
if "member" in self.request.GET:
return {
"member": self.request.GET["member"],
"subscription_type": "deux-semestres",
}
return {"subscription_type": "deux-semestres"}
def form_valid(self, form): class CreateSubscriptionFragment(CanCreateSubscriptionMixin, CreateView):
form.instance.subscription_start = Subscription.compute_start( template_name = "subscription/fragments/creation_form.jinja"
duration=settings.SITH_SUBSCRIPTIONS[form.instance.subscription_type][
"duration" def get_success_url(self):
], return reverse(
user=form.instance.member, "subscription:creation-success", kwargs={"subscription_id": self.object.id}
) )
form.instance.subscription_end = Subscription.compute_end(
duration=settings.SITH_SUBSCRIPTIONS[form.instance.subscription_type][
"duration" class CreateSubscriptionExistingUserFragment(CreateSubscriptionFragment):
], """Create a subscription for a user who already exists."""
start=form.instance.subscription_start,
user=form.instance.member, form_class = SubscriptionExistingUserForm
) extra_context = {"post_url": reverse_lazy("subscription:fragment-existing-user")}
return super().form_valid(form)
class CreateSubscriptionNewUserFragment(CreateSubscriptionFragment):
"""Create a subscription for a user who already exists."""
form_class = SubscriptionNewUserForm
extra_context = {"post_url": reverse_lazy("subscription:fragment-new-user")}
class SubscriptionCreatedFragment(CanCreateSubscriptionMixin, DetailView):
template_name = "subscription/fragments/creation_success.jinja"
model = Subscription
pk_url_kwarg = "subscription_id"
context_object_name = "subscription"
class SubscriptionsStatsView(FormView): class SubscriptionsStatsView(FormView):
template_name = "subscription/stats.jinja" template_name = "subscription/stats.jinja"
form_class = SelectionDateForm form_class = SelectionDateForm
success_url = reverse_lazy("subscriptions:stats")
def dispatch(self, request, *arg, **kwargs): def dispatch(self, request, *arg, **kwargs):
import datetime self.start_date = localdate()
self.start_date = datetime.datetime.today()
self.end_date = self.start_date self.end_date = self.start_date
res = super().dispatch(request, *arg, **kwargs)
if request.user.is_root or request.user.is_board_member: if request.user.is_root or request.user.is_board_member:
return res return super().dispatch(request, *arg, **kwargs)
raise PermissionDenied raise PermissionDenied
def post(self, request, *args, **kwargs): def post(self, request, *args, **kwargs):
self.form = self.get_form() self.form = self.get_form()
self.start_date = self.form["start_date"] self.start_date = self.form["start_date"]
self.end_date = self.form["end_date"] self.end_date = self.form["end_date"]
res = super().post(request, *args, **kwargs) return super().post(request, *args, **kwargs)
if request.user.is_root or request.user.is_board_member:
return res
raise PermissionDenied
def get_initial(self): def get_initial(self):
init = { return {
"start_date": self.start_date.strftime("%Y-%m-%d %H:%M:%S"), "start_date": self.start_date.strftime("%Y-%m-%d %H:%M:%S"),
"end_date": self.end_date.strftime("%Y-%m-%d %H:%M:%S"), "end_date": self.end_date.strftime("%Y-%m-%d %H:%M:%S"),
} }
return init
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
from subscription.models import Subscription
kwargs = super().get_context_data(**kwargs) kwargs = super().get_context_data(**kwargs)
kwargs["subscriptions_total"] = Subscription.objects.filter( kwargs["subscriptions_total"] = Subscription.objects.filter(
subscription_end__gte=self.end_date, subscription_start__lte=self.start_date subscription_end__gte=self.end_date, subscription_start__lte=self.start_date
@ -181,6 +111,3 @@ class SubscriptionsStatsView(FormView):
kwargs["payment_types"] = settings.SITH_COUNTER_PAYMENT_METHOD kwargs["payment_types"] = settings.SITH_COUNTER_PAYMENT_METHOD
kwargs["locations"] = settings.SITH_SUBSCRIPTION_LOCATIONS kwargs["locations"] = settings.SITH_SUBSCRIPTION_LOCATIONS
return kwargs return kwargs
def get_success_url(self, **kwargs):
return reverse_lazy("subscriptions:stats")

View File

@ -85,6 +85,7 @@ export default defineConfig((config: UserConfig) => {
inject({ inject({
// biome-ignore lint/style/useNamingConvention: that's how it's called // biome-ignore lint/style/useNamingConvention: that's how it's called
Alpine: "alpinejs", Alpine: "alpinejs",
htmx: "htmx.org",
}), }),
viteStaticCopy({ viteStaticCopy({
targets: [ targets: [