Author SHA1 Message Date
imperosol 5fe3622f42 add tests 2026-09-21 13:57:16 +02:00
imperosol dbb5d20093 make existing users approve CGU after login 2026-09-21 13:28:09 +02:00
imperosol 4c66a8706a refactor login.jinja 2026-09-21 13:28:09 +02:00
imperosol 3cc533044a force CGU/EULA approval on account creation 2026-09-21 13:28:09 +02:00
imperosol f2f0d21c6e add CGU/EULA to populate command 2026-09-20 23:36:33 +02:00
22 changed files with 422 additions and 215 deletions
+13 -5
View File
@@ -28,6 +28,7 @@ from typing import ClassVar, NamedTuple
from django.conf import settings from django.conf import settings
from django.contrib.auth.models import Permission from django.contrib.auth.models import Permission
from django.contrib.sites.models import Site from django.contrib.sites.models import Site
from django.core.files.base import ContentFile
from django.core.management import call_command from django.core.management import call_command
from django.core.management.base import BaseCommand from django.core.management.base import BaseCommand
from django.db import connection from django.db import connection
@@ -120,15 +121,22 @@ class Command(BaseCommand):
) )
self.profiles_root = SithFile.objects.create(name="profiles", owner=root) self.profiles_root = SithFile.objects.create(name="profiles", owner=root)
home_root = SithFile.objects.create(name="users", owner=root) home_root = SithFile.objects.create(name="users", owner=root)
# Page needed for club creation
p = Page(name=settings.SITH_CLUB_ROOT_PAGE)
p.save(force_lock=True)
club_root = SithFile.objects.create(name="clubs", owner=root) club_root = SithFile.objects.create(name="clubs", owner=root)
sas = SithFile.objects.create( sas = SithFile.objects.create(
name="SAS", owner=root, id=settings.SITH_SAS_ROOT_DIR_ID name="SAS", owner=root, id=settings.SITH_SAS_ROOT_DIR_ID
) )
s = SithFile.objects.create(
name="CGU",
is_folder=False,
file=ContentFile(
content="Conditions générales d'utilisation", name="cgu.txt"
),
owner=root,
)
s.view_groups.add(settings.SITH_GROUP_PUBLIC_ID)
# Page needed for club creation
p = Page(name=settings.SITH_CLUB_ROOT_PAGE)
p.save(force_lock=True)
clubs = self._create_clubs() clubs = self._create_clubs()
self.reset_index("club") self.reset_index("club")
+2 -14
View File
@@ -138,22 +138,10 @@ class Command(BaseCommand):
) )
def create_subscriptions(self, users: list[User]): def create_subscriptions(self, users: list[User]):
subscription_types = [
"un-semestre",
"deux-semestres",
"cursus-tronc-commun",
"cursus-branche",
]
def prepare_subscription(_user: User, start_date: date) -> Subscription: def prepare_subscription(_user: User, start_date: date) -> Subscription:
payment_method = random.choice(settings.SITH_SUBSCRIPTION_PAYMENT_METHOD)[0] payment_method = random.choice(settings.SITH_SUBSCRIPTION_PAYMENT_METHOD)[0]
subscription_type = random.choice(subscription_types) duration = random.randint(1, 4)
s = Subscription( s = Subscription(member=_user, payment_method=payment_method)
member=_user,
payment_method=payment_method,
subscription_type=subscription_type,
)
duration = settings.SITH_SUBSCRIPTIONS[subscription_type]["duration"]
s.subscription_start = s.compute_start(d=start_date, duration=duration) s.subscription_start = s.compute_start(d=start_date, duration=duration)
s.subscription_end = s.compute_end(duration) s.subscription_end = s.compute_end(duration)
return s return s
@@ -0,0 +1,15 @@
# Generated by Django 5.2.17 on 2026-09-15 11:02
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [("core", "0050_alter_sithfile_moderator")]
operations = [
migrations.AddField(
model_name="user",
name="cgu_approved",
field=models.BooleanField(default=False, verbose_name="ToS approved"),
),
]
+1
View File
@@ -291,6 +291,7 @@ class User(AbstractUser):
), ),
blank=True, blank=True,
) )
cgu_approved = models.BooleanField(_("ToS approved"), default=False)
godfathers = models.ManyToManyField("User", related_name="godchildren", blank=True) godfathers = models.ManyToManyField("User", related_name="godchildren", blank=True)
objects = CustomUserManager() objects = CustomUserManager()
+11 -46
View File
@@ -37,8 +37,7 @@ body {
margin: 0; margin: 0;
} }
>div, form {
>form {
box-sizing: border-box; box-sizing: border-box;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -49,67 +48,33 @@ body {
max-width: 500px; max-width: 500px;
margin-top: 20px; margin-top: 20px;
>p, input[type="submit"] {
>div {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
width: 100%; width: 100%;
margin: 0; max-width: 300px;
margin-top: 1em;
>label {
width: 100%;
@media (min-width: 500px) {
width: 300px;
}
}
} }
>input, .errorlist {
>p>input,
>div>input {
box-sizing: border-box;
width: 100%;
max-width: 500px;
@media (min-width: 500px) {
max-width: 300px;
}
}
>.errorlist {
color: red; color: red;
text-align: center; text-align: center;
margin: 10px 0 0 0; margin: 10px 0 0 0;
list-style-type: none; list-style-type: none;
} }
>.required>.helptext { div, fieldset {
text-align: center;
font-style: italic;
}
>.required:last-of-type {
box-sizing: border-box;
max-width: 300px; max-width: 300px;
flex-direction: row; }
flex-wrap: wrap; .captcha {
justify-content: space-between; box-sizing: border-box;
>label { fieldset {
width: 100%; margin-bottom: unset
} }
>img { >img {
width: 70px; width: 70px;
object-fit: contain; object-fit: contain;
} }
>input {
width: 200px;
}
} }
} }
} }
+39
View File
@@ -0,0 +1,39 @@
{% extends "core/base.jinja" %}
{%- block additional_css -%}
<link rel="stylesheet" href="{{ static('user/login.scss') }}">
{%- endblock -%}
{% block title %}
{% trans %}Login{% endtrans %}
{% endblock %}
{% block info_boxes %}
{% endblock %}
{% block nav %}
{% endblock %}
{% block content %}
<h1 class="title">{% trans %}Terms of Service{% endtrans %}</h1>
<form method="post" id="login-form">
{% csrf_token %}
<div class="alert alert-yellow">
{% trans trimmed %}
To continue using our services,
please read and approve the AE website's terms of service
{% endtrans %}
</div>
<div class="form-group">
{{ form.cgu_approved.errors }}
{{ form.cgu_approved }}
{{ form.cgu_approved.label_tag() }}
</div>
<input type="hidden" name="next" value="{{ next }}">
<input type="submit" class="btn btn-blue">
</form>
{% endblock %}
+8 -19
View File
@@ -35,28 +35,17 @@
{% csrf_token %} {% csrf_token %}
<div> {{ form }}
<label for="{{ form.username.name }}">{{ form.username.label }}</label>
{{ form.username }}
{{ form.username.errors }}
</div>
<div>
<label for="{{ form.password.name }}">{{ form.password.label }}</label>
{{ form.password }}
{{ form.password.errors }}
</div>
<input type="hidden" name="next" value="{{ next }}"> <input type="hidden" name="next" value="{{ next }}">
<input type="submit" value="{% trans %}Login{% endtrans %}"> <input type="submit" class="btn btn-blue" value="{% trans %}Login{% endtrans %}">
<div>
{# Assumes you setup the password_reset view in your URLconf #} <a href="{{ url("core:password_reset") }}">{% trans %}Lost password?{% endtrans %}</a>
<p> </div>
<a href="{{ url('core:password_reset') }}">{% trans %}Lost password?{% endtrans %}</a> <div>
&nbsp;&nbsp; <a href="{{ url("core:register") }}">{% trans %}Create account{% endtrans %}</a>
<a href="{{ url('core:register') }}">{% trans %}Create account{% endtrans %}</a> </div>
</p>
</form> </form>
{% endblock %} {% endblock %}
+12 -2
View File
@@ -18,7 +18,17 @@
<form action="{{ url('core:register') }}" method="post"> <form action="{{ url('core:register') }}" method="post">
{% csrf_token %} {% csrf_token %}
{% render_honeypot_field %} {% render_honeypot_field %}
{{ form.as_p() }} {% for field in form %}
<input type="submit" value="{% trans %}Register{% endtrans %}" /> {% if field.name not in ["cgu_approved", "captcha"] %}
<div>{{ field.as_field_group() }}</div>
{% endif %}
{% endfor %}
<div class="captcha">{{ form.captcha.as_field_group() }}</div>
<div class="form-group">
{{ form.cgu_approved.errors }}
{{ form.cgu_approved }}
{{ form.cgu_approved.label_tag() }}
</div>
<input type="submit" class="btn btn-blue" value="{% trans %}Register{% endtrans %}" />
</form> </form>
{% endblock %} {% endblock %}
+37 -2
View File
@@ -55,6 +55,7 @@ class TestUserRegistration:
"password2": "plop", "password2": "plop",
"captcha_0": "dummy-value", "captcha_0": "dummy-value",
"captcha_1": "PASSED", "captcha_1": "PASSED",
"cgu_approved": True,
} }
@pytest.fixture() @pytest.fixture()
@@ -92,6 +93,10 @@ class TestUserRegistration:
({"first_name": ""}, "Ce champ est obligatoire."), ({"first_name": ""}, "Ce champ est obligatoire."),
({"last_name": ""}, "Ce champ est obligatoire."), ({"last_name": ""}, "Ce champ est obligatoire."),
({"captcha_1": "WRONG_CAPTCHA"}, "CAPTCHA invalide"), ({"captcha_1": "WRONG_CAPTCHA"}, "CAPTCHA invalide"),
(
{"cgu_approved": ""},
"Vous devez approuver les conditions générales d'utilisation",
),
], ],
) )
def test_register_user_form_fail( def test_register_user_form_fail(
@@ -150,7 +155,7 @@ class TestUserRegistration:
class TestUserLogin: class TestUserLogin:
@pytest.fixture() @pytest.fixture()
def user(self) -> User: def user(self) -> User:
return baker.make(User, password=make_password("plop")) return baker.make(User, password=make_password("plop"), cgu_approved=True)
@pytest.mark.parametrize( @pytest.mark.parametrize(
"identifier_getter", "identifier_getter",
@@ -191,10 +196,40 @@ class TestUserLogin:
reverse("core:login"), reverse("core:login"),
{"username": identifier_getter(user), "password": "plop"}, {"username": identifier_getter(user), "password": "plop"},
) )
assertRedirects(response, reverse("core:index")) assertRedirects(response, settings.LOGIN_REDIRECT_URL)
assert response.wsgi_request.user == user assert response.wsgi_request.user == user
@pytest.mark.django_db
class TestCGU:
def test_cgu_approval(self, client: Client):
user = baker.make(User, password=make_password("plop"), cgu_approved=False)
user_url = user.get_absolute_url()
res = client.post(
reverse("core:login"),
{"username": user.username, "password": "plop", "next": user_url},
)
assertRedirects(res, reverse("core:approve_cgu", query={"next": user_url}))
res = client.post(
reverse("core:approve_cgu"), {"cgu_approved": True, "next": user_url}
)
assertRedirects(res, user_url)
user.refresh_from_db()
assert user.cgu_approved
def test_access_cgu_when_already_approved(self, client: Client):
url = reverse("core:approve_cgu")
res = client.get(url)
assertRedirects(res, reverse("core:login"))
client.force_login(baker.make(User, cgu_approved=True))
res = client.get(url)
assertRedirects(res, settings.LOGIN_REDIRECT_URL)
res = client.post(url, {"cgu_approved": True})
assertRedirects(res, settings.LOGIN_REDIRECT_URL)
@pytest.mark.parametrize( @pytest.mark.parametrize(
("md", "html"), ("md", "html"),
[ [
+2
View File
@@ -31,6 +31,7 @@ from core.converters import (
TwoDigitMonthConverter, TwoDigitMonthConverter,
) )
from core.views import ( from core.views import (
CGUApprovalView,
FileDeleteView, FileDeleteView,
FileEditPropView, FileEditPropView,
FileEditView, FileEditView,
@@ -125,6 +126,7 @@ urlpatterns = [
name="password_reset_complete", name="password_reset_complete",
), ),
path("register/", UserCreationView.as_view(), name="register"), path("register/", UserCreationView.as_view(), name="register"),
path("cgu/", CGUApprovalView.as_view(), name="approve_cgu"),
# Group handling # Group handling
path("group/", GroupListView.as_view(), name="group_list"), path("group/", GroupListView.as_view(), name="group_list"),
path("group/new/", GroupCreateView.as_view(), name="group_new"), path("group/new/", GroupCreateView.as_view(), name="group_new"),
+41 -5
View File
@@ -30,9 +30,7 @@ from django import forms
from django.conf import settings from django.conf import settings
from django.contrib.auth.forms import AuthenticationForm, UserCreationForm from django.contrib.auth.forms import AuthenticationForm, UserCreationForm
from django.contrib.auth.models import Permission from django.contrib.auth.models import Permission
from django.contrib.staticfiles.management.commands.collectstatic import ( from django.contrib.staticfiles.storage import staticfiles_storage
staticfiles_storage,
)
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.db import transaction from django.db import transaction
from django.forms import ( from django.forms import (
@@ -42,6 +40,9 @@ from django.forms import (
TextInput, TextInput,
Widget, Widget,
) )
from django.urls import reverse
from django.utils.functional import lazy
from django.utils.safestring import mark_safe
from django.utils.timezone import now from django.utils.timezone import now
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from phonenumber_field.widgets import RegionalPhoneNumberWidget from phonenumber_field.widgets import RegionalPhoneNumberWidget
@@ -108,6 +109,34 @@ class FutureDateTimeField(forms.DateTimeField):
return {"min": widget.format_value(now())} return {"min": widget.format_value(now())}
class CGUApprovalField(forms.BooleanField):
cgu_file_id = settings.SITH_CGU_FILE_ID
default_error_messages = {"required": _("You must approve the terms of service.")}
__label = None
def __init__(self, *, label_suffix: str | None = "", **kwargs):
# Because the core app of the sith is so huge,
# and because we require a url from the latter,
# putting the reverse into the __init__ will result in it
# being evaluated at server startup time (even with reverse_lazy).
# This will result in a circular import.
# Thus, we must keep the label in its own property and force it to be lazy.
kwargs["label"] = lazy(self.get_label, str)
kwargs["required"] = True
super().__init__(label_suffix=label_suffix, **kwargs)
def get_label(self):
if not self.__label:
self.__label = mark_safe(
_(
"I have read and I approve the "
'<a href="%(url)s" target="_blank">Terms of Service</a>'
)
% {"url": reverse("core:page", kwargs={"page_name": self.cgu_file_id})}
)
return self.__label
# Forms # Forms
@@ -146,8 +175,15 @@ class RegisteringForm(UserCreationForm):
class Meta: class Meta:
model = User model = User
fields = ("first_name", "last_name", "email") fields = ("first_name", "last_name", "email", "cgu_approved")
field_classes = {"email": AntiSpamEmailField} field_classes = {"email": AntiSpamEmailField, "cgu_approved": CGUApprovalField}
class CGUApprovalForm(forms.ModelForm):
class Meta:
model = User
fields = ["cgu_approved"]
field_classes = {"cgu_approved": CGUApprovalField}
class UserProfileForm(forms.ModelForm): class UserProfileForm(forms.ModelForm):
+33 -3
View File
@@ -27,12 +27,12 @@ from datetime import timedelta
# This file contains all the views that concern the user model # This file contains all the views that concern the user model
from operator import itemgetter from operator import itemgetter
from smtplib import SMTPException from smtplib import SMTPException
from typing import TYPE_CHECKING from typing import TYPE_CHECKING, Any
from django.contrib import messages from django.contrib import messages
from django.contrib.auth import login, views from django.contrib.auth import login, views
from django.contrib.auth.decorators import login_required from django.contrib.auth.decorators import login_required
from django.contrib.auth.forms import PasswordChangeForm, SetPasswordForm from django.contrib.auth.forms import SetPasswordForm
from django.contrib.auth.mixins import LoginRequiredMixin, UserPassesTestMixin from django.contrib.auth.mixins import LoginRequiredMixin, UserPassesTestMixin
from django.contrib.messages.views import SuccessMessageMixin from django.contrib.messages.views import SuccessMessageMixin
from django.core.exceptions import PermissionDenied from django.core.exceptions import PermissionDenied
@@ -60,6 +60,7 @@ from honeypot.decorators import check_honeypot
from core.auth.mixins import CanEditMixin, CanEditPropMixin, CanViewMixin from core.auth.mixins import CanEditMixin, CanEditPropMixin, CanViewMixin
from core.models import Gift, Preferences, User from core.models import Gift, Preferences, User
from core.views.forms import ( from core.views.forms import (
CGUApprovalForm,
GiftForm, GiftForm,
LoginForm, LoginForm,
RegisteringForm, RegisteringForm,
@@ -71,6 +72,7 @@ from core.views.forms import (
from core.views.mixins import FragmentMixin, TabedViewMixin, UseFragmentsMixin from core.views.mixins import FragmentMixin, TabedViewMixin, UseFragmentsMixin
from counter.models import Refilling, Selling from counter.models import Refilling, Selling
from eboutic.models import Invoice from eboutic.models import Invoice
from sith import settings
from trombi.views import UserTrombiForm from trombi.views import UserTrombiForm
if TYPE_CHECKING: if TYPE_CHECKING:
@@ -82,9 +84,16 @@ class SithLoginView(views.LoginView):
template_name = "core/login.jinja" template_name = "core/login.jinja"
authentication_form = LoginForm authentication_form = LoginForm
form_class = PasswordChangeForm
redirect_authenticated_user = True redirect_authenticated_user = True
def get_success_url(self) -> str:
redirect_to = self.get_redirect_url()
default_url = self.get_default_redirect_url()
if not self.request.user.cgu_approved:
query = {"next": redirect_to} if redirect_to else {}
return reverse("core:approve_cgu", query=query)
return redirect_to or default_url
class SithPasswordChangeView(views.PasswordChangeView): class SithPasswordChangeView(views.PasswordChangeView):
"""Allows a user to change its password.""" """Allows a user to change its password."""
@@ -188,6 +197,27 @@ class UserCreationView(FormView):
return super().form_valid(form) return super().form_valid(form)
class CGUApprovalView(views.RedirectURLMixin, UpdateView):
form_class = CGUApprovalForm
next_page = settings.LOGIN_REDIRECT_URL
template_name = "core/cgu_approve.jinja"
def dispatch(self, request, *args, **kwargs):
if self.request.user.is_anonymous:
return redirect("core:login")
if self.request.user.cgu_approved:
return redirect(self.get_success_url())
return super().dispatch(request, *args, **kwargs)
def get_object(self, *args, **kwargs):
return self.request.user
def get_context_data(self, **kwargs) -> dict[str, Any]:
return super().get_context_data(**kwargs) | {
self.redirect_field_name: self.get_redirect_url()
}
class UserMeRedirect(LoginRequiredMixin, RedirectView): class UserMeRedirect(LoginRequiredMixin, RedirectView):
def get_redirect_url(self, *args, **kwargs): def get_redirect_url(self, *args, **kwargs):
if remaining := kwargs.get("remaining_path"): if remaining := kwargs.get("remaining_path"):
@@ -123,14 +123,14 @@ document.addEventListener("alpine:init", () => {
onRefillingSuccess(event: CustomEvent) { onRefillingSuccess(event: CustomEvent) {
if ( if (
event.type !== "htmx:after:swap" || event.type !== "htmx:after-swap" ||
event.detail.ctx.response.status !== 200 || event.detail.failed ||
event.detail.ctx.target.querySelector(".errorlist") event.detail.elt.querySelector(".errorlist")
) { ) {
return; return;
} }
this.customerBalance += Number.parseFloat( this.customerBalance += Number.parseFloat(
(event.detail.ctx.target.querySelector("#id_amount") as HTMLInputElement).value, (event.detail.target.querySelector("#id_amount") as HTMLInputElement).value,
); );
document.getElementById("selling-accordion")?.setAttribute("open", ""); document.getElementById("selling-accordion")?.setAttribute("open", "");
this.codeField?.widget.focus(); this.codeField?.widget.focus();
@@ -62,7 +62,8 @@
} }
form { form {
margin: 0; margin-top: .5rem;
margin-bottom: .5rem;
} }
} }
@@ -186,7 +186,7 @@
{% if refilling_fragment %} {% if refilling_fragment %}
<div <div
class="accordion-content" class="accordion-content"
@htmx:after:swap="onRefillingSuccess" @htmx:after-swap="onRefillingSuccess"
> >
{{ refilling_fragment }} {{ refilling_fragment }}
</div> </div>
@@ -4,8 +4,6 @@
hx-swap="outerHTML" hx-swap="outerHTML"
> >
{% csrf_token %} {% csrf_token %}
<div class="margin-bottom"> {{ form.as_p() }}
{{ form.as_p() }} <input type="submit" value="{% trans %}Go{% endtrans %}"/>
</div>
<input type="submit" class="btn btn-blue" value="{% trans %}Go{% endtrans %}"/>
</form> </form>
@@ -60,7 +60,7 @@
</p> </p>
<br> <br>
{% if settings.SITH_EBOUTIC_CB_ENABLED %} {% if settings.SITH_EBOUTIC_CB_ENABLED %}
<div @htmx:after:request="fill"> <div @htmx:after-request="fill">
{{ billing_infos_form }} {{ billing_infos_form }}
</div> </div>
{% endif %} {% endif %}
+119 -72
View File
@@ -6,7 +6,7 @@
msgid "" msgid ""
msgstr "" msgstr ""
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-09-09 07:21+0200\n" "POT-Creation-Date: 2026-09-21 13:16+0200\n"
"PO-Revision-Date: 2016-07-18\n" "PO-Revision-Date: 2016-07-18\n"
"Last-Translator: Maréchal <thomas.girod@utbm.fr\n" "Last-Translator: Maréchal <thomas.girod@utbm.fr\n"
"Language-Team: AE info <ae.info@utbm.fr>\n" "Language-Team: AE info <ae.info@utbm.fr>\n"
@@ -736,8 +736,9 @@ msgstr "Méthode de paiement"
#: counter/templates/counter/fragments/create_student_card.jinja #: counter/templates/counter/fragments/create_student_card.jinja
#: counter/templates/counter/last_ops.jinja #: counter/templates/counter/last_ops.jinja
#: election/templates/election/election_detail.jinja #: election/templates/election/election_detail.jinja
#: forum/templates/forum/macros.jinja pedagogy/templates/pedagogy/guide.jinja #: forum/templates/forum/macros.jinja
#: pedagogy/templates/pedagogy/ue_detail.jinja sas/templates/sas/album.jinja #: pedagogy/templates/pedagogy/fragments/ue_details/comments.jinja
#: pedagogy/templates/pedagogy/guide.jinja sas/templates/sas/album.jinja
#: sas/templates/sas/moderation.jinja sas/templates/sas/picture.jinja #: sas/templates/sas/moderation.jinja sas/templates/sas/picture.jinja
#: trombi/templates/trombi/detail.jinja #: trombi/templates/trombi/detail.jinja
#: trombi/templates/trombi/edit_profile.jinja #: trombi/templates/trombi/edit_profile.jinja
@@ -931,8 +932,9 @@ msgstr "Outils"
#: counter/templates/counter/cash_summary_list.jinja #: counter/templates/counter/cash_summary_list.jinja
#: counter/templates/counter/counter_list.jinja #: counter/templates/counter/counter_list.jinja
#: election/templates/election/election_detail.jinja #: election/templates/election/election_detail.jinja
#: forum/templates/forum/macros.jinja pedagogy/templates/pedagogy/guide.jinja #: forum/templates/forum/macros.jinja
#: pedagogy/templates/pedagogy/ue_detail.jinja sas/templates/sas/album.jinja #: pedagogy/templates/pedagogy/fragments/ue_details/comments.jinja
#: pedagogy/templates/pedagogy/guide.jinja sas/templates/sas/album.jinja
#: trombi/templates/trombi/detail.jinja #: trombi/templates/trombi/detail.jinja
#: trombi/templates/trombi/edit_profile.jinja #: trombi/templates/trombi/edit_profile.jinja
msgid "Edit" msgid "Edit"
@@ -1835,6 +1837,10 @@ msgstr ""
"Même si ce profil est caché, les utilisateurs sur cette liste pourront " "Même si ce profil est caché, les utilisateurs sur cette liste pourront "
"toujours le voir." "toujours le voir."
#: core/models.py
msgid "ToS approved"
msgstr "CGU approuvées"
#: core/models.py #: core/models.py
msgid "A user with that username already exists" msgid "A user with that username already exists"
msgstr "Un utilisateur de ce nom d'utilisateur existe déjà" msgstr "Un utilisateur de ce nom d'utilisateur existe déjà"
@@ -2109,7 +2115,8 @@ msgstr "R&D"
msgid "Site created by the IT Department of the AE" msgid "Site created by the IT Department of the AE"
msgstr "Site réalisé par le Pôle Informatique de l'AE" msgstr "Site réalisé par le Pôle Informatique de l'AE"
#: core/templates/core/base/header.jinja core/templates/core/login.jinja #: core/templates/core/base/header.jinja core/templates/core/cgu_approve.jinja
#: core/templates/core/login.jinja
#: core/templates/core/password_reset_complete.jinja #: core/templates/core/password_reset_complete.jinja
msgid "Login" msgid "Login"
msgstr "Connexion" msgstr "Connexion"
@@ -2207,6 +2214,18 @@ msgstr "FAQ"
msgid "Wiki" msgid "Wiki"
msgstr "Wiki" msgstr "Wiki"
#: core/templates/core/cgu_approve.jinja
msgid "Terms of Service"
msgstr "Conditions générales d'utilisation"
#: core/templates/core/cgu_approve.jinja
msgid ""
"To continue using our services, please read and approve the AE website's "
"terms of service"
msgstr ""
"Pour continuer à utiliser nos services, veuillez lire et approuver les "
"conditions générales d'utilisation du site AE."
#: core/templates/core/create.jinja #: core/templates/core/create.jinja
#, python-format #, python-format
msgid "Create %(name)s" msgid "Create %(name)s"
@@ -2233,6 +2252,7 @@ msgstr "Confirmation"
#: core/templates/core/file_delete_confirm.jinja #: core/templates/core/file_delete_confirm.jinja
#: counter/templates/counter/counter_click.jinja #: counter/templates/counter/counter_click.jinja
#: counter/templates/counter/fragments/delete_student_card.jinja #: counter/templates/counter/fragments/delete_student_card.jinja
#: pedagogy/templates/pedagogy/fragments/comment_report.jinja
#: sas/templates/sas/ask_picture_removal.jinja #: sas/templates/sas/ask_picture_removal.jinja
msgid "Cancel" msgid "Cancel"
msgstr "Annuler" msgstr "Annuler"
@@ -2259,6 +2279,7 @@ msgstr "Propriétés"
#: core/templates/core/file_delete_confirm.jinja #: core/templates/core/file_delete_confirm.jinja
#: counter/templates/counter/fragments/delete_student_card.jinja #: counter/templates/counter/fragments/delete_student_card.jinja
#: pedagogy/templates/pedagogy/fragments/ue_details/comments.jinja
#, python-format #, python-format
msgid "Are you sure you want to delete \"%(obj)s\"?" msgid "Are you sure you want to delete \"%(obj)s\"?"
msgstr "Êtes-vous sûr de vouloir supprimer \"%(obj)s\" ?" msgstr "Êtes-vous sûr de vouloir supprimer \"%(obj)s\" ?"
@@ -3133,6 +3154,19 @@ msgstr "Appliquer les droits récursivement"
msgid "Ensure this timestamp is set in the future" msgid "Ensure this timestamp is set in the future"
msgstr "Assurez-vous que cet horodatage est dans le futur" msgstr "Assurez-vous que cet horodatage est dans le futur"
#: core/views/forms.py
msgid "You must approve the terms of service."
msgstr "Vous devez approuver les conditions générales d'utilisation"
#: core/views/forms.py
#, python-format
msgid ""
"I have read and I approve the <a href=\"%(url)s\" target=\"_blank\">Terms of "
"Service</a>"
msgstr ""
"J'ai lu et j'approuve les <a href=\"%(url)s\" target=\"_blank\">Conditions "
"Générales d'utilisation</a>"
#: core/views/forms.py #: core/views/forms.py
msgid "Username, email, or account number" msgid "Username, email, or account number"
msgstr "Nom d'utilisateur, email, ou numéro de compte AE" msgstr "Nom d'utilisateur, email, ou numéro de compte AE"
@@ -3778,8 +3812,8 @@ msgid "Emptied"
msgstr "Coffre vidé" msgstr "Coffre vidé"
#: counter/templates/counter/cash_summary_list.jinja counter/views/cash.py #: counter/templates/counter/cash_summary_list.jinja counter/views/cash.py
#: pedagogy/templates/pedagogy/fragments/ue_comment_form.jinja
#: pedagogy/templates/pedagogy/moderation.jinja #: pedagogy/templates/pedagogy/moderation.jinja
#: pedagogy/templates/pedagogy/ue_detail.jinja
#: trombi/templates/trombi/comment.jinja #: trombi/templates/trombi/comment.jinja
#: trombi/templates/trombi/user_tools.jinja #: trombi/templates/trombi/user_tools.jinja
msgid "Comment" msgid "Comment"
@@ -5109,6 +5143,80 @@ msgstr "signaler"
msgid "reporter" msgid "reporter"
msgstr "signalant" msgstr "signalant"
#: pedagogy/templates/pedagogy/fragments/comment_report.jinja
msgid "Report"
msgstr "Signaler"
#: pedagogy/templates/pedagogy/fragments/ue_comment_form.jinja
msgid "Leave comment"
msgstr "Laisser un commentaire"
#: pedagogy/templates/pedagogy/fragments/ue_details/comments.jinja
#: trombi/templates/trombi/export.jinja
msgid "Comments"
msgstr "Commentaires"
#: pedagogy/templates/pedagogy/fragments/ue_details/comments.jinja
#: pedagogy/templates/pedagogy/fragments/ue_details/grade.jinja
msgid "Global grade"
msgstr "Note globale"
#: pedagogy/templates/pedagogy/fragments/ue_details/comments.jinja
#: pedagogy/templates/pedagogy/fragments/ue_details/grade.jinja
msgid "Utility"
msgstr "Utilité"
#: pedagogy/templates/pedagogy/fragments/ue_details/comments.jinja
#: pedagogy/templates/pedagogy/fragments/ue_details/grade.jinja
msgid "Interest"
msgstr "Intérêt"
#: pedagogy/templates/pedagogy/fragments/ue_details/comments.jinja
#: pedagogy/templates/pedagogy/fragments/ue_details/grade.jinja
msgid "Teaching"
msgstr "Enseignement"
#: pedagogy/templates/pedagogy/fragments/ue_details/comments.jinja
#: pedagogy/templates/pedagogy/fragments/ue_details/grade.jinja
msgid "Work load"
msgstr "Charge de travail"
#: pedagogy/templates/pedagogy/fragments/ue_details/comments.jinja
msgid "This comment has been reported"
msgstr "Ce commentaire a été signalé"
#: pedagogy/templates/pedagogy/fragments/ue_details/comments.jinja
msgid "Report this comment"
msgstr "Signaler ce commentaire"
#: pedagogy/templates/pedagogy/fragments/ue_details/form.jinja
msgid ""
"You already posted a comment on this UE. If you want to comment again, "
"please modify or delete your previous comment."
msgstr ""
"Vous avez déjà commenté cette UE. Si vous voulez de nouveau commenter, "
"veuillez modifier ou supprimer votre commentaire précédent."
#: pedagogy/templates/pedagogy/fragments/ue_details/grade.jinja
msgid "Objectives"
msgstr "Objectifs"
#: pedagogy/templates/pedagogy/fragments/ue_details/grade.jinja
msgid "Program"
msgstr "Programme"
#: pedagogy/templates/pedagogy/fragments/ue_details/grade.jinja
msgid "Earned skills"
msgstr "Compétences acquises"
#: pedagogy/templates/pedagogy/fragments/ue_details/grade.jinja
msgid "Key concepts"
msgstr "Concepts clefs"
#: pedagogy/templates/pedagogy/fragments/ue_details/grade.jinja
msgid "UE manager: "
msgstr "Gestionnaire d'UE : "
#: pedagogy/templates/pedagogy/guide.jinja #: pedagogy/templates/pedagogy/guide.jinja
msgid "A guide of courses available at UTBM." msgid "A guide of courses available at UTBM."
msgstr "Un guide de tous les cours disponibles à l'UTBM." msgstr "Un guide de tous les cours disponibles à l'UTBM."
@@ -5117,6 +5225,10 @@ msgstr "Un guide de tous les cours disponibles à l'UTBM."
msgid "Search UE" msgid "Search UE"
msgstr "Recherche d'UE" msgstr "Recherche d'UE"
#: pedagogy/templates/pedagogy/guide.jinja
msgid "Hide closed UEs"
msgstr "Cacher les UEs fermées"
#: pedagogy/templates/pedagogy/guide.jinja #: pedagogy/templates/pedagogy/guide.jinja
#, python-format #, python-format
msgid "%(display_name)s" msgid "%(display_name)s"
@@ -5189,71 +5301,6 @@ msgstr "TE : "
msgid "THE: " msgid "THE: "
msgstr "THE : " msgstr "THE : "
#: pedagogy/templates/pedagogy/ue_detail.jinja
msgid "Global grade"
msgstr "Note globale"
#: pedagogy/templates/pedagogy/ue_detail.jinja
msgid "Utility"
msgstr "Utilité"
#: pedagogy/templates/pedagogy/ue_detail.jinja
msgid "Interest"
msgstr "Intérêt"
#: pedagogy/templates/pedagogy/ue_detail.jinja
msgid "Teaching"
msgstr "Enseignement"
#: pedagogy/templates/pedagogy/ue_detail.jinja
msgid "Work load"
msgstr "Charge de travail"
#: pedagogy/templates/pedagogy/ue_detail.jinja
msgid "Objectives"
msgstr "Objectifs"
#: pedagogy/templates/pedagogy/ue_detail.jinja
msgid "Program"
msgstr "Programme"
#: pedagogy/templates/pedagogy/ue_detail.jinja
msgid "Earned skills"
msgstr "Compétences acquises"
#: pedagogy/templates/pedagogy/ue_detail.jinja
msgid "Key concepts"
msgstr "Concepts clefs"
#: pedagogy/templates/pedagogy/ue_detail.jinja
msgid "UE manager: "
msgstr "Gestionnaire d'UE : "
#: pedagogy/templates/pedagogy/ue_detail.jinja pedagogy/tests/tests.py
msgid ""
"You already posted a comment on this UE. If you want to comment again, "
"please modify or delete your previous comment."
msgstr ""
"Vous avez déjà commenté cette UE. Si vous voulez de nouveau commenter, "
"veuillez modifier ou supprimer votre commentaire précédent."
#: pedagogy/templates/pedagogy/ue_detail.jinja
msgid "Leave comment"
msgstr "Laisser un commentaire"
#: pedagogy/templates/pedagogy/ue_detail.jinja
#: trombi/templates/trombi/export.jinja
msgid "Comments"
msgstr "Commentaires"
#: pedagogy/templates/pedagogy/ue_detail.jinja
msgid "This comment has been reported"
msgstr "Ce commentaire a été signalé"
#: pedagogy/templates/pedagogy/ue_detail.jinja
msgid "Report this comment"
msgstr "Signaler ce commentaire"
#: pedagogy/templates/pedagogy/ue_edit.jinja #: pedagogy/templates/pedagogy/ue_edit.jinja
msgid "Edit UE" msgid "Edit UE"
msgstr "Éditer l'UE" msgstr "Éditer l'UE"
+2
View File
@@ -417,6 +417,8 @@ SITH_FORUM_PAGE_LENGTH = 30
SITH_SAS_ROOT_DIR_ID = env.int("SITH_SAS_ROOT_DIR_ID", default=4) SITH_SAS_ROOT_DIR_ID = env.int("SITH_SAS_ROOT_DIR_ID", default=4)
SITH_SAS_IMAGES_PER_PAGE = 60 SITH_SAS_IMAGES_PER_PAGE = 60
SITH_CGU_FILE_ID = env.int("SITH_CGU_FILE_ID", default=5)
SITH_PROFILE_DEPARTMENTS = [ SITH_PROFILE_DEPARTMENTS = [
("TC", _("TC")), ("TC", _("TC")),
("IMSI", _("IMSI")), ("IMSI", _("IMSI")),
+12 -1
View File
@@ -8,11 +8,22 @@ from django.utils.translation import gettext_lazy as _
from core.models import User from core.models import User
from core.utils import get_last_promo from core.utils import get_last_promo
from core.views.forms import SelectDate from core.views.forms import SelectDate, SelectDateTime
from core.views.widgets.ajax_select import AutoCompleteSelectUser from core.views.widgets.ajax_select import AutoCompleteSelectUser
from subscription.models import Subscription 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): class SubscriptionForm(forms.ModelForm):
allowed_payment_methods = ["CARD", "CASH", "AE_ACCOUNT"] allowed_payment_methods = ["CARD", "CASH", "AE_ACCOUNT"]
+44 -31
View File
@@ -11,38 +11,51 @@
{% block content %} {% block content %}
<p>
<form>
{{ form.start_date.label }}<br>
{{ form.start_date }}<br><br>
{{ form.end_date.label }}<br>
{{ form.end_date }}<br>
<p><input type="submit" value="{% trans %}Go{% endtrans %}" /></p>
</form>
</p>
<canvas id="statsChart" width="400" height="200"></canvas> <canvas id="statsChart" width="400" height="200"></canvas>
{% trans %}Total subscriptions{% endtrans %} : {{ subscriptions_total.count() }}<br><br> <p>
{% trans %}Subscriptions by type{% endtrans %}<br><br> {% trans %}Total subscriptions{% endtrans %} : {{ subscriptions_total.count() }}<br><br>
{% for location in locations %} {% trans %}Subscriptions by type{% endtrans %}<br><br>
{{ location[1] }} : <i class="nb">{{ subscriptions_total.filter(location=location[0]).count() }}</i><br> {% for location in locations %}
{% endfor %} {{ location[1] }} : <i class="nb">{{ subscriptions_total.filter(location=location[0]).count() }}</i><br>
<br>
<table>
<thead>
<th>{% trans %}Subscription type{% endtrans %}</th>
{% for location in locations %}
<th>{{ location[1] }}</th>
{% endfor %}
<th id="graphLabel">{% trans %}Total{% endtrans %}</th>
</thead>
{% for type in subscriptions_types %}
<tr>
<td><i class="types" >{{ subscriptions_types[type]['name'] }}</i></td>
{% set subscriptions_total_type = subscriptions_total.filter(subscription_type=type) %}
{% for location in locations %}
<td>
{% set subscriptions_total_type_location = subscriptions_total_type.filter(location=location[0]) %}
{% trans %}Total{% endtrans %} : {{ subscriptions_total_type_location.count()}}<br>
{% for p_type in payment_types %}
{{ p_type[1] }} : <i class="nb">{{ subscriptions_total_type_location.filter(payment_method=p_type[0]).count()}}</i><br>
{% endfor %}
</td>
{% endfor %}
<td class="total"><i class="nb">{{subscriptions_total_type.count()}}</i></td>
</tr>
{% endfor %} {% endfor %}
</table> <p>
<br>
<table>
<tr>
<th>{% trans %}Subscription type{% endtrans %}</th>
{% for location in locations %}
<th>{{ location[1] }}</th>
{% endfor %}
<th id="graphLabel">{% trans %}Total{% endtrans %}</th>
{% for type in subscriptions_types %}
<tr>
<td><i class="types" >{{ subscriptions_types[type]['name'] }}</i></td>
{% set subscriptions_total_type = subscriptions_total.filter(subscription_type=type) %}
{% for location in locations %}
<td>
{% set subscriptions_total_type_location = subscriptions_total_type.filter(location=location[0]) %}
{% trans %}Total{% endtrans %} : {{ subscriptions_total_type_location.count()}}<br>
{% for p_type in payment_types %}
{{ p_type[1] }} : <i class="nb">{{ subscriptions_total_type_location.filter(payment_method=p_type[0]).count()}}</i><br>
{% endfor %}
</td>
{% endfor %}
<td class="total"><i class="nb">{{subscriptions_total_type.count()}}</i>
</tr>
{% endfor %}
</table>
{% endblock %} {% endblock %}
+21 -4
View File
@@ -17,14 +17,16 @@ from django.conf import settings
from django.contrib.auth.forms import PasswordResetForm from django.contrib.auth.forms import PasswordResetForm
from django.contrib.auth.mixins import PermissionRequiredMixin from django.contrib.auth.mixins import PermissionRequiredMixin
from django.core.exceptions import PermissionDenied from django.core.exceptions import PermissionDenied
from django.urls import reverse from django.urls import reverse, reverse_lazy
from django.utils.timezone import localdate from django.utils.timezone import localdate
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from django.views.generic import CreateView, DetailView, TemplateView from django.views.generic import CreateView, DetailView, TemplateView
from django.views.generic.edit import FormView
from core.views import FragmentMixin, UseFragmentsMixin from core.views import FragmentMixin, UseFragmentsMixin
from core.views.group import PermissionGroupsUpdateView from core.views.group import PermissionGroupsUpdateView
from subscription.forms import ( from subscription.forms import (
SelectionDateForm,
SubscriptionExistingUserForm, SubscriptionExistingUserForm,
SubscriptionNewUserForm, SubscriptionNewUserForm,
) )
@@ -91,19 +93,34 @@ class SubscriptionPermissionView(PermissionGroupsUpdateView):
extra_context = {"object_name": _("the groups that can create subscriptions")} extra_context = {"object_name": _("the groups that can create subscriptions")}
class SubscriptionsStatsView(TemplateView): class SubscriptionsStatsView(FormView):
template_name = "subscription/stats.jinja" template_name = "subscription/stats.jinja"
form_class = SelectionDateForm
success_url = reverse_lazy("subscriptions:stats")
def dispatch(self, request, *arg, **kwargs): def dispatch(self, request, *arg, **kwargs):
self.start_date = localdate()
self.end_date = self.start_date
if request.user.is_root or request.user.is_board_member: if request.user.is_root or request.user.is_board_member:
return super().dispatch(request, *arg, **kwargs) return super().dispatch(request, *arg, **kwargs)
raise PermissionDenied 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"]
return super().post(request, *args, **kwargs)
def get_initial(self):
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"),
}
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
kwargs = super().get_context_data(**kwargs) kwargs = super().get_context_data(**kwargs)
today = localdate()
kwargs["subscriptions_total"] = Subscription.objects.filter( kwargs["subscriptions_total"] = Subscription.objects.filter(
subscription_end__gte=today, subscription_start__lte=today subscription_end__gte=self.end_date, subscription_start__lte=self.start_date
) )
kwargs["subscriptions_types"] = settings.SITH_SUBSCRIPTIONS kwargs["subscriptions_types"] = settings.SITH_SUBSCRIPTIONS
kwargs["payment_types"] = settings.SITH_SUBSCRIPTION_PAYMENT_METHOD kwargs["payment_types"] = settings.SITH_SUBSCRIPTION_PAYMENT_METHOD