mirror of
https://github.com/ae-utbm/sith.git
synced 2024-12-22 07:41:14 +00:00
Merge pull request #932 from ae-utbm/fix-subscriptions
Rework the subscription page
This commit is contained in:
commit
95f8e7517c
@ -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):
|
||||
|
89
core/static/core/forms.scss
Normal file
89
core/static/core/forms.scss
Normal 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;
|
||||
}
|
||||
}
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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 %}
|
||||
|
@ -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"
|
||||
|
@ -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
124
subscription/forms.py
Normal 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}
|
@ -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"]
|
||||
|
@ -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;
|
||||
},
|
||||
}));
|
||||
});
|
28
subscription/static/subscription/css/subscription.scss
Normal file
28
subscription/static/subscription/css/subscription.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
@ -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>
|
@ -0,0 +1 @@
|
||||
{{ form.as_p }}
|
@ -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>
|
@ -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>
|
@ -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 %}
|
||||
|
0
subscription/tests/__init__.py
Normal file
0
subscription/tests/__init__.py
Normal 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
|
151
subscription/tests/test_new_susbcription.py
Normal file
151
subscription/tests/test_new_susbcription.py
Normal 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},
|
||||
),
|
||||
)
|
@ -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"),
|
||||
]
|
||||
|
@ -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")
|
||||
|
@ -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: [
|
||||
|
Loading…
Reference in New Issue
Block a user