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
@cached_property
def can_create_subscription(self):
from club.models import Club
def can_create_subscription(self) -> bool:
from club.models import Membership
for club in Club.objects.filter(id__in=settings.SITH_CAN_CREATE_SUBSCRIPTIONS):
if club in self.clubs_with_rights:
return True
return False
return (
Membership.objects.board()
.ongoing()
.filter(club_id__in=settings.SITH_CAN_CREATE_SUBSCRIPTIONS)
.exists()
)
@cached_property
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 "forms";
/*--------------------------MEDIA QUERY HELPERS------------------------*/
$small-devices: 576px;
@ -13,91 +14,6 @@ body {
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] {
--loading-size: 50px;
--loading-stroke: 5px;
@ -262,8 +178,10 @@ a:not(.button) {
font-weight: normal;
color: white;
padding: 9px 13px;
margin: 3px;
border: none;
text-decoration: none;
text-align: center;
border-radius: 5px;
&.btn-blue {
@ -367,6 +285,49 @@ a:not(.button) {
.alert-aside {
display: flex;
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_mini_profile {
height: 100%;
width: 100%;
--gap-size: 1em;
max-height: 100%;
max-width: 100%;
display: flex;
flex-direction: column;
gap: var(--gap-size);
img {
max-width: 100%;
max-height: 100%;
max-width: 100%;
}
.user_mini_profile_infos {
padding: 0.2em;
height: 20%;
max-height: 20%;
display: flex;
flex-wrap: nowrap;
justify-content: space-around;
font-size: 0.9em;
div {
max-height: 100%;
}
.user_mini_profile_infos_text {
text-align: center;
@ -1276,10 +1237,10 @@ u,
}
.user_mini_profile_picture {
height: 80%;
display: flex;
justify-content: center;
align-items: center;
max-height: calc(80% - var(--gap-size));
max-width: 100%;
display: block;
margin: auto;
}
}

View File

@ -66,7 +66,12 @@
</div>
{% if user.promo and user.promo_has_logo() %}
<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>
{% endif %}
</div>
@ -74,8 +79,11 @@
{% if user.profile_pict %}
<img src="{{ user.profile_pict.get_download_url() }}" alt="{% trans %}Profile{% endtrans %}" />
{% else %}
<img src="{{ static('core/img/unknown.jpg') }}" alt="{% trans %}Profile{% endtrans %}"
title="{% trans %}Profile{% endtrans %}" />
<img
src="{{ static('core/img/unknown.jpg') }}"
alt="{% trans %}Profile{% endtrans %}"
title="{% trans %}Profile{% endtrans %}"
/>
{% endif %}
</div>
</div>
@ -170,12 +178,12 @@
{% endmacro %}
{% 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
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:
current_page (django.core.paginator.Page): the current page object
@ -247,9 +255,9 @@
{% macro select_all_checkbox(form_id) %}
<script type="text/javascript">
function checkbox_{{form_id}}(value) {
list = document.getElementById("{{ form_id }}").getElementsByTagName("input");
for (let element of list){
if (element.type == "checkbox"){
const inputs = document.getElementById("{{ form_id }}").getElementsByTagName("input");
for (let element of inputs){
if (element.type === "checkbox"){
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}}(false);">{% trans %}Unselect All{% endtrans %}</button>
{% 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 ""
msgstr ""
"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"
"Last-Translator: Maréchal <thomas.girod@utbm.fr\n"
"Language-Team: AE info <ae.info@utbm.fr>\n"
@ -40,7 +40,7 @@ msgstr "code postal"
msgid "country"
msgstr "pays"
#: accounting/models.py:67 core/models.py:390
#: accounting/models.py:67 core/models.py:391
msgid "phone"
msgstr "téléphone"
@ -126,8 +126,8 @@ msgstr "numéro"
msgid "journal"
msgstr "classeur"
#: accounting/models.py:256 core/models.py:945 core/models.py:1456
#: core/models.py:1501 core/models.py:1530 core/models.py:1554
#: accounting/models.py:256 core/models.py:956 core/models.py:1467
#: core/models.py:1512 core/models.py:1541 core/models.py:1565
#: counter/models.py:689 counter/models.py:793 counter/models.py:997
#: eboutic/models.py:57 eboutic/models.py:193 forum/models.py:312
#: forum/models.py:413
@ -165,7 +165,7 @@ msgid "accounting type"
msgstr "type comptable"
#: 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
msgid "label"
msgstr "étiquette"
@ -218,7 +218,7 @@ msgstr "Compte"
msgid "Company"
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"
msgstr "Autre"
@ -362,8 +362,8 @@ msgstr "Compte en banque : "
#: core/templates/core/file_detail.jinja:62
#: core/templates/core/file_moderation.jinja:48
#: core/templates/core/group_detail.jinja:26
#: core/templates/core/group_list.jinja:25 core/templates/core/macros.jinja:96
#: core/templates/core/macros.jinja:115 core/templates/core/page_prop.jinja:14
#: core/templates/core/group_list.jinja:25 core/templates/core/macros.jinja:104
#: 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:77
#: core/templates/core/user_clubs.jinja:34
@ -372,7 +372,7 @@ msgstr "Compte en banque : "
#: core/templates/core/user_preferences.jinja:48
#: counter/templates/counter/last_ops.jinja:35
#: 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
#: launderette/templates/launderette/launderette_admin.jinja:16
#: 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:33
#: 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
#: launderette/templates/launderette/launderette_list.jinja:16
#: pedagogy/templates/pedagogy/guide.jinja:98
@ -774,7 +774,7 @@ msgstr "Opération liée : "
#: core/templates/core/user_preferences.jinja:65
#: counter/templates/counter/cash_register_summary.jinja:28
#: 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/edit_profile.jinja:13
#: trombi/templates/trombi/user_tools.jinja:13
@ -956,7 +956,7 @@ msgid "Begin date"
msgstr "Date de début"
#: 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"
msgstr "Date de fin"
@ -1025,11 +1025,11 @@ msgstr "actif"
msgid "short description"
msgstr "description courte"
#: club/models.py:81 core/models.py:392
#: club/models.py:81 core/models.py:393
msgid "address"
msgstr "Adresse"
#: club/models.py:98 core/models.py:303
#: club/models.py:98 core/models.py:304
msgid "home"
msgstr "home"
@ -1048,12 +1048,12 @@ msgstr "Un club avec ce nom UNIX existe déjà."
msgid "user"
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
msgid "role"
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
#: election/models.py:188 forum/models.py:61 forum/models.py:245
msgid "description"
@ -1068,7 +1068,7 @@ msgid "Enter a valid address. Only the root of the address is needed."
msgstr ""
"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"
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"
#: club/templates/club/mailing.jinja:72
#: subscription/templates/subscription/subscription.jinja:39
msgid "New member"
msgstr "Nouveau membre"
@ -1439,7 +1440,7 @@ msgstr "résumé"
msgid "content"
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
msgid "type"
msgstr "type"
@ -1489,7 +1490,7 @@ msgstr "weekmail"
msgid "rank"
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"
msgstr "fichier"
@ -1917,7 +1918,7 @@ msgid "Format: 16:9 | Resolution: 1920x1080"
msgstr "Format : 16:9 | Résolution : 1920x1080"
#: com/views.py:78 com/views.py:199 election/views.py:167
#: subscription/views.py:35
#: subscription/forms.py:18
msgid "Start date"
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 "
"Weekmail."
#: core/models.py:84
#: core/models.py:85
msgid "meta group status"
msgstr "status du meta-groupe"
#: core/models.py:86
#: core/models.py:87
msgid "Whether a group is a meta group or not"
msgstr "Si un groupe est un meta-groupe ou pas"
#: core/models.py:172
#: core/models.py:173
#, python-format
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)"
#: core/models.py:256
#: core/models.py:257
msgid "username"
msgstr "nom d'utilisateur"
#: core/models.py:260
#: core/models.py:261
msgid "Required. 254 characters or fewer. Letters, digits and ./+/-/_ only."
msgstr ""
"Requis. Pas plus de 254 caractères. Uniquement des lettres, numéros, et ./"
"+/-/_"
#: core/models.py:266
#: core/models.py:267
msgid ""
"Enter a valid username. This value may contain only letters, numbers and ./"
"+/-/_ characters."
@ -2003,43 +2004,43 @@ msgstr ""
"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."
msgstr "Un utilisateur de ce nom existe déjà"
#: core/models.py:274
#: core/models.py:275
msgid "first name"
msgstr "Prénom"
#: core/models.py:275
#: core/models.py:276
msgid "last name"
msgstr "Nom"
#: core/models.py:276
#: core/models.py:277
msgid "email address"
msgstr "adresse email"
#: core/models.py:277
#: core/models.py:278
msgid "date of birth"
msgstr "date de naissance"
#: core/models.py:278
#: core/models.py:279
msgid "nick name"
msgstr "surnom"
#: core/models.py:280
#: core/models.py:281
msgid "staff status"
msgstr "status \"staff\""
#: core/models.py:282
#: core/models.py:283
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."
#: core/models.py:285
#: core/models.py:286
msgid "active"
msgstr "actif"
#: core/models.py:288
#: core/models.py:289
msgid ""
"Designates whether this user should be treated as active. Unselect this "
"instead of deleting accounts."
@ -2047,164 +2048,164 @@ msgstr ""
"Est-ce que l'utilisateur doit être traité comme actif. Désélectionnez au "
"lieu de supprimer les comptes."
#: core/models.py:292
#: core/models.py:293
msgid "date joined"
msgstr "date d'inscription"
#: core/models.py:293
#: core/models.py:294
msgid "last update"
msgstr "dernière mise à jour"
#: core/models.py:295
#: core/models.py:296
msgid "superuser"
msgstr "super-utilisateur"
#: core/models.py:297
#: core/models.py:298
msgid "Designates whether this user is a superuser. "
msgstr "Est-ce que l'utilisateur est super-utilisateur."
#: core/models.py:311
#: core/models.py:312
msgid "profile"
msgstr "profil"
#: core/models.py:319
#: core/models.py:320
msgid "avatar"
msgstr "avatar"
#: core/models.py:327
#: core/models.py:328
msgid "scrub"
msgstr "blouse"
#: core/models.py:333
#: core/models.py:334
msgid "sex"
msgstr "Genre"
#: core/models.py:337
#: core/models.py:338
msgid "Man"
msgstr "Homme"
#: core/models.py:337
#: core/models.py:338
msgid "Woman"
msgstr "Femme"
#: core/models.py:339
#: core/models.py:340
msgid "pronouns"
msgstr "pronoms"
#: core/models.py:341
#: core/models.py:342
msgid "tshirt size"
msgstr "taille de t-shirt"
#: core/models.py:344
#: core/models.py:345
msgid "-"
msgstr "-"
#: core/models.py:345
#: core/models.py:346
msgid "XS"
msgstr "XS"
#: core/models.py:346
#: core/models.py:347
msgid "S"
msgstr "S"
#: core/models.py:347
#: core/models.py:348
msgid "M"
msgstr "M"
#: core/models.py:348
#: core/models.py:349
msgid "L"
msgstr "L"
#: core/models.py:349
#: core/models.py:350
msgid "XL"
msgstr "XL"
#: core/models.py:350
#: core/models.py:351
msgid "XXL"
msgstr "XXL"
#: core/models.py:351
#: core/models.py:352
msgid "XXXL"
msgstr "XXXL"
#: core/models.py:359
#: core/models.py:360
msgid "Student"
msgstr "Étudiant"
#: core/models.py:360
#: core/models.py:361
msgid "Administrative agent"
msgstr "Personnel administratif"
#: core/models.py:361
#: core/models.py:362
msgid "Teacher"
msgstr "Enseignant"
#: core/models.py:362
#: core/models.py:363
msgid "Agent"
msgstr "Personnel"
#: core/models.py:363
#: core/models.py:364
msgid "Doctor"
msgstr "Doctorant"
#: core/models.py:364
#: core/models.py:365
msgid "Former student"
msgstr "Ancien étudiant"
#: core/models.py:365
#: core/models.py:366
msgid "Service"
msgstr "Service"
#: core/models.py:371
#: core/models.py:372
msgid "department"
msgstr "département"
#: core/models.py:378
#: core/models.py:379
msgid "dpt option"
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"
msgstr "semestre"
#: core/models.py:381
#: core/models.py:382
msgid "quote"
msgstr "citation"
#: core/models.py:382
#: core/models.py:383
msgid "school"
msgstr "école"
#: core/models.py:384
#: core/models.py:385
msgid "promo"
msgstr "promo"
#: core/models.py:387
#: core/models.py:388
msgid "forum signature"
msgstr "signature du forum"
#: core/models.py:389
#: core/models.py:390
msgid "second email address"
msgstr "adresse email secondaire"
#: core/models.py:391
#: core/models.py:392
msgid "parent phone"
msgstr "téléphone des parents"
#: core/models.py:394
#: core/models.py:395
msgid "parent address"
msgstr "adresse des parents"
#: core/models.py:397
#: core/models.py:398
msgid "is subscriber viewable"
msgstr "profil visible par les cotisants"
#: core/models.py:591
#: core/models.py:594
msgid "A user with that username already exists"
msgstr "Un utilisateur de ce nom d'utilisateur existe déjà"
#: core/models.py:750 core/templates/core/macros.jinja:75
#: core/templates/core/macros.jinja:77 core/templates/core/macros.jinja:78
#: core/models.py:761 core/templates/core/macros.jinja:80
#: 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:101
#: 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:113
#: core/templates/core/user_edit.jinja:21
#: election/templates/election/election_detail.jinja:132
#: election/templates/election/election_detail.jinja:134
#: election/templates/election/election_detail.jinja:136
#: election/templates/election/election_detail.jinja:138
#: forum/templates/forum/macros.jinja:105
#: forum/templates/forum/macros.jinja:107
#: forum/templates/forum/macros.jinja:109
@ -2223,101 +2224,101 @@ msgstr "Un utilisateur de ce nom d'utilisateur existe déjà"
msgid "Profile"
msgstr "Profil"
#: core/models.py:861
#: core/models.py:872
msgid "Visitor"
msgstr "Visiteur"
#: core/models.py:868
#: core/models.py:879
msgid "receive the Weekmail"
msgstr "recevoir le Weekmail"
#: core/models.py:869
#: core/models.py:880
msgid "show your stats to others"
msgstr "montrez vos statistiques aux autres"
#: core/models.py:871
#: core/models.py:882
msgid "get a notification for every click"
msgstr "avoir une notification pour chaque click"
#: core/models.py:874
#: core/models.py:885
msgid "get a notification for every refilling"
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"
msgstr "nom du fichier"
#: core/models.py:904 core/models.py:1257
#: core/models.py:915 core/models.py:1268
msgid "parent"
msgstr "parent"
#: core/models.py:918
#: core/models.py:929
msgid "compressed file"
msgstr "version allégée"
#: core/models.py:925
#: core/models.py:936
msgid "thumbnail"
msgstr "miniature"
#: core/models.py:933 core/models.py:950
#: core/models.py:944 core/models.py:961
msgid "owner"
msgstr "propriétaire"
#: core/models.py:937 core/models.py:1274
#: core/models.py:948 core/models.py:1285
msgid "edit group"
msgstr "groupe d'édition"
#: core/models.py:940 core/models.py:1277
#: core/models.py:951 core/models.py:1288
msgid "view group"
msgstr "groupe de vue"
#: core/models.py:942
#: core/models.py:953
msgid "is folder"
msgstr "est un dossier"
#: core/models.py:943
#: core/models.py:954
msgid "mime type"
msgstr "type mime"
#: core/models.py:944
#: core/models.py:955
msgid "size"
msgstr "taille"
#: core/models.py:955
#: core/models.py:966
msgid "asked for removal"
msgstr "retrait demandé"
#: core/models.py:957
#: core/models.py:968
msgid "is in the SAS"
msgstr "est dans le SAS"
#: core/models.py:1026
#: core/models.py:1037
msgid "Character '/' not authorized in name"
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"
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"
msgstr ""
"Vous ne pouvez pas mettre un fichier enfant de quelque chose qui n'est pas "
"un dossier"
#: core/models.py:1046
#: core/models.py:1057
msgid "Duplicate file"
msgstr "Un fichier de ce nom existe déjà"
#: core/models.py:1063
#: core/models.py:1074
msgid "You must provide a file"
msgstr "Vous devez fournir un fichier"
#: core/models.py:1240
#: core/models.py:1251
msgid "page unix name"
msgstr "nom unix de la page"
#: core/models.py:1246
#: core/models.py:1257
msgid ""
"Enter a valid page name. This value may contain only unaccented letters, "
"numbers and ./+/-/_ characters."
@ -2325,55 +2326,55 @@ msgstr ""
"Entrez un nom de page correct. Uniquement des lettres non accentuées, "
"numéros, et ./+/-/_"
#: core/models.py:1264
#: core/models.py:1275
msgid "page name"
msgstr "nom de la page"
#: core/models.py:1269
#: core/models.py:1280
msgid "owner group"
msgstr "groupe propriétaire"
#: core/models.py:1282
#: core/models.py:1293
msgid "lock user"
msgstr "utilisateur bloquant"
#: core/models.py:1289
#: core/models.py:1300
msgid "lock_timeout"
msgstr "décompte du déblocage"
#: core/models.py:1339
#: core/models.py:1350
msgid "Duplicate page"
msgstr "Une page de ce nom existe déjà"
#: core/models.py:1342
#: core/models.py:1353
msgid "Loop in page tree"
msgstr "Boucle dans l'arborescence des pages"
#: core/models.py:1453
#: core/models.py:1464
msgid "revision"
msgstr "révision"
#: core/models.py:1454
#: core/models.py:1465
msgid "page title"
msgstr "titre de la page"
#: core/models.py:1455
#: core/models.py:1466
msgid "page content"
msgstr "contenu de la page"
#: core/models.py:1496
#: core/models.py:1507
msgid "url"
msgstr "url"
#: core/models.py:1497
#: core/models.py:1508
msgid "param"
msgstr "param"
#: core/models.py:1502
#: core/models.py:1513
msgid "viewed"
msgstr "vue"
#: core/models.py:1560
#: core/models.py:1571
msgid "operation type"
msgstr "type d'opération"
@ -2393,27 +2394,27 @@ msgstr "500, Erreur Serveur"
msgid "Welcome!"
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"
msgstr "Contacts"
#: core/templates/core/base.jinja:105
#: core/templates/core/base.jinja:106
msgid "Legal notices"
msgstr "Mentions légales"
#: core/templates/core/base.jinja:106
#: core/templates/core/base.jinja:107
msgid "Intellectual property"
msgstr "Propriété intellectuelle"
#: core/templates/core/base.jinja:107
#: core/templates/core/base.jinja:108
msgid "Help & Documentation"
msgstr "Aide & Documentation"
#: core/templates/core/base.jinja:108
#: core/templates/core/base.jinja:109
msgid "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"
msgstr "Site réalisé par le Pôle Informatique de l'AE"
@ -2752,29 +2753,29 @@ msgstr "Partager sur Facebook"
msgid "Tweet"
msgstr "Tweeter"
#: core/templates/core/macros.jinja:85
#: core/templates/core/macros.jinja:93
#, python-format
msgid "Subscribed until %(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: "
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"
msgstr "Créneau"
#: core/templates/core/macros.jinja:104
#: core/templates/core/macros.jinja:112
#: launderette/templates/launderette/launderette_admin.jinja:20
msgid "Tokens"
msgstr "Jetons"
#: core/templates/core/macros.jinja:258
#: core/templates/core/macros.jinja:266
msgid "Select All"
msgstr "Tout sélectionner"
#: core/templates/core/macros.jinja:259
#: core/templates/core/macros.jinja:267
msgid "Unselect All"
msgstr "Tout désélectionner"
@ -3135,8 +3136,8 @@ msgid "Not subscribed"
msgstr "Non cotisant"
#: core/templates/core/user_detail.jinja:162
#: subscription/templates/subscription/subscription.jinja:4
#: subscription/templates/subscription/subscription.jinja:8
#: subscription/templates/subscription/subscription.jinja:6
#: subscription/templates/subscription/subscription.jinja:37
msgid "New subscription"
msgstr "Nouvelle cotisation"
@ -4512,7 +4513,7 @@ msgstr "candidature"
#: election/templates/election/candidate_form.jinja:4
#: election/templates/election/candidate_form.jinja:13
#: election/templates/election/election_detail.jinja:175
#: election/templates/election/election_detail.jinja:179
msgid "Candidate"
msgstr "Candidater"
@ -4520,20 +4521,20 @@ msgstr "Candidater"
msgid "Candidature are closed for this election"
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 "
msgstr "Votes fermés"
#: election/templates/election/election_detail.jinja:21
#: election/templates/election/election_detail.jinja:25
msgid "Polls closed "
msgstr "Votes fermés"
#: election/templates/election/election_detail.jinja:23
#: election/templates/election/election_detail.jinja:27
msgid "Polls will open "
msgstr "Les votes ouvriront "
#: election/templates/election/election_detail.jinja:25
#: 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:35
#: election/templates/election/election_list.jinja:40
@ -4542,58 +4543,58 @@ msgstr "Les votes ouvriront "
msgid " at "
msgstr " à "
#: election/templates/election/election_detail.jinja:26
#: election/templates/election/election_detail.jinja:30
msgid "and will close "
msgstr "et fermeront"
#: election/templates/election/election_detail.jinja:34
#: election/templates/election/election_detail.jinja:38
msgid "You already have submitted your 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."
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"
msgstr "Vote blanc"
#: election/templates/election/election_detail.jinja:71
#: election/templates/election/election_detail.jinja:75
msgid "You may choose up to"
msgstr "Vous pouvez choisir jusqu'à"
#: election/templates/election/election_detail.jinja:71
#: election/templates/election/election_detail.jinja:75
msgid "people."
msgstr "personne(s)"
#: election/templates/election/election_detail.jinja:108
#: election/templates/election/election_detail.jinja:112
msgid "Choose blank vote"
msgstr "Choisir de voter blanc"
#: election/templates/election/election_detail.jinja:116
#: election/templates/election/election_detail.jinja:159
#: election/templates/election/election_detail.jinja:120
#: election/templates/election/election_detail.jinja:163
msgid "votes"
msgstr "votes"
#: election/templates/election/election_detail.jinja:178
#: election/templates/election/election_detail.jinja:182
msgid "Add a new list"
msgstr "Ajouter une nouvelle liste"
#: election/templates/election/election_detail.jinja:182
#: election/templates/election/election_detail.jinja:186
msgid "Add a new role"
msgstr "Ajouter un nouveau rôle"
#: election/templates/election/election_detail.jinja:192
#: election/templates/election/election_detail.jinja:196
msgid "Submit the vote !"
msgstr "Envoyer le vote !"
#: election/templates/election/election_detail.jinja:201
#: election/templates/election/election_detail.jinja:206
#: election/templates/election/election_detail.jinja:205
#: election/templates/election/election_detail.jinja:210
msgid "Show more"
msgstr "Montrer plus"
#: election/templates/election/election_detail.jinja:202
#: election/templates/election/election_detail.jinja:207
#: election/templates/election/election_detail.jinja:206
#: election/templates/election/election_detail.jinja:211
msgid "Show less"
msgstr "Montrer moins"
@ -5790,6 +5791,10 @@ msgstr "Weekmail envoyé avec succès"
msgid "AE tee-shirt"
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
msgid "Bad subscription type"
msgstr "Mauvais type de cotisation"
@ -5814,10 +5819,36 @@ msgstr "fin de la cotisation"
msgid "location"
msgstr "lieu"
#: subscription/models.py:106
#: subscription/models.py:107
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"
#: 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
msgid "Total subscriptions"
msgstr "Cotisations totales"
@ -5826,20 +5857,6 @@ msgstr "Cotisations totales"
msgid "Subscriptions by 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
msgid "subscription deadline"
msgstr "fin des inscriptions"

View File

@ -1,9 +1,6 @@
from pydantic import TypeAdapter
from core.views.widgets.select import (
AutoCompleteSelect,
AutoCompleteSelectMultiple,
)
from core.views.widgets.select import AutoCompleteSelect, AutoCompleteSelectMultiple
from sas.models import Album
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):
today = localdate()
active_subscriptions = Subscription.objects.exclude(pk=self.pk).filter(
subscription_start__gte=today, subscription_end__lte=today
threshold = timedelta(weeks=settings.SITH_SUBSCRIPTION_END)
# 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 (
s.is_valid_now()
and s.subscription_end - timedelta(weeks=settings.SITH_SUBSCRIPTION_END)
> date.today()
):
raise ValidationError(
_("You can not subscribe many time for the same period")
)
if overlapping_subscriptions.exists():
raise ValidationError(
_("You can not subscribe many time for the same period")
)
@staticmethod
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:
"""Computes the start date of the subscription.
@ -132,7 +133,7 @@ class Subscription(models.Model):
@staticmethod
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:
"""Compute the end date of the subscription.
@ -163,3 +164,19 @@ class Subscription(models.Model):
def is_valid_now(self):
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" %}
{% from "core/macros.jinja" import tabs %}
{% block title %}
{% trans %}New subscription{% endtrans %}
{% 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 %}
<h3>{% trans %}New subscription{% endtrans %}</h3>
<div id="user_info"></div>
<form action="" method="post" id="subscription_form">
{% csrf_token %}
{{ form.non_field_errors() }}
<p>{{ form.member.errors }}<label for="{{ form.member.name }}">{{ form.member.label }}</label> {{ form.member }}</p>
<div id="new_member">
<p>{{ form.first_name.errors }}<label for="{{ form.first_name.name }}">{{ form.first_name.label }}</label> {{ form.first_name }}</p>
<p>{{ form.last_name.errors }}<label for="{{ form.last_name.name }}">{{ form.last_name.label }}</label> {{ form.last_name }}</p>
<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>
<div id="subscription-form">
{% with title1=_("Existing member"), title2=_("New member") %}
{{ tabs([
(title1, form_fragment(existing_user_form, existing_user_post_url)),
(title2, form_fragment(new_user_form, new_user_post_url)),
]) }}
{% endwith %}
</div>
{% endblock %}

View File

View File

@ -12,6 +12,8 @@
# OR WITHIN THE LOCAL FILE "LICENSE"
#
#
"""Tests focused on the computing of subscription end, start and duration"""
from datetime import date
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 subscription.views import NewSubscription, SubscriptionsStatsView
from subscription.views import (
CreateSubscriptionExistingUserFragment,
CreateSubscriptionNewUserFragment,
NewSubscription,
SubscriptionCreatedFragment,
SubscriptionsStatsView,
)
urlpatterns = [
# Subscription views
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"),
]

View File

@ -13,166 +13,96 @@
#
#
import secrets
from django import forms
from django.conf import settings
from django.core.exceptions import PermissionDenied, ValidationError
from django.urls import reverse_lazy
from django.utils.translation import gettext_lazy as _
from django.views.generic.edit import CreateView, FormView
from django.contrib.auth.mixins import UserPassesTestMixin
from django.core.exceptions import PermissionDenied
from django.urls import reverse, reverse_lazy
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 core.views.forms import SelectDate, SelectDateTime
from core.views.widgets.select import AutoCompleteSelectUser
from subscription.forms import (
SelectionDateForm,
SubscriptionExistingUserForm,
SubscriptionNewUserForm,
)
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 CanCreateSubscriptionMixin(UserPassesTestMixin):
def test_func(self):
return self.request.user.can_create_subscription
class SubscriptionForm(forms.ModelForm):
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):
class NewSubscription(CanCreateSubscriptionMixin, TemplateView):
template_name = "subscription/subscription.jinja"
form_class = SubscriptionForm
def dispatch(self, request, *arg, **kwargs):
if request.user.can_create_subscription:
return super().dispatch(request, *arg, **kwargs)
raise PermissionDenied
def get_context_data(self, **kwargs):
return super().get_context_data(**kwargs) | {
"existing_user_form": SubscriptionExistingUserForm(
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):
form.instance.subscription_start = Subscription.compute_start(
duration=settings.SITH_SUBSCRIPTIONS[form.instance.subscription_type][
"duration"
],
user=form.instance.member,
class CreateSubscriptionFragment(CanCreateSubscriptionMixin, CreateView):
template_name = "subscription/fragments/creation_form.jinja"
def get_success_url(self):
return reverse(
"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"
],
start=form.instance.subscription_start,
user=form.instance.member,
)
return super().form_valid(form)
class CreateSubscriptionExistingUserFragment(CreateSubscriptionFragment):
"""Create a subscription for a user who already exists."""
form_class = SubscriptionExistingUserForm
extra_context = {"post_url": reverse_lazy("subscription:fragment-existing-user")}
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):
template_name = "subscription/stats.jinja"
form_class = SelectionDateForm
success_url = reverse_lazy("subscriptions:stats")
def dispatch(self, request, *arg, **kwargs):
import datetime
self.start_date = datetime.datetime.today()
self.start_date = localdate()
self.end_date = self.start_date
res = super().dispatch(request, *arg, **kwargs)
if request.user.is_root or request.user.is_board_member:
return res
return super().dispatch(request, *arg, **kwargs)
raise PermissionDenied
def post(self, request, *args, **kwargs):
self.form = self.get_form()
self.start_date = self.form["start_date"]
self.end_date = self.form["end_date"]
res = super().post(request, *args, **kwargs)
if request.user.is_root or request.user.is_board_member:
return res
raise PermissionDenied
return super().post(request, *args, **kwargs)
def get_initial(self):
init = {
return {
"start_date": self.start_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):
from subscription.models import Subscription
kwargs = super().get_context_data(**kwargs)
kwargs["subscriptions_total"] = Subscription.objects.filter(
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["locations"] = settings.SITH_SUBSCRIPTION_LOCATIONS
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({
// biome-ignore lint/style/useNamingConvention: that's how it's called
Alpine: "alpinejs",
htmx: "htmx.org",
}),
viteStaticCopy({
targets: [