diff --git a/accounting/tests.py b/accounting/tests.py
index c66558e0..1140acc7 100644
--- a/accounting/tests.py
+++ b/accounting/tests.py
@@ -216,7 +216,7 @@ class TestOperation(TestCase):
self.journal.operations.filter(target_label="Le fantome du jour").exists()
)
- def test__operation_simple_accounting(self):
+ def test_operation_simple_accounting(self):
sat = SimplifiedAccountingType.objects.all().first()
response = self.client.post(
reverse("accounting:op_new", args=[self.journal.id]),
@@ -237,15 +237,14 @@ class TestOperation(TestCase):
"done": False,
},
)
- self.assertFalse(response.status_code == 403)
- self.assertTrue(self.journal.operations.filter(amount=23).exists())
+ assert response.status_code != 403
+ assert self.journal.operations.filter(amount=23).exists()
response_get = self.client.get(
reverse("accounting:journal_details", args=[self.journal.id])
)
- self.assertTrue(
- "
Le fantome de l'aurore | " in str(response_get.content)
- )
- self.assertTrue(
+ assert "Le fantome de l'aurore | " in str(response_get.content)
+
+ assert (
self.journal.operations.filter(amount=23)
.values("accounting_type")
.first()["accounting_type"]
diff --git a/accounting/views.py b/accounting/views.py
index 928dc009..ce0ae45b 100644
--- a/accounting/views.py
+++ b/accounting/views.py
@@ -215,17 +215,14 @@ class JournalTabsMixin(TabedViewMixin):
return _("Journal")
def get_list_of_tabs(self):
- tab_list = []
- tab_list.append(
+ return [
{
"url": reverse(
"accounting:journal_details", kwargs={"j_id": self.object.id}
),
"slug": "journal",
"name": _("Journal"),
- }
- )
- tab_list.append(
+ },
{
"url": reverse(
"accounting:journal_nature_statement",
@@ -233,9 +230,7 @@ class JournalTabsMixin(TabedViewMixin):
),
"slug": "nature_statement",
"name": _("Statement by nature"),
- }
- )
- tab_list.append(
+ },
{
"url": reverse(
"accounting:journal_person_statement",
@@ -243,9 +238,7 @@ class JournalTabsMixin(TabedViewMixin):
),
"slug": "person_statement",
"name": _("Statement by person"),
- }
- )
- tab_list.append(
+ },
{
"url": reverse(
"accounting:journal_accounting_statement",
@@ -253,9 +246,8 @@ class JournalTabsMixin(TabedViewMixin):
),
"slug": "accounting_statement",
"name": _("Accounting statement"),
- }
- )
- return tab_list
+ },
+ ]
class JournalCreateView(CanCreateMixin, CreateView):
diff --git a/club/migrations/0010_auto_20170912_2028.py b/club/migrations/0010_auto_20170912_2028.py
index d6e28063..aa49fc4b 100644
--- a/club/migrations/0010_auto_20170912_2028.py
+++ b/club/migrations/0010_auto_20170912_2028.py
@@ -3,19 +3,6 @@ from __future__ import unicode_literals
import django.db.models.deletion
from django.db import migrations, models
-from club.models import Club
-from core.operations import PsqlRunOnly
-
-
-def generate_club_pages(apps, schema_editor):
- def recursive_generate_club_page(club):
- club.make_page()
- for child in Club.objects.filter(parent=club).all():
- recursive_generate_club_page(child)
-
- for club in Club.objects.filter(parent=None).all():
- recursive_generate_club_page(club)
-
class Migration(migrations.Migration):
dependencies = [("core", "0024_auto_20170906_1317"), ("club", "0010_club_logo")]
@@ -48,11 +35,4 @@ class Migration(migrations.Migration):
null=True,
),
),
- PsqlRunOnly(
- "SET CONSTRAINTS ALL IMMEDIATE", reverse_sql=migrations.RunSQL.noop
- ),
- migrations.RunPython(generate_club_pages),
- PsqlRunOnly(
- migrations.RunSQL.noop, reverse_sql="SET CONSTRAINTS ALL IMMEDIATE"
- ),
]
diff --git a/club/models.py b/club/models.py
index 573fd176..5300057d 100644
--- a/club/models.py
+++ b/club/models.py
@@ -31,14 +31,14 @@ from django.core.cache import cache
from django.core.exceptions import ObjectDoesNotExist, ValidationError
from django.core.validators import RegexValidator, validate_email
from django.db import models, transaction
-from django.db.models import Q
+from django.db.models import Exists, OuterRef, Q
from django.urls import reverse
from django.utils import timezone
from django.utils.functional import cached_property
from django.utils.timezone import localdate
from django.utils.translation import gettext_lazy as _
-from core.models import Group, MetaGroup, Notification, Page, RealGroup, SithFile, User
+from core.models import Group, MetaGroup, Notification, Page, SithFile, User
# Create your models here.
@@ -438,19 +438,18 @@ class Mailing(models.Model):
def save(self, *args, **kwargs):
if not self.is_moderated:
- for user in (
- RealGroup.objects.filter(id=settings.SITH_GROUP_COM_ADMIN_ID)
- .first()
- .users.all()
+ unread_notif_subquery = Notification.objects.filter(
+ user=OuterRef("pk"), type="MAILING_MODERATION", viewed=False
+ )
+ for user in User.objects.filter(
+ ~Exists(unread_notif_subquery),
+ groups__id__in=[settings.SITH_GROUP_COM_ADMIN_ID],
):
- if not user.notifications.filter(
- type="MAILING_MODERATION", viewed=False
- ).exists():
- Notification(
- user=user,
- url=reverse("com:mailing_admin"),
- type="MAILING_MODERATION",
- ).save(*args, **kwargs)
+ Notification(
+ user=user,
+ url=reverse("com:mailing_admin"),
+ type="MAILING_MODERATION",
+ ).save(*args, **kwargs)
super().save(*args, **kwargs)
def clean(self):
diff --git a/com/migrations/0007_alter_news_club_alter_news_content_and_more.py b/com/migrations/0007_alter_news_club_alter_news_content_and_more.py
new file mode 100644
index 00000000..99145cb7
--- /dev/null
+++ b/com/migrations/0007_alter_news_club_alter_news_content_and_more.py
@@ -0,0 +1,56 @@
+# Generated by Django 4.2.17 on 2024-12-16 14:51
+
+import django.db.models.deletion
+from django.conf import settings
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ("club", "0011_auto_20180426_2013"),
+ migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+ ("com", "0006_remove_sith_index_page"),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name="news",
+ name="club",
+ field=models.ForeignKey(
+ help_text="The club which organizes the event.",
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="news",
+ to="club.club",
+ verbose_name="club",
+ ),
+ ),
+ migrations.AlterField(
+ model_name="news",
+ name="content",
+ field=models.TextField(
+ blank=True,
+ default="",
+ help_text="A more detailed and exhaustive description of the event.",
+ verbose_name="content",
+ ),
+ ),
+ migrations.AlterField(
+ model_name="news",
+ name="moderator",
+ field=models.ForeignKey(
+ null=True,
+ on_delete=django.db.models.deletion.SET_NULL,
+ related_name="moderated_news",
+ to=settings.AUTH_USER_MODEL,
+ verbose_name="moderator",
+ ),
+ ),
+ migrations.AlterField(
+ model_name="news",
+ name="summary",
+ field=models.TextField(
+ help_text="A description of the event (what is the activity ? is there an associated clic ? is there a inscription form ?)",
+ verbose_name="summary",
+ ),
+ ),
+ ]
diff --git a/com/models.py b/com/models.py
index 5c1466ca..f3076174 100644
--- a/com/models.py
+++ b/com/models.py
@@ -34,7 +34,7 @@ from django.utils import timezone
from django.utils.translation import gettext_lazy as _
from club.models import Club
-from core.models import Notification, Preferences, RealGroup, User
+from core.models import Notification, Preferences, User
class Sith(models.Model):
@@ -62,16 +62,31 @@ NEWS_TYPES = [
class News(models.Model):
- """The news class."""
+ """News about club events."""
title = models.CharField(_("title"), max_length=64)
- summary = models.TextField(_("summary"))
- content = models.TextField(_("content"))
+ summary = models.TextField(
+ _("summary"),
+ help_text=_(
+ "A description of the event (what is the activity ? "
+ "is there an associated clic ? is there a inscription form ?)"
+ ),
+ )
+ content = models.TextField(
+ _("content"),
+ blank=True,
+ default="",
+ help_text=_("A more detailed and exhaustive description of the event."),
+ )
type = models.CharField(
_("type"), max_length=16, choices=NEWS_TYPES, default="EVENT"
)
club = models.ForeignKey(
- Club, related_name="news", verbose_name=_("club"), on_delete=models.CASCADE
+ Club,
+ related_name="news",
+ verbose_name=_("club"),
+ on_delete=models.CASCADE,
+ help_text=_("The club which organizes the event."),
)
author = models.ForeignKey(
User,
@@ -85,7 +100,7 @@ class News(models.Model):
related_name="moderated_news",
verbose_name=_("moderator"),
null=True,
- on_delete=models.CASCADE,
+ on_delete=models.SET_NULL,
)
def __str__(self):
@@ -93,17 +108,15 @@ class News(models.Model):
def save(self, *args, **kwargs):
super().save(*args, **kwargs)
- for u in (
- RealGroup.objects.filter(id=settings.SITH_GROUP_COM_ADMIN_ID)
- .first()
- .users.all()
+ for user in User.objects.filter(
+ groups__id__in=[settings.SITH_GROUP_COM_ADMIN_ID]
):
- Notification(
- user=u,
+ Notification.objects.create(
+ user=user,
url=reverse("com:news_admin_list"),
type="NEWS_MODERATION",
param="1",
- ).save()
+ )
def get_absolute_url(self):
return reverse("com:news_detail", kwargs={"news_id": self.id})
@@ -321,16 +334,14 @@ class Poster(models.Model):
def save(self, *args, **kwargs):
if not self.is_moderated:
- for u in (
- RealGroup.objects.filter(id=settings.SITH_GROUP_COM_ADMIN_ID)
- .first()
- .users.all()
+ for user in User.objects.filter(
+ groups__id__in=[settings.SITH_GROUP_COM_ADMIN_ID]
):
- Notification(
- user=u,
+ Notification.objects.create(
+ user=user,
url=reverse("com:poster_moderate_list"),
type="POSTER_MODERATION",
- ).save()
+ )
return super().save(*args, **kwargs)
def clean(self, *args, **kwargs):
diff --git a/com/templates/com/news_edit.jinja b/com/templates/com/news_edit.jinja
index 858ec9a8..74040dc8 100644
--- a/com/templates/com/news_edit.jinja
+++ b/com/templates/com/news_edit.jinja
@@ -34,43 +34,90 @@
{% csrf_token %}
{{ form.non_field_errors() }}
{{ form.author }}
- {{ form.type.errors }}
+
+ {{ form.type.errors }}
+
- {% trans %}Notice: Information, election result - no date{% endtrans %}
- {% trans %}Event: punctual event, associated with one date{% endtrans %}
- - {% trans %}Weekly: recurrent event, associated with many dates (specify the first one, and a deadline){% endtrans %}
- - {% trans %}Call: long time event, associated with a long date (election appliance, ...){% endtrans %}
+ -
+ {% trans trimmed%}
+ Weekly: recurrent event, associated with many dates
+ (specify the first one, and a deadline)
+ {% endtrans %}
+
+ -
+ {% trans trimmed %}
+ Call: long time event, associated with a long date (like election appliance)
+ {% endtrans %}
+
- {{ form.type }}
- {{ form.start_date.errors }} {{ form.start_date }}
- {{ form.end_date.errors }} {{ form.end_date }}
- {{ form.until.errors }} {{ form.until }}
- {{ form.title.errors }} {{ form.title }}
- {{ form.club.errors }} {{ form.club }}
- {{ form.summary.errors }} {{ form.summary }}
- {{ form.content.errors }} {{ form.content }}
+ {{ form.type }}
+
+
+ {{ form.start_date.errors }}
+
+ {{ form.start_date }}
+
+
+ {{ form.end_date.errors }}
+
+ {{ form.end_date }}
+
+
+ {{ form.until.errors }}
+
+ {{ form.until }}
+
+
+ {{ form.title.errors }}
+
+ {{ form.title }}
+
+
+ {{ form.club.errors }}
+
+ {{ form.club.help_text }}
+ {{ form.club }}
+
+
+ {{ form.summary.errors }}
+
+ {{ form.summary.help_text }}
+ {{ form.summary }}
+
+
+ {{ form.content.errors }}
+
+ {{ form.content.help_text }}
+ {{ form.content }}
+
{% if user.is_com_admin %}
- {{ form.automoderation.errors }}
- {{ form.automoderation }}
+
+ {{ form.automoderation.errors }}
+
+ {{ form.automoderation }}
+
{% endif %}
-
-
+
+
{% endblock %}
{% block script %}
{{ super() }}
{% endblock %}
diff --git a/com/tests.py b/com/tests.py
index 1c39fa36..399eb0e8 100644
--- a/com/tests.py
+++ b/com/tests.py
@@ -23,7 +23,7 @@ from django.utils.translation import gettext as _
from club.models import Club, Membership
from com.models import News, Poster, Sith, Weekmail, WeekmailArticle
-from core.models import AnonymousUser, RealGroup, User
+from core.models import AnonymousUser, Group, User
@pytest.fixture()
@@ -49,9 +49,7 @@ class TestCom(TestCase):
@classmethod
def setUpTestData(cls):
cls.skia = User.objects.get(username="skia")
- cls.com_group = RealGroup.objects.filter(
- id=settings.SITH_GROUP_COM_ADMIN_ID
- ).first()
+ cls.com_group = Group.objects.get(id=settings.SITH_GROUP_COM_ADMIN_ID)
cls.skia.groups.set([cls.com_group])
def setUp(self):
diff --git a/com/views.py b/com/views.py
index 69ee7221..d4136d20 100644
--- a/com/views.py
+++ b/com/views.py
@@ -28,7 +28,7 @@ from smtplib import SMTPRecipientsRefused
from django import forms
from django.conf import settings
from django.core.exceptions import PermissionDenied, ValidationError
-from django.db.models import Max
+from django.db.models import Exists, Max, OuterRef
from django.forms.models import modelform_factory
from django.http import HttpResponseRedirect
from django.shortcuts import get_object_or_404, redirect
@@ -42,7 +42,7 @@ from django.views.generic.edit import CreateView, DeleteView, UpdateView
from club.models import Club, Mailing
from com.models import News, NewsDate, Poster, Screen, Sith, Weekmail, WeekmailArticle
-from core.models import Notification, RealGroup, User
+from core.models import Notification, User
from core.views import (
CanCreateMixin,
CanEditMixin,
@@ -223,15 +223,13 @@ class NewsForm(forms.ModelForm):
):
self.add_error(
"end_date",
- ValidationError(
- _("You crazy? You can not finish an event before starting it.")
- ),
+ ValidationError(_("An event cannot end before its beginning.")),
)
if self.cleaned_data["type"] == "WEEKLY" and not self.cleaned_data["until"]:
self.add_error("until", ValidationError(_("This field is required.")))
return self.cleaned_data
- def save(self):
+ def save(self, *args, **kwargs):
ret = super().save()
self.instance.dates.all().delete()
if self.instance.type == "EVENT" or self.instance.type == "CALL":
@@ -280,21 +278,18 @@ class NewsEditView(CanEditMixin, UpdateView):
else:
self.object.is_moderated = False
self.object.save()
- for u in (
- RealGroup.objects.filter(id=settings.SITH_GROUP_COM_ADMIN_ID)
- .first()
- .users.all()
+ unread_notif_subquery = Notification.objects.filter(
+ user=OuterRef("pk"), type="NEWS_MODERATION", viewed=False
+ )
+ for user in User.objects.filter(
+ ~Exists(unread_notif_subquery),
+ groups__id__in=[settings.SITH_GROUP_COM_ADMIN_ID],
):
- if not u.notifications.filter(
- type="NEWS_MODERATION", viewed=False
- ).exists():
- Notification(
- user=u,
- url=reverse(
- "com:news_detail", kwargs={"news_id": self.object.id}
- ),
- type="NEWS_MODERATION",
- ).save()
+ Notification.objects.create(
+ user=user,
+ url=self.object.get_absolute_url(),
+ type="NEWS_MODERATION",
+ )
return super().form_valid(form)
@@ -325,19 +320,18 @@ class NewsCreateView(CanCreateMixin, CreateView):
self.object.is_moderated = True
self.object.save()
else:
- for u in (
- RealGroup.objects.filter(id=settings.SITH_GROUP_COM_ADMIN_ID)
- .first()
- .users.all()
+ unread_notif_subquery = Notification.objects.filter(
+ user=OuterRef("pk"), type="NEWS_MODERATION", viewed=False
+ )
+ for user in User.objects.filter(
+ ~Exists(unread_notif_subquery),
+ groups__id__in=[settings.SITH_GROUP_COM_ADMIN_ID],
):
- if not u.notifications.filter(
- type="NEWS_MODERATION", viewed=False
- ).exists():
- Notification(
- user=u,
- url=reverse("com:news_admin_list"),
- type="NEWS_MODERATION",
- ).save()
+ Notification.objects.create(
+ user=user,
+ url=reverse("com:news_admin_list"),
+ type="NEWS_MODERATION",
+ )
return super().form_valid(form)
diff --git a/core/management/commands/populate.py b/core/management/commands/populate.py
index 7098101a..5770c715 100644
--- a/core/management/commands/populate.py
+++ b/core/management/commands/populate.py
@@ -261,19 +261,19 @@ class Command(BaseCommand):
User.groups.through.objects.bulk_create(
[
User.groups.through(
- realgroup_id=settings.SITH_GROUP_COUNTER_ADMIN_ID, user=counter
+ group_id=settings.SITH_GROUP_COUNTER_ADMIN_ID, user=counter
),
User.groups.through(
- realgroup_id=settings.SITH_GROUP_ACCOUNTING_ADMIN_ID, user=comptable
+ group_id=settings.SITH_GROUP_ACCOUNTING_ADMIN_ID, user=comptable
),
User.groups.through(
- realgroup_id=settings.SITH_GROUP_COM_ADMIN_ID, user=comunity
+ group_id=settings.SITH_GROUP_COM_ADMIN_ID, user=comunity
),
User.groups.through(
- realgroup_id=settings.SITH_GROUP_PEDAGOGY_ADMIN_ID, user=tutu
+ group_id=settings.SITH_GROUP_PEDAGOGY_ADMIN_ID, user=tutu
),
User.groups.through(
- realgroup_id=settings.SITH_GROUP_SAS_ADMIN_ID, user=skia
+ group_id=settings.SITH_GROUP_SAS_ADMIN_ID, user=skia
),
]
)
diff --git a/core/management/commands/populate_more.py b/core/management/commands/populate_more.py
index eaac58c0..f8ac1cef 100644
--- a/core/management/commands/populate_more.py
+++ b/core/management/commands/populate_more.py
@@ -11,7 +11,7 @@ from django.utils.timezone import localdate, make_aware, now
from faker import Faker
from club.models import Club, Membership
-from core.models import RealGroup, User
+from core.models import Group, User
from counter.models import (
Counter,
Customer,
@@ -225,9 +225,7 @@ class Command(BaseCommand):
ae = Club.objects.get(unix_name="ae")
other_clubs = random.sample(list(Club.objects.all()), k=3)
groups = list(
- RealGroup.objects.filter(
- name__in=["Subscribers", "Old subscribers", "Public"]
- )
+ Group.objects.filter(name__in=["Subscribers", "Old subscribers", "Public"])
)
counters = list(
Counter.objects.filter(name__in=["Foyer", "MDE", "La Gommette", "Eboutic"])
diff --git a/core/migrations/0040_alter_user_options_user_user_permissions_and_more.py b/core/migrations/0040_alter_user_options_user_user_permissions_and_more.py
new file mode 100644
index 00000000..43e4911c
--- /dev/null
+++ b/core/migrations/0040_alter_user_options_user_user_permissions_and_more.py
@@ -0,0 +1,82 @@
+# Generated by Django 4.2.16 on 2024-11-20 16:22
+
+import django.contrib.auth.validators
+import django.utils.timezone
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ("auth", "0012_alter_user_first_name_max_length"),
+ ("core", "0039_alter_user_managers"),
+ ]
+
+ operations = [
+ migrations.AlterModelOptions(
+ name="user",
+ options={"verbose_name": "user", "verbose_name_plural": "users"},
+ ),
+ migrations.AddField(
+ model_name="user",
+ name="user_permissions",
+ field=models.ManyToManyField(
+ blank=True,
+ help_text="Specific permissions for this user.",
+ related_name="user_set",
+ related_query_name="user",
+ to="auth.permission",
+ verbose_name="user permissions",
+ ),
+ ),
+ migrations.AlterField(
+ model_name="user",
+ name="date_joined",
+ field=models.DateTimeField(
+ default=django.utils.timezone.now, verbose_name="date joined"
+ ),
+ ),
+ migrations.AlterField(
+ model_name="user",
+ name="is_superuser",
+ field=models.BooleanField(
+ default=False,
+ help_text="Designates that this user has all permissions without explicitly assigning them.",
+ verbose_name="superuser status",
+ ),
+ ),
+ migrations.AlterField(
+ model_name="user",
+ name="username",
+ field=models.CharField(
+ error_messages={"unique": "A user with that username already exists."},
+ help_text="Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.",
+ max_length=150,
+ unique=True,
+ validators=[django.contrib.auth.validators.UnicodeUsernameValidator()],
+ verbose_name="username",
+ ),
+ ),
+ migrations.AlterField(
+ model_name="user",
+ name="groups",
+ field=models.ManyToManyField(
+ blank=True,
+ help_text="The groups this user belongs to. A user will get all permissions granted to each of their groups.",
+ related_name="user_set",
+ related_query_name="user",
+ to="auth.group",
+ verbose_name="groups",
+ ),
+ ),
+ migrations.AlterField(
+ model_name="user",
+ name="groups",
+ field=models.ManyToManyField(
+ blank=True,
+ help_text="The groups this user belongs to. A user will get all permissions granted to each of their groups.",
+ related_name="users",
+ to="core.group",
+ verbose_name="groups",
+ ),
+ ),
+ ]
diff --git a/core/models.py b/core/models.py
index 7a9d3a46..347e9bd4 100644
--- a/core/models.py
+++ b/core/models.py
@@ -30,19 +30,13 @@ import string
import unicodedata
from datetime import timedelta
from pathlib import Path
-from typing import TYPE_CHECKING, Any, Optional, Self
+from typing import TYPE_CHECKING, Optional, Self
from django.conf import settings
-from django.contrib.auth.models import AbstractBaseUser, UserManager
-from django.contrib.auth.models import (
- AnonymousUser as AuthAnonymousUser,
-)
-from django.contrib.auth.models import (
- Group as AuthGroup,
-)
-from django.contrib.auth.models import (
- GroupManager as AuthGroupManager,
-)
+from django.contrib.auth.models import AbstractUser, UserManager
+from django.contrib.auth.models import AnonymousUser as AuthAnonymousUser
+from django.contrib.auth.models import Group as AuthGroup
+from django.contrib.auth.models import GroupManager as AuthGroupManager
from django.contrib.staticfiles.storage import staticfiles_storage
from django.core import validators
from django.core.cache import cache
@@ -242,7 +236,7 @@ class CustomUserManager(UserManager.from_queryset(UserQuerySet)):
pass
-class User(AbstractBaseUser):
+class User(AbstractUser):
"""Defines the base user class, useable in every app.
This is almost the same as the auth module AbstractUser since it inherits from it,
@@ -253,51 +247,22 @@ class User(AbstractBaseUser):
Required fields: email, first_name, last_name, date_of_birth
"""
- username = models.CharField(
- _("username"),
- max_length=254,
- unique=True,
- help_text=_(
- "Required. 254 characters or fewer. Letters, digits and ./+/-/_ only."
- ),
- validators=[
- validators.RegexValidator(
- r"^[\w.+-]+$",
- _(
- "Enter a valid username. This value may contain only "
- "letters, numbers "
- "and ./+/-/_ characters."
- ),
- )
- ],
- error_messages={"unique": _("A user with that username already exists.")},
- )
first_name = models.CharField(_("first name"), max_length=64)
last_name = models.CharField(_("last name"), max_length=64)
email = models.EmailField(_("email address"), unique=True)
date_of_birth = models.DateField(_("date of birth"), blank=True, null=True)
nick_name = models.CharField(_("nick name"), max_length=64, null=True, blank=True)
- is_staff = models.BooleanField(
- _("staff status"),
- default=False,
- help_text=_("Designates whether the user can log into this admin site."),
- )
- is_active = models.BooleanField(
- _("active"),
- default=True,
- help_text=_(
- "Designates whether this user should be treated as active. "
- "Unselect this instead of deleting accounts."
- ),
- )
- date_joined = models.DateField(_("date joined"), auto_now_add=True)
last_update = models.DateTimeField(_("last update"), auto_now=True)
- is_superuser = models.BooleanField(
- _("superuser"),
- default=False,
- help_text=_("Designates whether this user is a superuser. "),
+ groups = models.ManyToManyField(
+ Group,
+ verbose_name=_("groups"),
+ help_text=_(
+ "The groups this user belongs to. A user will get all permissions "
+ "granted to each of their groups."
+ ),
+ related_name="users",
+ blank=True,
)
- groups = models.ManyToManyField(RealGroup, related_name="users", blank=True)
home = models.OneToOneField(
"SithFile",
related_name="home_of",
@@ -401,8 +366,6 @@ class User(AbstractBaseUser):
objects = CustomUserManager()
- USERNAME_FIELD = "username"
-
def __str__(self):
return self.get_display_name()
@@ -422,22 +385,23 @@ class User(AbstractBaseUser):
settings.BASE_DIR / f"core/static/core/img/promo_{self.promo}.png"
).exists()
- def has_module_perms(self, package_name: str) -> bool:
- return self.is_active
-
- def has_perm(self, perm: str, obj: Any = None) -> bool:
- return self.is_active and self.is_superuser
-
@cached_property
def was_subscribed(self) -> bool:
+ if "is_subscribed" in self.__dict__ and self.is_subscribed:
+ # if the user is currently subscribed, he is an old subscriber too
+ # if the property has already been cached, avoid another request
+ return True
return self.subscriptions.exists()
@cached_property
def is_subscribed(self) -> bool:
- s = self.subscriptions.filter(
+ if "was_subscribed" in self.__dict__ and not self.was_subscribed:
+ # if the user never subscribed, he cannot be a subscriber now.
+ # if the property has already been cached, avoid another request
+ return False
+ return self.subscriptions.filter(
subscription_start__lte=timezone.now(), subscription_end__gte=timezone.now()
- )
- return s.exists()
+ ).exists()
@cached_property
def account_balance(self):
@@ -530,10 +494,8 @@ class User(AbstractBaseUser):
@cached_property
def can_create_subscription(self) -> bool:
- from club.models import Membership
-
- return (
- Membership.objects.board()
+ return self.is_root or (
+ self.memberships.board()
.ongoing()
.filter(club_id__in=settings.SITH_CAN_CREATE_SUBSCRIPTIONS)
.exists()
@@ -601,11 +563,6 @@ class User(AbstractBaseUser):
"date_of_birth": self.date_of_birth,
}
- def get_full_name(self):
- """Returns the first_name plus the last_name, with a space in between."""
- full_name = "%s %s" % (self.first_name, self.last_name)
- return full_name.strip()
-
def get_short_name(self):
"""Returns the short name for the user."""
if self.nick_name:
@@ -984,13 +941,11 @@ class SithFile(models.Model):
if copy_rights:
self.copy_rights()
if self.is_in_sas:
- for u in (
- RealGroup.objects.filter(id=settings.SITH_GROUP_SAS_ADMIN_ID)
- .first()
- .users.all()
+ for user in User.objects.filter(
+ groups__id__in=[settings.SITH_GROUP_SAS_ADMIN_ID]
):
Notification(
- user=u,
+ user=user,
url=reverse("sas:moderation"),
type="SAS_MODERATION",
param="1",
diff --git a/core/static/bundled/alpine-index.js b/core/static/bundled/alpine-index.js
index d07e0bf2..211600a5 100644
--- a/core/static/bundled/alpine-index.js
+++ b/core/static/bundled/alpine-index.js
@@ -1,5 +1,7 @@
+import sort from "@alpinejs/sort";
import Alpine from "alpinejs";
+Alpine.plugin(sort);
window.Alpine = Alpine;
window.addEventListener("DOMContentLoaded", () => {
diff --git a/core/static/bundled/core/components/ajax-select-base.ts b/core/static/bundled/core/components/ajax-select-base.ts
index 674b7b73..06c3508e 100644
--- a/core/static/bundled/core/components/ajax-select-base.ts
+++ b/core/static/bundled/core/components/ajax-select-base.ts
@@ -67,6 +67,8 @@ export class AutoCompleteSelectBase extends inheritHtmlElement("select") {
remove_button: {
title: gettext("Remove"),
},
+ // biome-ignore lint/style/useNamingConvention: this is required by the api
+ restore_on_backspace: {},
},
persist: false,
maxItems: this.node.multiple ? this.max : 1,
diff --git a/core/static/core/forms.scss b/core/static/core/forms.scss
index 7dab0484..42a4d719 100644
--- a/core/static/core/forms.scss
+++ b/core/static/core/forms.scss
@@ -87,3 +87,38 @@ a:not(.button) {
color: $primary-color;
}
}
+
+form {
+ margin: 0 auto 10px;
+
+ .helptext {
+ margin-top: .25rem;
+ margin-bottom: .25rem;
+ font-size: 80%;
+ }
+
+ fieldset {
+ margin-bottom: 1rem;
+ }
+
+ .row {
+ label {
+ margin: unset;
+ }
+ }
+
+ label {
+ display: block;
+ margin-bottom: 8px;
+
+ &.required:after {
+ margin-left: 4px;
+ content: "*";
+ color: red;
+ }
+ }
+
+ .choose_file_widget {
+ display: none;
+ }
+}
diff --git a/core/static/core/style.scss b/core/static/core/style.scss
index cbe8d326..d7a396d1 100644
--- a/core/static/core/style.scss
+++ b/core/static/core/style.scss
@@ -314,6 +314,17 @@ body {
}
}
+ .snackbar {
+ width: 250px;
+ margin-left: -125px;
+ box-sizing: border-box;
+ position: fixed;
+ z-index: 1;
+ left: 50%;
+ top: 60px;
+ text-align: center;
+ }
+
.tabs {
border-radius: 5px;
@@ -1401,21 +1412,6 @@ footer {
}
}
-/*---------------------------------FORMS-------------------------------*/
-
-form {
- margin: 0 auto;
- margin-bottom: 10px;
-}
-
-label {
- display: block;
- margin-bottom: 8px;
-}
-
-.choose_file_widget {
- display: none;
-}
.ui-dialog .ui-dialog-buttonpane {
bottom: 0;
diff --git a/core/templates/core/macros_pages.jinja b/core/templates/core/macros_pages.jinja
index c18bfe31..79228ae7 100644
--- a/core/templates/core/macros_pages.jinja
+++ b/core/templates/core/macros_pages.jinja
@@ -3,17 +3,18 @@
{% macro page_history(page) %}
{% trans page_name=page.name %}You're seeing the history of page "{{ page_name }}"{% endtrans %}
- {% for r in (page.revisions.all()|sort(attribute='date', reverse=True)) %}
- {% if loop.index < 2 %}
- - {% trans %}last{% endtrans %} -
- {{ user_profile_link(page.revisions.last().author) }} -
- {{ page.revisions.last().date|localtime|date(DATETIME_FORMAT) }} {{ page.revisions.last().date|localtime|time(DATETIME_FORMAT) }}
- {% else %}
- - {{ r.revision }} -
- {{ user_profile_link(r.author) }} -
- {{ r.date|localtime|date(DATETIME_FORMAT) }} {{ r.date|localtime|time(DATETIME_FORMAT) }}
- {% endif %}
- {% endfor %}
+ {% set page_name = page.get_full_name() %}
+ {%- for rev in page.revisions.order_by("-date").select_related("author") -%}
+ -
+ {% if loop.first %}
+ {% trans %}last{% endtrans %}
+ {% else %}
+ {{ rev.revision }}
+ {% endif %}
+ {{ user_profile_link(rev.author) }} -
+ {{ rev.date|localtime|date(DATETIME_FORMAT) }} {{ rev.date|localtime|time(DATETIME_FORMAT) }}
+
+ {%- endfor -%}
{% endmacro %}
diff --git a/core/templates/core/user_tools.jinja b/core/templates/core/user_tools.jinja
index e00b24da..d9a6c0c7 100644
--- a/core/templates/core/user_tools.jinja
+++ b/core/templates/core/user_tools.jinja
@@ -52,7 +52,7 @@
%}
{% trans %}General counters management{% endtrans %}
{% trans %}Products management{% endtrans %}
- {% trans %}Product types management{% endtrans %}
+ {% trans %}Product types management{% endtrans %}
{% trans %}Cash register summaries{% endtrans %}
{% trans %}Invoices call{% endtrans %}
{% trans %}Etickets{% endtrans %}
diff --git a/core/tests/test_core.py b/core/tests/test_core.py
index 9b70e886..a33a8705 100644
--- a/core/tests/test_core.py
+++ b/core/tests/test_core.py
@@ -118,7 +118,9 @@ class TestUserRegistration:
response = client.post(reverse("core:register"), valid_payload)
assert response.status_code == 200
- error_html = "Un objet User avec ce champ Adresse email existe déjà."
+ error_html = (
+ "Un objet Utilisateur avec ce champ Adresse email existe déjà."
+ )
assertInHTML(error_html, str(response.content.decode()))
def test_register_fail_with_not_existing_email(
diff --git a/core/tests/test_files.py b/core/tests/test_files.py
index 1f39fcd8..998ceab5 100644
--- a/core/tests/test_files.py
+++ b/core/tests/test_files.py
@@ -14,7 +14,7 @@ from PIL import Image
from pytest_django.asserts import assertNumQueries
from core.baker_recipes import board_user, old_subscriber_user, subscriber_user
-from core.models import Group, RealGroup, SithFile, User
+from core.models import Group, SithFile, User
from sas.models import Picture
from sith import settings
@@ -26,12 +26,10 @@ class TestImageAccess:
[
lambda: baker.make(User, is_superuser=True),
lambda: baker.make(
- User,
- groups=[RealGroup.objects.get(pk=settings.SITH_GROUP_SAS_ADMIN_ID)],
+ User, groups=[Group.objects.get(pk=settings.SITH_GROUP_SAS_ADMIN_ID)]
),
lambda: baker.make(
- User,
- groups=[RealGroup.objects.get(pk=settings.SITH_GROUP_COM_ADMIN_ID)],
+ User, groups=[Group.objects.get(pk=settings.SITH_GROUP_COM_ADMIN_ID)]
),
],
)
diff --git a/core/views/files.py b/core/views/files.py
index 0d083a84..f8539080 100644
--- a/core/views/files.py
+++ b/core/views/files.py
@@ -21,6 +21,7 @@ from wsgiref.util import FileWrapper
from django import forms
from django.conf import settings
from django.core.exceptions import PermissionDenied
+from django.db.models import Exists, OuterRef
from django.forms.models import modelform_factory
from django.http import Http404, HttpRequest, HttpResponse
from django.shortcuts import get_object_or_404, redirect
@@ -31,7 +32,7 @@ from django.views.generic import DetailView, ListView
from django.views.generic.detail import SingleObjectMixin
from django.views.generic.edit import DeleteView, FormMixin, UpdateView
-from core.models import Notification, RealGroup, SithFile, User
+from core.models import Notification, SithFile, User
from core.views import (
AllowFragment,
CanEditMixin,
@@ -159,19 +160,18 @@ class AddFilesForm(forms.Form):
% {"file_name": f, "msg": repr(e)},
)
if notif:
- for u in (
- RealGroup.objects.filter(id=settings.SITH_GROUP_COM_ADMIN_ID)
- .first()
- .users.all()
+ unread_notif_subquery = Notification.objects.filter(
+ user=OuterRef("pk"), type="FILE_MODERATION", viewed=False
+ )
+ for user in User.objects.filter(
+ ~Exists(unread_notif_subquery),
+ groups__id__in=[settings.SITH_GROUP_COM_ADMIN_ID],
):
- if not u.notifications.filter(
- type="FILE_MODERATION", viewed=False
- ).exists():
- Notification(
- user=u,
- url=reverse("core:file_moderation"),
- type="FILE_MODERATION",
- ).save()
+ Notification.objects.create(
+ user=user,
+ url=reverse("core:file_moderation"),
+ type="FILE_MODERATION",
+ )
class FileListView(ListView):
diff --git a/core/views/forms.py b/core/views/forms.py
index de01f7aa..8a998ab0 100644
--- a/core/views/forms.py
+++ b/core/views/forms.py
@@ -44,7 +44,7 @@ from phonenumber_field.widgets import RegionalPhoneNumberWidget
from PIL import Image
from antispam.forms import AntiSpamEmailField
-from core.models import Gift, Page, SithFile, User
+from core.models import Gift, Page, RealGroup, SithFile, User
from core.utils import resize_image
from core.views.widgets.select import (
AutoCompleteSelect,
@@ -167,9 +167,7 @@ class RegisteringForm(UserCreationForm):
class Meta:
model = User
fields = ("first_name", "last_name", "email")
- field_classes = {
- "email": AntiSpamEmailField,
- }
+ field_classes = {"email": AntiSpamEmailField}
class UserProfileForm(forms.ModelForm):
@@ -287,15 +285,19 @@ class UserProfileForm(forms.ModelForm):
self._post_clean()
-class UserPropForm(forms.ModelForm):
+class UserGroupsForm(forms.ModelForm):
error_css_class = "error"
required_css_class = "required"
+ groups = forms.ModelMultipleChoiceField(
+ queryset=RealGroup.objects.all(),
+ widget=CheckboxSelectMultiple,
+ label=_("Groups"),
+ )
+
class Meta:
model = User
fields = ["groups"]
- help_texts = {"groups": "Which groups this user belongs to"}
- widgets = {"groups": CheckboxSelectMultiple}
class UserGodfathersForm(forms.Form):
diff --git a/core/views/group.py b/core/views/group.py
index abb0097f..b6e77b54 100644
--- a/core/views/group.py
+++ b/core/views/group.py
@@ -23,9 +23,7 @@ from django.views.generic.edit import CreateView, DeleteView, UpdateView
from core.models import RealGroup, User
from core.views import CanCreateMixin, CanEditMixin, DetailFormView
-from core.views.widgets.select import (
- AutoCompleteSelectMultipleUser,
-)
+from core.views.widgets.select import AutoCompleteSelectMultipleUser
# Forms
diff --git a/core/views/user.py b/core/views/user.py
index 9f724fca..264a8dd6 100644
--- a/core/views/user.py
+++ b/core/views/user.py
@@ -35,7 +35,6 @@ from django.contrib.auth.mixins import LoginRequiredMixin
from django.core.exceptions import PermissionDenied
from django.db.models import DateField, QuerySet
from django.db.models.functions import Trunc
-from django.forms import CheckboxSelectMultiple
from django.forms.models import modelform_factory
from django.http import Http404
from django.shortcuts import get_object_or_404, redirect
@@ -68,6 +67,7 @@ from core.views.forms import (
LoginForm,
RegisteringForm,
UserGodfathersForm,
+ UserGroupsForm,
UserProfileForm,
)
from counter.models import Refilling, Selling
@@ -583,9 +583,7 @@ class UserUpdateGroupView(UserTabsMixin, CanEditPropMixin, UpdateView):
model = User
pk_url_kwarg = "user_id"
template_name = "core/user_group.jinja"
- form_class = modelform_factory(
- User, fields=["groups"], widgets={"groups": CheckboxSelectMultiple}
- )
+ form_class = UserGroupsForm
context_object_name = "profile"
current_tab = "groups"
diff --git a/counter/admin.py b/counter/admin.py
index b3e6a91a..5dc795f2 100644
--- a/counter/admin.py
+++ b/counter/admin.py
@@ -129,7 +129,7 @@ class PermanencyAdmin(SearchModelAdmin):
@admin.register(ProductType)
class ProductTypeAdmin(admin.ModelAdmin):
- list_display = ("name", "priority")
+ list_display = ("name", "order")
@admin.register(CashRegisterSummary)
diff --git a/counter/api.py b/counter/api.py
index f3f0f101..dd7b75f0 100644
--- a/counter/api.py
+++ b/counter/api.py
@@ -12,24 +12,33 @@
# OR WITHIN THE LOCAL FILE "LICENSE"
#
#
-from typing import Annotated
-
-from annotated_types import MinLen
-from django.db.models import Q
+from django.conf import settings
+from django.db.models import F
+from django.shortcuts import get_object_or_404
from ninja import Query
from ninja_extra import ControllerBase, api_controller, paginate, route
from ninja_extra.pagination import PageNumberPaginationExtra
from ninja_extra.schemas import PaginatedResponseSchema
-from core.api_permissions import CanAccessLookup, CanView, IsRoot
-from counter.models import Counter, Product
+from core.api_permissions import CanAccessLookup, CanView, IsInGroup, IsRoot
+from counter.models import Counter, Product, ProductType
from counter.schemas import (
CounterFilterSchema,
CounterSchema,
+ ProductFilterSchema,
ProductSchema,
+ ProductTypeSchema,
+ ReorderProductTypeSchema,
+ SimpleProductSchema,
SimplifiedCounterSchema,
)
+IsCounterAdmin = (
+ IsRoot
+ | IsInGroup(settings.SITH_GROUP_COUNTER_ADMIN_ID)
+ | IsInGroup(settings.SITH_GROUP_ACCOUNTING_ADMIN_ID)
+)
+
@api_controller("/counter")
class CounterController(ControllerBase):
@@ -64,15 +73,72 @@ class CounterController(ControllerBase):
class ProductController(ControllerBase):
@route.get(
"/search",
- response=PaginatedResponseSchema[ProductSchema],
+ response=PaginatedResponseSchema[SimpleProductSchema],
permissions=[CanAccessLookup],
)
@paginate(PageNumberPaginationExtra, page_size=50)
- def search_products(self, search: Annotated[str, MinLen(1)]):
- return (
- Product.objects.filter(
- Q(name__icontains=search) | Q(code__icontains=search)
- )
- .filter(archived=False)
- .values()
+ def search_products(self, filters: Query[ProductFilterSchema]):
+ return filters.filter(
+ Product.objects.order_by(
+ F("product_type__order").asc(nulls_last=True),
+ "product_type",
+ "name",
+ ).values()
)
+
+ @route.get(
+ "/search/detailed",
+ response=PaginatedResponseSchema[ProductSchema],
+ permissions=[IsCounterAdmin],
+ url_name="search_products_detailed",
+ )
+ @paginate(PageNumberPaginationExtra, page_size=50)
+ def search_products_detailed(self, filters: Query[ProductFilterSchema]):
+ """Get the detailed information about the products."""
+ return filters.filter(
+ Product.objects.select_related("club")
+ .prefetch_related("buying_groups")
+ .select_related("product_type")
+ .order_by(
+ F("product_type__order").asc(nulls_last=True),
+ "product_type",
+ "name",
+ )
+ )
+
+
+@api_controller("/product-type", permissions=[IsCounterAdmin])
+class ProductTypeController(ControllerBase):
+ @route.get("", response=list[ProductTypeSchema], url_name="fetch_product_types")
+ def fetch_all(self):
+ return ProductType.objects.order_by("order")
+
+ @route.patch("/{type_id}/move")
+ def reorder(self, type_id: int, other_id: Query[ReorderProductTypeSchema]):
+ """Change the order of a product type.
+
+ To use this route, give either the id of the product type
+ this one should be above of,
+ of the id of the product type this one should be below of.
+
+ Order affects the display order of the product types.
+
+ Examples:
+ ```
+ GET /api/counter/product-type
+ => [<1: type A>, <2: type B>, <3: type C>]
+
+ PATCH /api/counter/product-type/3/move?below=1
+
+ GET /api/counter/product-type
+ => [<1: type A>, <3: type C>, <2: type B>]
+ ```
+ """
+ product_type: ProductType = self.get_object_or_exception(
+ ProductType, pk=type_id
+ )
+ other = get_object_or_404(ProductType, pk=other_id.above or other_id.below)
+ if other_id.below is not None:
+ product_type.below(other)
+ else:
+ product_type.above(other)
diff --git a/counter/migrations/0013_customer_recorded_products.py b/counter/migrations/0013_customer_recorded_products.py
index f2a48ba5..72825d16 100644
--- a/counter/migrations/0013_customer_recorded_products.py
+++ b/counter/migrations/0013_customer_recorded_products.py
@@ -1,38 +1,6 @@
from __future__ import unicode_literals
-from django.conf import settings
from django.db import migrations, models
-from django.utils.translation import gettext_lazy as _
-
-from core.models import User
-from counter.models import Counter, Customer, Product, Selling
-
-
-def balance_ecocups(apps, schema_editor):
- for customer in Customer.objects.all():
- customer.recorded_products = 0
- for selling in customer.buyings.filter(
- product__id__in=[settings.SITH_ECOCUP_CONS, settings.SITH_ECOCUP_DECO]
- ).all():
- if selling.product.is_record_product:
- customer.recorded_products += selling.quantity
- elif selling.product.is_unrecord_product:
- customer.recorded_products -= selling.quantity
- if customer.recorded_products < -settings.SITH_ECOCUP_LIMIT:
- qt = -(customer.recorded_products + settings.SITH_ECOCUP_LIMIT)
- cons = Product.objects.get(id=settings.SITH_ECOCUP_CONS)
- Selling(
- label=_("Ecocup regularization"),
- product=cons,
- unit_price=cons.selling_price,
- club=cons.club,
- counter=Counter.objects.filter(name="Foyer").first(),
- quantity=qt,
- seller=User.objects.get(id=0),
- customer=customer,
- ).save(allow_negative=True)
- customer.recorded_products += qt
- customer.save()
class Migration(migrations.Migration):
@@ -44,5 +12,4 @@ class Migration(migrations.Migration):
name="recorded_products",
field=models.IntegerField(verbose_name="recorded items", default=0),
),
- migrations.RunPython(balance_ecocups),
]
diff --git a/counter/migrations/0028_alter_producttype_comment_and_more.py b/counter/migrations/0028_alter_producttype_comment_and_more.py
new file mode 100644
index 00000000..f7fabb83
--- /dev/null
+++ b/counter/migrations/0028_alter_producttype_comment_and_more.py
@@ -0,0 +1,62 @@
+# Generated by Django 4.2.17 on 2024-12-15 17:53
+
+from django.db import migrations, models
+from django.db.migrations.state import StateApps
+
+
+def move_priority_to_order(apps: StateApps, schema_editor):
+ """Migrate the previous homemade `priority` to `OrderedModel.order`.
+
+ `priority` was a system were click managers set themselves the priority
+ of a ProductType.
+ The higher the priority, the higher it was to be displayed in the eboutic.
+ Multiple product types could share the same priority, in which
+ case they were ordered by alphabetic order.
+
+ The new field is unique per object, and works in the other way :
+ the nearer from 0, the higher it should appear.
+ """
+ ProductType = apps.get_model("counter", "ProductType")
+ product_types = list(ProductType.objects.order_by("-priority", "name"))
+ for order, product_type in enumerate(product_types):
+ product_type.order = order
+ ProductType.objects.bulk_update(product_types, ["order"])
+
+
+class Migration(migrations.Migration):
+ dependencies = [("counter", "0027_alter_refilling_payment_method")]
+
+ operations = [
+ migrations.AlterField(
+ model_name="producttype",
+ name="comment",
+ field=models.TextField(
+ default="",
+ help_text="A text that will be shown on the eboutic.",
+ verbose_name="comment",
+ ),
+ ),
+ migrations.AlterField(
+ model_name="producttype",
+ name="description",
+ field=models.TextField(default="", verbose_name="description"),
+ ),
+ migrations.AlterModelOptions(
+ name="producttype",
+ options={"ordering": ["order"], "verbose_name": "product type"},
+ ),
+ migrations.AddField(
+ model_name="producttype",
+ name="order",
+ field=models.PositiveIntegerField(
+ db_index=True, default=0, editable=False, verbose_name="order"
+ ),
+ preserve_default=False,
+ ),
+ migrations.RunPython(
+ move_priority_to_order,
+ reverse_code=migrations.RunPython.noop,
+ elidable=True,
+ ),
+ migrations.RemoveField(model_name="producttype", name="priority"),
+ ]
diff --git a/counter/models.py b/counter/models.py
index 087baffc..48bb841f 100644
--- a/counter/models.py
+++ b/counter/models.py
@@ -35,6 +35,7 @@ from django.utils import timezone
from django.utils.functional import cached_property
from django.utils.translation import gettext_lazy as _
from django_countries.fields import CountryField
+from ordered_model.models import OrderedModel
from phonenumber_field.modelfields import PhoneNumberField
from accounting.models import CurrencyField
@@ -289,32 +290,32 @@ class AccountDump(models.Model):
)
-class ProductType(models.Model):
+class ProductType(OrderedModel):
"""A product type.
Useful only for categorizing.
"""
name = models.CharField(_("name"), max_length=30)
- description = models.TextField(_("description"), null=True, blank=True)
- comment = models.TextField(_("comment"), null=True, blank=True)
+ description = models.TextField(_("description"), default="")
+ comment = models.TextField(
+ _("comment"),
+ default="",
+ help_text=_("A text that will be shown on the eboutic."),
+ )
icon = ResizedImageField(
height=70, force_format="WEBP", upload_to="products", null=True, blank=True
)
- # priority holds no real backend logic but helps to handle the order in which
- # the items are to be shown to the user
- priority = models.PositiveIntegerField(default=0)
-
class Meta:
verbose_name = _("product type")
- ordering = ["-priority", "name"]
+ ordering = ["order"]
def __str__(self):
return self.name
def get_absolute_url(self):
- return reverse("counter:producttype_list")
+ return reverse("counter:product_type_list")
def is_owned_by(self, user):
"""Method to see if that object can be edited by the given user."""
diff --git a/counter/schemas.py b/counter/schemas.py
index ec1a842d..adc8094b 100644
--- a/counter/schemas.py
+++ b/counter/schemas.py
@@ -1,10 +1,13 @@
-from typing import Annotated
+from typing import Annotated, Self
from annotated_types import MinLen
-from ninja import Field, FilterSchema, ModelSchema
+from django.urls import reverse
+from ninja import Field, FilterSchema, ModelSchema, Schema
+from pydantic import model_validator
-from core.schemas import SimpleUserSchema
-from counter.models import Counter, Product
+from club.schemas import ClubSchema
+from core.schemas import GroupSchema, SimpleUserSchema
+from counter.models import Counter, Product, ProductType
class CounterSchema(ModelSchema):
@@ -26,7 +29,72 @@ class SimplifiedCounterSchema(ModelSchema):
fields = ["id", "name"]
-class ProductSchema(ModelSchema):
+class ProductTypeSchema(ModelSchema):
+ class Meta:
+ model = ProductType
+ fields = ["id", "name", "description", "comment", "icon", "order"]
+
+ url: str
+
+ @staticmethod
+ def resolve_url(obj: ProductType) -> str:
+ return reverse("counter:product_type_edit", kwargs={"type_id": obj.id})
+
+
+class SimpleProductTypeSchema(ModelSchema):
+ class Meta:
+ model = ProductType
+ fields = ["id", "name"]
+
+
+class ReorderProductTypeSchema(Schema):
+ below: int | None = None
+ above: int | None = None
+
+ @model_validator(mode="after")
+ def validate_exclusive(self) -> Self:
+ if self.below is None and self.above is None:
+ raise ValueError("Either 'below' or 'above' must be set.")
+ if self.below is not None and self.above is not None:
+ raise ValueError("Only one of 'below' or 'above' must be set.")
+ return self
+
+
+class SimpleProductSchema(ModelSchema):
class Meta:
model = Product
fields = ["id", "name", "code"]
+
+
+class ProductSchema(ModelSchema):
+ class Meta:
+ model = Product
+ fields = [
+ "id",
+ "name",
+ "code",
+ "description",
+ "purchase_price",
+ "selling_price",
+ "icon",
+ "limit_age",
+ "archived",
+ ]
+
+ buying_groups: list[GroupSchema]
+ club: ClubSchema
+ product_type: SimpleProductTypeSchema | None
+ url: str
+
+ @staticmethod
+ def resolve_url(obj: Product) -> str:
+ return reverse("counter:product_edit", kwargs={"product_id": obj.id})
+
+
+class ProductFilterSchema(FilterSchema):
+ search: Annotated[str, MinLen(1)] | None = Field(
+ None, q=["name__icontains", "code__icontains"]
+ )
+ is_archived: bool | None = Field(None, q="archived")
+ buying_groups: set[int] | None = Field(None, q="buying_groups__in")
+ product_type: set[int] | None = Field(None, q="product_type__in")
diff --git a/counter/static/bundled/counter/components/ajax-select-index.ts b/counter/static/bundled/counter/components/ajax-select-index.ts
index 147e4733..a2d61a48 100644
--- a/counter/static/bundled/counter/components/ajax-select-index.ts
+++ b/counter/static/bundled/counter/components/ajax-select-index.ts
@@ -4,7 +4,7 @@ import type { TomOption } from "tom-select/dist/types/types";
import type { escape_html } from "tom-select/dist/types/utils";
import {
type CounterSchema,
- type ProductSchema,
+ type SimpleProductSchema,
counterSearchCounter,
productSearchProducts,
} from "#openapi";
@@ -23,13 +23,13 @@ export class ProductAjaxSelect extends AjaxSelect {
return [];
}
- protected renderOption(item: ProductSchema, sanitize: typeof escape_html) {
+ protected renderOption(item: SimpleProductSchema, sanitize: typeof escape_html) {
return `
${sanitize(item.code)} - ${sanitize(item.name)}
`;
}
- protected renderItem(item: ProductSchema, sanitize: typeof escape_html) {
+ protected renderItem(item: SimpleProductSchema, sanitize: typeof escape_html) {
return `${sanitize(item.code)} - ${sanitize(item.name)}`;
}
}
diff --git a/counter/static/bundled/counter/product-type-index.ts b/counter/static/bundled/counter/product-type-index.ts
new file mode 100644
index 00000000..4d1bdda4
--- /dev/null
+++ b/counter/static/bundled/counter/product-type-index.ts
@@ -0,0 +1,64 @@
+import Alpine from "alpinejs";
+import { producttypeReorder } from "#openapi";
+
+document.addEventListener("alpine:init", () => {
+ Alpine.data("productTypesList", () => ({
+ loading: false,
+ alertMessage: {
+ open: false,
+ success: true,
+ content: "",
+ timeout: null,
+ },
+
+ async reorder(itemId: number, newPosition: number) {
+ // The sort plugin of Alpine doesn't manage dynamic lists with x-sort
+ // (cf. https://github.com/alpinejs/alpine/discussions/4157).
+ // There is an open PR that fixes this issue
+ // (cf. https://github.com/alpinejs/alpine/pull/4361).
+ // However, it hasn't been merged yet.
+ // To overcome this, I get the list of DOM elements
+ // And fetch the `x-sort:item` attribute, which value is
+ // the id of the object in database.
+ // Please make this a little bit cleaner when the fix has been merged
+ // into the main Alpine repo.
+ this.loading = true;
+ const productTypes = this.$refs.productTypes
+ .childNodes as NodeListOf;
+ const getId = (elem: HTMLLIElement) =>
+ Number.parseInt(elem.getAttribute("x-sort:item"));
+ const query =
+ newPosition === 0
+ ? { above: getId(productTypes.item(1)) }
+ : { below: getId(productTypes.item(newPosition - 1)) };
+ const response = await producttypeReorder({
+ // biome-ignore lint/style/useNamingConvention: api is snake_case
+ path: { type_id: itemId },
+ query: query,
+ });
+ this.openAlertMessage(response.response);
+ this.loading = false;
+ },
+
+ openAlertMessage(response: Response) {
+ if (response.ok) {
+ this.alertMessage.success = true;
+ this.alertMessage.content = gettext("Products types successfully reordered");
+ } else {
+ this.alertMessage.success = false;
+ this.alertMessage.content = interpolate(
+ gettext("Product type reorganisation failed with status code : %d"),
+ [response.status],
+ );
+ }
+ this.alertMessage.open = true;
+ if (this.alertMessage.timeout !== null) {
+ clearTimeout(this.alertMessage.timeout);
+ }
+ this.alertMessage.timeout = setTimeout(() => {
+ this.alertMessage.open = false;
+ }, 2000);
+ this.loading = false;
+ },
+ }));
+});
diff --git a/counter/static/counter/css/product_type.scss b/counter/static/counter/css/product_type.scss
new file mode 100644
index 00000000..16bd43a9
--- /dev/null
+++ b/counter/static/counter/css/product_type.scss
@@ -0,0 +1,15 @@
+.product-type-list {
+ li {
+ list-style: none;
+ margin-bottom: 10px;
+
+ i {
+ cursor: grab;
+ visibility: hidden;
+ }
+ }
+}
+
+body:not(.sorting) .product-type-list li:hover i {
+ visibility: visible;
+}
\ No newline at end of file
diff --git a/counter/templates/counter/product_type_list.jinja b/counter/templates/counter/product_type_list.jinja
new file mode 100644
index 00000000..68548829
--- /dev/null
+++ b/counter/templates/counter/product_type_list.jinja
@@ -0,0 +1,64 @@
+{% extends "core/base.jinja" %}
+
+{% block title %}
+ {% trans %}Product type list{% endtrans %}
+{% endblock %}
+
+{% block additional_css %}
+
+{% endblock %}
+
+{% block additional_js %}
+
+{% endblock %}
+
+{% block content %}
+
+
+ {% trans %}New product type{% endtrans %}
+
+
+
+ {% if product_types %}
+
+
+
+
{% trans %}Product type list{% endtrans %}
+
+
+ {% else %}
+
+ {% trans %}There are no product types in this website.{% endtrans %}
+
+ {% endif %}
+{% endblock %}
diff --git a/counter/templates/counter/producttype_list.jinja b/counter/templates/counter/producttype_list.jinja
deleted file mode 100644
index 0c4ff0c5..00000000
--- a/counter/templates/counter/producttype_list.jinja
+++ /dev/null
@@ -1,24 +0,0 @@
-{% extends "core/base.jinja" %}
-
-{% block title %}
- {% trans %}Product type list{% endtrans %}
-{% endblock %}
-
-{% block content %}
- {% trans %}New product type{% endtrans %}
- {% if producttype_list %}
- {% trans %}Product type list{% endtrans %}
-
- {% for t in producttype_list %}
- - {{ t }}
- {% endfor %}
-
- {% else %}
- {% trans %}There is no product types in this website.{% endtrans %}
- {% endif %}
-{% endblock %}
-
-
-
-
-
diff --git a/counter/tests/test_product.py b/counter/tests/test_product.py
index d18c6f11..8daab2b1 100644
--- a/counter/tests/test_product.py
+++ b/counter/tests/test_product.py
@@ -1,11 +1,19 @@
from io import BytesIO
+from typing import Callable
from uuid import uuid4
import pytest
+from django.conf import settings
+from django.core.cache import cache
from django.core.files.uploadedfile import SimpleUploadedFile
+from django.test import Client
+from django.urls import reverse
from model_bakery import baker
from PIL import Image
+from pytest_django.asserts import assertNumQueries
+from core.baker_recipes import board_user, subscriber_user
+from core.models import Group, User
from counter.models import Product, ProductType
@@ -31,3 +39,48 @@ def test_resize_product_icon(model):
assert product.icon.height == 70
assert product.icon.name == f"products/{name}.webp"
assert Image.open(product.icon).format == "WEBP"
+
+
+@pytest.mark.django_db
+@pytest.mark.parametrize(
+ ("user_factory", "status_code"),
+ [
+ (lambda: baker.make(User, is_superuser=True), 200),
+ (board_user.make, 403),
+ (subscriber_user.make, 403),
+ (
+ lambda: baker.make(
+ User,
+ groups=[Group.objects.get(pk=settings.SITH_GROUP_COUNTER_ADMIN_ID)],
+ ),
+ 200,
+ ),
+ (
+ lambda: baker.make(
+ User,
+ groups=[Group.objects.get(pk=settings.SITH_GROUP_ACCOUNTING_ADMIN_ID)],
+ ),
+ 200,
+ ),
+ ],
+)
+def test_fetch_product_access(
+ client: Client, user_factory: Callable[[], User], status_code: int
+):
+ """Test that only authorized users can use the `GET /product` route."""
+ client.force_login(user_factory())
+ assert (
+ client.get(reverse("api:search_products_detailed")).status_code == status_code
+ )
+
+
+@pytest.mark.django_db
+def test_fetch_product_nb_queries(client: Client):
+ client.force_login(baker.make(User, is_superuser=True))
+ cache.clear()
+ with assertNumQueries(5):
+ # - 2 for authentication
+ # - 1 for pagination
+ # - 1 for the actual request
+ # - 1 to prefetch the related buying_groups
+ client.get(reverse("api:search_products_detailed"))
diff --git a/counter/tests/test_product_type.py b/counter/tests/test_product_type.py
new file mode 100644
index 00000000..16dc40dd
--- /dev/null
+++ b/counter/tests/test_product_type.py
@@ -0,0 +1,89 @@
+import pytest
+from django.conf import settings
+from django.test import Client
+from django.urls import reverse
+from model_bakery import baker, seq
+from ninja_extra.testing import TestClient
+
+from core.baker_recipes import board_user, subscriber_user
+from core.models import Group, User
+from counter.api import ProductTypeController
+from counter.models import ProductType
+
+
+@pytest.fixture
+def product_types(db) -> list[ProductType]:
+ """All existing product types, ordered by their `order` field"""
+ # delete product types that have been created in the `populate` command
+ ProductType.objects.all().delete()
+ return baker.make(ProductType, _quantity=5, order=seq(0))
+
+
+@pytest.mark.django_db
+def test_fetch_product_types(product_types: list[ProductType]):
+ """Test that the API returns the right products in the right order"""
+ client = TestClient(ProductTypeController)
+ response = client.get("")
+ assert response.status_code == 200
+ assert [i["id"] for i in response.json()] == [t.id for t in product_types]
+
+
+@pytest.mark.django_db
+def test_move_below_product_type(product_types: list[ProductType]):
+ """Test that moving a product below another works"""
+ client = TestClient(ProductTypeController)
+ response = client.patch(
+ f"/{product_types[-1].id}/move", query={"below": product_types[0].id}
+ )
+ assert response.status_code == 200
+ new_order = [i["id"] for i in client.get("").json()]
+ assert new_order == [
+ product_types[0].id,
+ product_types[-1].id,
+ *[t.id for t in product_types[1:-1]],
+ ]
+
+
+@pytest.mark.django_db
+def test_move_above_product_type(product_types: list[ProductType]):
+ """Test that moving a product above another works"""
+ client = TestClient(ProductTypeController)
+ response = client.patch(
+ f"/{product_types[1].id}/move", query={"above": product_types[0].id}
+ )
+ assert response.status_code == 200
+ new_order = [i["id"] for i in client.get("").json()]
+ assert new_order == [
+ product_types[1].id,
+ product_types[0].id,
+ *[t.id for t in product_types[2:]],
+ ]
+
+
+@pytest.mark.django_db
+@pytest.mark.parametrize(
+ ("user_factory", "status_code"),
+ [
+ (lambda: baker.make(User, is_superuser=True), 200),
+ (subscriber_user.make, 403),
+ (board_user.make, 403),
+ (
+ lambda: baker.make(
+ User,
+ groups=[Group.objects.get(pk=settings.SITH_GROUP_COUNTER_ADMIN_ID)],
+ ),
+ 200,
+ ),
+ (
+ lambda: baker.make(
+ User,
+ groups=[Group.objects.get(pk=settings.SITH_GROUP_ACCOUNTING_ADMIN_ID)],
+ ),
+ 200,
+ ),
+ ],
+)
+def test_controller_permissions(client: Client, user_factory, status_code):
+ client.force_login(user_factory())
+ response = client.get(reverse("api:fetch_product_types"))
+ assert response.status_code == status_code
diff --git a/counter/urls.py b/counter/urls.py
index 0ca77f73..91564a8b 100644
--- a/counter/urls.py
+++ b/counter/urls.py
@@ -121,19 +121,19 @@ urlpatterns = [
name="product_edit",
),
path(
- "admin/producttype/list/",
+ "admin/product-type/list/",
ProductTypeListView.as_view(),
- name="producttype_list",
+ name="product_type_list",
),
path(
- "admin/producttype/create/",
+ "admin/product-type/create/",
ProductTypeCreateView.as_view(),
- name="new_producttype",
+ name="new_product_type",
),
path(
- "admin/producttype//",
+ "admin/product-type//",
ProductTypeEditView.as_view(),
- name="producttype_edit",
+ name="product_type_edit",
),
path("admin/eticket/list/", EticketListView.as_view(), name="eticket_list"),
path("admin/eticket/new/", EticketCreateView.as_view(), name="new_eticket"),
diff --git a/counter/views/admin.py b/counter/views/admin.py
index fbf466b3..aa7e2c50 100644
--- a/counter/views/admin.py
+++ b/counter/views/admin.py
@@ -101,15 +101,16 @@ class ProductTypeListView(CounterAdminTabsMixin, CounterAdminMixin, ListView):
"""A list view for the admins."""
model = ProductType
- template_name = "counter/producttype_list.jinja"
+ template_name = "counter/product_type_list.jinja"
current_tab = "product_types"
+ context_object_name = "product_types"
class ProductTypeCreateView(CounterAdminTabsMixin, CounterAdminMixin, CreateView):
"""A create view for the admins."""
model = ProductType
- fields = ["name", "description", "comment", "icon", "priority"]
+ fields = ["name", "description", "comment", "icon"]
template_name = "core/create.jinja"
current_tab = "products"
@@ -119,7 +120,7 @@ class ProductTypeEditView(CounterAdminTabsMixin, CounterAdminMixin, UpdateView):
model = ProductType
template_name = "core/edit.jinja"
- fields = ["name", "description", "comment", "icon", "priority"]
+ fields = ["name", "description", "comment", "icon"]
pk_url_kwarg = "type_id"
current_tab = "products"
@@ -129,7 +130,7 @@ class ProductListView(CounterAdminTabsMixin, CounterAdminMixin, ListView):
queryset = Product.objects.values("id", "name", "code", "product_type__name")
template_name = "counter/product_list.jinja"
ordering = [
- F("product_type__priority").desc(nulls_last=True),
+ F("product_type__order").asc(nulls_last=True),
"product_type",
"name",
]
diff --git a/counter/views/mixins.py b/counter/views/mixins.py
index 4a07d848..2e88f54c 100644
--- a/counter/views/mixins.py
+++ b/counter/views/mixins.py
@@ -99,7 +99,7 @@ class CounterAdminTabsMixin(TabedViewMixin):
"name": _("Archived products"),
},
{
- "url": reverse_lazy("counter:producttype_list"),
+ "url": reverse_lazy("counter:product_type_list"),
"slug": "product_types",
"name": _("Product types"),
},
diff --git a/counter/widgets/select.py b/counter/widgets/select.py
index 68b0bfc1..78c92862 100644
--- a/counter/widgets/select.py
+++ b/counter/widgets/select.py
@@ -2,7 +2,7 @@ from pydantic import TypeAdapter
from core.views.widgets.select import AutoCompleteSelect, AutoCompleteSelectMultiple
from counter.models import Counter, Product
-from counter.schemas import ProductSchema, SimplifiedCounterSchema
+from counter.schemas import SimpleProductSchema, SimplifiedCounterSchema
_js = ["bundled/counter/components/ajax-select-index.ts"]
@@ -24,12 +24,12 @@ class AutoCompleteSelectMultipleCounter(AutoCompleteSelectMultiple):
class AutoCompleteSelectProduct(AutoCompleteSelect):
component_name = "product-ajax-select"
model = Product
- adapter = TypeAdapter(list[ProductSchema])
+ adapter = TypeAdapter(list[SimpleProductSchema])
js = _js
class AutoCompleteSelectMultipleProduct(AutoCompleteSelectMultiple):
component_name = "product-ajax-select"
model = Product
- adapter = TypeAdapter(list[ProductSchema])
+ adapter = TypeAdapter(list[SimpleProductSchema])
js = _js
diff --git a/docs/howto/translation.md b/docs/howto/translation.md
index 6ae299ed..02f9f87b 100644
--- a/docs/howto/translation.md
+++ b/docs/howto/translation.md
@@ -37,8 +37,11 @@ Il faut d'abord générer un fichier de traductions,
l'éditer et enfin le compiler au format binaire pour qu'il soit lu par le serveur.
```bash
-./manage.py makemessages --locale=fr -e py,jinja --ignore=node_modules # Pour le backend
-./manage.py makemessages --locale=fr -d djangojs -e js,ts --ignore=node_modules # Pour le frontend
+# Pour le backend
+./manage.py makemessages --locale=fr -e py,jinja --ignore=node_modules
+
+# Pour le frontend
+./manage.py makemessages --locale=fr -d djangojs -e js,ts --ignore=node_modules --ignore=staticfiles/generated
```
## Éditer le fichier django.po
diff --git a/eboutic/models.py b/eboutic/models.py
index 7ec9deef..7f7282b1 100644
--- a/eboutic/models.py
+++ b/eboutic/models.py
@@ -36,7 +36,7 @@ def get_eboutic_products(user: User) -> list[Product]:
.products.filter(product_type__isnull=False)
.filter(archived=False)
.filter(limit_age__lte=user.age)
- .annotate(priority=F("product_type__priority"))
+ .annotate(order=F("product_type__order"))
.annotate(category=F("product_type__name"))
.annotate(category_comment=F("product_type__comment"))
.prefetch_related("buying_groups") # <-- used in `Product.can_be_sold_to`
diff --git a/eboutic/templates/eboutic/eboutic_main.jinja b/eboutic/templates/eboutic/eboutic_main.jinja
index bf5d7556..b71eb434 100644
--- a/eboutic/templates/eboutic/eboutic_main.jinja
+++ b/eboutic/templates/eboutic/eboutic_main.jinja
@@ -88,7 +88,7 @@
{% endif %}
- {% for priority_groups in products|groupby('priority')|reverse %}
+ {% for priority_groups in products|groupby('order') %}
{% for category, items in priority_groups.list|groupby('category') %}
{% if items|count > 0 %}
diff --git a/locale/fr/LC_MESSAGES/django.po b/locale/fr/LC_MESSAGES/django.po
index 1f74ddaa..015e3dd7 100644
--- a/locale/fr/LC_MESSAGES/django.po
+++ b/locale/fr/LC_MESSAGES/django.po
@@ -6,7 +6,7 @@
msgid ""
msgstr ""
"Report-Msgid-Bugs-To: \n"
-"POT-Creation-Date: 2024-12-17 10:53+0100\n"
+"POT-Creation-Date: 2024-12-19 10:43+0100\n"
"PO-Revision-Date: 2016-07-18\n"
"Last-Translator: Maréchal \n"
@@ -17,9 +17,9 @@ msgstr ""
"Plural-Forms: nplurals=2; plural=(n > 1);\n"
#: accounting/models.py:62 accounting/models.py:101 accounting/models.py:132
-#: accounting/models.py:190 club/models.py:55 com/models.py:274
-#: com/models.py:293 counter/models.py:298 counter/models.py:329
-#: counter/models.py:480 forum/models.py:60 launderette/models.py:29
+#: accounting/models.py:190 club/models.py:55 com/models.py:287
+#: com/models.py:306 counter/models.py:299 counter/models.py:330
+#: counter/models.py:481 forum/models.py:60 launderette/models.py:29
#: launderette/models.py:80 launderette/models.py:116
msgid "name"
msgstr "nom"
@@ -40,7 +40,7 @@ msgstr "code postal"
msgid "country"
msgstr "pays"
-#: accounting/models.py:67 core/models.py:391
+#: accounting/models.py:67 core/models.py:356
msgid "phone"
msgstr "téléphone"
@@ -65,8 +65,8 @@ msgid "account number"
msgstr "numéro de compte"
#: accounting/models.py:107 accounting/models.py:136 club/models.py:345
-#: com/models.py:74 com/models.py:259 com/models.py:299 counter/models.py:358
-#: counter/models.py:482 trombi/models.py:209
+#: com/models.py:87 com/models.py:272 com/models.py:312 counter/models.py:359
+#: counter/models.py:483 trombi/models.py:209
msgid "club"
msgstr "club"
@@ -87,12 +87,12 @@ msgstr "Compte club"
msgid "%(club_account)s on %(bank_account)s"
msgstr "%(club_account)s sur %(bank_account)s"
-#: accounting/models.py:188 club/models.py:351 counter/models.py:965
+#: accounting/models.py:188 club/models.py:351 counter/models.py:966
#: election/models.py:16 launderette/models.py:165
msgid "start date"
msgstr "date de début"
-#: accounting/models.py:189 club/models.py:352 counter/models.py:966
+#: accounting/models.py:189 club/models.py:352 counter/models.py:967
#: election/models.py:17
msgid "end date"
msgstr "date de fin"
@@ -105,8 +105,8 @@ msgstr "est fermé"
msgid "club account"
msgstr "compte club"
-#: accounting/models.py:199 accounting/models.py:255 counter/models.py:92
-#: counter/models.py:683
+#: accounting/models.py:199 accounting/models.py:255 counter/models.py:93
+#: counter/models.py:684
msgid "amount"
msgstr "montant"
@@ -126,20 +126,20 @@ msgstr "numéro"
msgid "journal"
msgstr "classeur"
-#: 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:693 counter/models.py:797 counter/models.py:1001
+#: accounting/models.py:256 core/models.py:913 core/models.py:1422
+#: core/models.py:1467 core/models.py:1496 core/models.py:1520
+#: counter/models.py:694 counter/models.py:798 counter/models.py:1002
#: eboutic/models.py:57 eboutic/models.py:193 forum/models.py:312
#: forum/models.py:413
msgid "date"
msgstr "date"
-#: accounting/models.py:257 counter/models.py:300 counter/models.py:1002
+#: accounting/models.py:257 counter/models.py:302 counter/models.py:1003
#: pedagogy/models.py:208
msgid "comment"
msgstr "commentaire"
-#: accounting/models.py:259 counter/models.py:695 counter/models.py:799
+#: accounting/models.py:259 counter/models.py:696 counter/models.py:800
#: subscription/models.py:56
msgid "payment method"
msgstr "méthode de paiement"
@@ -165,8 +165,8 @@ 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:1540 core/models.py:1566
-#: counter/models.py:763
+#: accounting/models.py:492 core/models.py:1495 core/models.py:1521
+#: counter/models.py:764
msgid "label"
msgstr "étiquette"
@@ -174,7 +174,7 @@ msgstr "étiquette"
msgid "target type"
msgstr "type de cible"
-#: accounting/models.py:303 club/models.py:505
+#: accounting/models.py:303 club/models.py:504
#: club/templates/club/club_members.jinja:17
#: club/templates/club/club_old_members.jinja:8
#: club/templates/club/mailing.jinja:41
@@ -218,7 +218,7 @@ msgstr "Compte"
msgid "Company"
msgstr "Entreprise"
-#: accounting/models.py:307 core/models.py:338 sith/settings.py:423
+#: accounting/models.py:307 core/models.py:303 sith/settings.py:423
msgid "Other"
msgstr "Autre"
@@ -264,7 +264,7 @@ msgstr ""
"Vous devez fournir soit un type comptable simplifié ou un type comptable "
"standard"
-#: accounting/models.py:421 counter/models.py:339 pedagogy/models.py:41
+#: accounting/models.py:421 counter/models.py:340 pedagogy/models.py:41
msgid "code"
msgstr "code"
@@ -279,14 +279,14 @@ msgstr "type de mouvement"
#: accounting/models.py:433
#: accounting/templates/accounting/journal_statement_nature.jinja:9
#: accounting/templates/accounting/journal_statement_person.jinja:12
-#: accounting/views.py:574
+#: accounting/views.py:566
msgid "Credit"
msgstr "Crédit"
#: accounting/models.py:434
#: accounting/templates/accounting/journal_statement_nature.jinja:28
#: accounting/templates/accounting/journal_statement_person.jinja:40
-#: accounting/views.py:574
+#: accounting/views.py:566
msgid "Debit"
msgstr "Débit"
@@ -760,7 +760,7 @@ msgid "Linked operation:"
msgstr "Opération liée : "
#: accounting/templates/accounting/operation_edit.jinja:55
-#: com/templates/com/news_edit.jinja:57 com/templates/com/poster_edit.jinja:33
+#: com/templates/com/news_edit.jinja:103 com/templates/com/poster_edit.jinja:33
#: com/templates/com/screen_edit.jinja:25 com/templates/com/weekmail.jinja:74
#: core/templates/core/create.jinja:12 core/templates/core/edit.jinja:7
#: core/templates/core/edit.jinja:15 core/templates/core/edit.jinja:20
@@ -782,7 +782,7 @@ msgstr "Sauver"
#: accounting/templates/accounting/refound_account.jinja:4
#: accounting/templates/accounting/refound_account.jinja:9
-#: accounting/views.py:892
+#: accounting/views.py:884
msgid "Refound account"
msgstr "Remboursement de compte"
@@ -803,87 +803,87 @@ msgstr "Types simplifiés"
msgid "New simplified type"
msgstr "Nouveau type simplifié"
-#: accounting/views.py:215 accounting/views.py:225 accounting/views.py:549
+#: accounting/views.py:215 accounting/views.py:224 accounting/views.py:541
msgid "Journal"
msgstr "Classeur"
-#: accounting/views.py:235
+#: accounting/views.py:232
msgid "Statement by nature"
msgstr "Bilan par nature"
-#: accounting/views.py:245
+#: accounting/views.py:240
msgid "Statement by person"
msgstr "Bilan par personne"
-#: accounting/views.py:255
+#: accounting/views.py:248
msgid "Accounting statement"
msgstr "Bilan comptable"
-#: accounting/views.py:369
+#: accounting/views.py:361
msgid "Link this operation to the target account"
msgstr "Lier cette opération au compte cible"
-#: accounting/views.py:399
+#: accounting/views.py:391
msgid "The target must be set."
msgstr "La cible doit être indiquée."
-#: accounting/views.py:414
+#: accounting/views.py:406
msgid "The amount must be set."
msgstr "Le montant doit être indiqué."
-#: accounting/views.py:543 accounting/views.py:549
+#: accounting/views.py:535 accounting/views.py:541
msgid "Operation"
msgstr "Opération"
-#: accounting/views.py:558
+#: accounting/views.py:550
msgid "Financial proof: "
msgstr "Justificatif de libellé : "
-#: accounting/views.py:561
+#: accounting/views.py:553
#, python-format
msgid "Club: %(club_name)s"
msgstr "Club : %(club_name)s"
-#: accounting/views.py:566
+#: accounting/views.py:558
#, python-format
msgid "Label: %(op_label)s"
msgstr "Libellé : %(op_label)s"
-#: accounting/views.py:569
+#: accounting/views.py:561
#, python-format
msgid "Date: %(date)s"
msgstr "Date : %(date)s"
-#: accounting/views.py:577
+#: accounting/views.py:569
#, python-format
msgid "Amount: %(amount).2f €"
msgstr "Montant : %(amount).2f €"
-#: accounting/views.py:592
+#: accounting/views.py:584
msgid "Debtor"
msgstr "Débiteur"
-#: accounting/views.py:592
+#: accounting/views.py:584
msgid "Creditor"
msgstr "Créditeur"
-#: accounting/views.py:597
+#: accounting/views.py:589
msgid "Comment:"
msgstr "Commentaire :"
-#: accounting/views.py:622
+#: accounting/views.py:614
msgid "Signature:"
msgstr "Signature :"
-#: accounting/views.py:686
+#: accounting/views.py:678
msgid "General statement"
msgstr "Bilan général"
-#: accounting/views.py:693
+#: accounting/views.py:685
msgid "No label operations"
msgstr "Opérations sans étiquette"
-#: accounting/views.py:846
+#: accounting/views.py:838
msgid "Refound this account"
msgstr "Rembourser ce compte"
@@ -910,7 +910,7 @@ msgstr ""
msgid "Users to add"
msgstr "Utilisateurs à ajouter"
-#: club/forms.py:55 club/forms.py:181 core/views/group.py:42
+#: club/forms.py:55 club/forms.py:181 core/views/group.py:40
msgid "Search users to add (one or more)."
msgstr "Recherche les utilisateurs à ajouter (un ou plus)."
@@ -1025,11 +1025,11 @@ msgstr "actif"
msgid "short description"
msgstr "description courte"
-#: club/models.py:81 core/models.py:393
+#: club/models.py:81 core/models.py:358
msgid "address"
msgstr "Adresse"
-#: club/models.py:98 core/models.py:304
+#: club/models.py:98 core/models.py:269
msgid "home"
msgstr "home"
@@ -1041,25 +1041,25 @@ msgstr "Vous ne pouvez pas faire de boucles dans les clubs"
msgid "A club with that unix_name already exists"
msgstr "Un club avec ce nom UNIX existe déjà."
-#: club/models.py:337 counter/models.py:956 counter/models.py:992
+#: club/models.py:337 counter/models.py:957 counter/models.py:993
#: eboutic/models.py:53 eboutic/models.py:189 election/models.py:183
#: launderette/models.py:130 launderette/models.py:184 sas/models.py:273
#: trombi/models.py:205
msgid "user"
-msgstr "nom d'utilisateur"
+msgstr "utilisateur"
-#: club/models.py:354 core/models.py:357 election/models.py:178
+#: club/models.py:354 core/models.py:322 election/models.py:178
#: election/models.py:212 trombi/models.py:210
msgid "role"
msgstr "rôle"
-#: club/models.py:359 core/models.py:90 counter/models.py:299
-#: counter/models.py:330 election/models.py:13 election/models.py:115
+#: club/models.py:359 core/models.py:84 counter/models.py:300
+#: counter/models.py:331 election/models.py:13 election/models.py:115
#: election/models.py:188 forum/models.py:61 forum/models.py:245
msgid "description"
msgstr "description"
-#: club/models.py:415 club/models.py:511
+#: club/models.py:415 club/models.py:510
msgid "Email address"
msgstr "Adresse email"
@@ -1068,31 +1068,31 @@ 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:957
+#: club/models.py:427 com/models.py:97 com/models.py:322 core/models.py:914
msgid "is moderated"
msgstr "est modéré"
-#: club/models.py:431 com/models.py:86 com/models.py:313
+#: club/models.py:431 com/models.py:101 com/models.py:326
msgid "moderator"
msgstr "modérateur"
-#: club/models.py:458
+#: club/models.py:457
msgid "This mailing list already exists."
msgstr "Cette liste de diffusion existe déjà."
-#: club/models.py:497 club/templates/club/mailing.jinja:23
+#: club/models.py:496 club/templates/club/mailing.jinja:23
msgid "Mailing"
msgstr "Liste de diffusion"
-#: club/models.py:521
+#: club/models.py:520
msgid "At least user or email is required"
msgstr "Au moins un utilisateur ou un email est nécessaire"
-#: club/models.py:529 club/tests.py:770
+#: club/models.py:528 club/tests.py:770
msgid "This email is already suscribed in this mailing"
msgstr "Cet email est déjà abonné à cette mailing"
-#: club/models.py:557
+#: club/models.py:556
msgid "Unregistered user"
msgstr "Utilisateur non enregistré"
@@ -1146,7 +1146,7 @@ msgid "There are no members in this club."
msgstr "Il n'y a pas de membres dans ce club."
#: club/templates/club/club_members.jinja:80
-#: core/templates/core/file_detail.jinja:19 core/views/forms.py:307
+#: core/templates/core/file_detail.jinja:19 core/views/forms.py:305
#: launderette/views.py:208 trombi/templates/trombi/detail.jinja:19
msgid "Add"
msgstr "Ajouter"
@@ -1426,80 +1426,96 @@ msgstr "Hebdomadaire"
msgid "Call"
msgstr "Appel"
-#: com/models.py:67 com/models.py:174 com/models.py:248
+#: com/models.py:67 com/models.py:187 com/models.py:261
#: core/templates/core/macros.jinja:301 election/models.py:12
#: election/models.py:114 election/models.py:152 forum/models.py:256
#: forum/models.py:310 pedagogy/models.py:97
msgid "title"
msgstr "titre"
-#: com/models.py:68
+#: com/models.py:69
msgid "summary"
msgstr "résumé"
-#: com/models.py:69 com/models.py:249 trombi/models.py:188
+#: com/models.py:71
+msgid ""
+"A description of the event (what is the activity ? is there an associated "
+"clic ? is there a inscription form ?)"
+msgstr ""
+"Une description de l'évènement (quelle est l'activité ? Y a-t-il un clic "
+"associé ? Y-a-t'il un formulaire d'inscription ?)"
+
+#: com/models.py:76 com/models.py:262 trombi/models.py:188
msgid "content"
msgstr "contenu"
-#: com/models.py:71 core/models.py:1510 launderette/models.py:88
+#: com/models.py:79
+msgid "A more detailed and exhaustive description of the event."
+msgstr "Une description plus détaillée et exhaustive de l'évènement."
+
+#: com/models.py:82 core/models.py:1465 launderette/models.py:88
#: launderette/models.py:124 launderette/models.py:167
msgid "type"
msgstr "type"
-#: com/models.py:79 com/models.py:253 pedagogy/models.py:57
+#: com/models.py:89
+msgid "The club which organizes the event."
+msgstr "Le club qui organise l'évènement."
+
+#: com/models.py:94 com/models.py:266 pedagogy/models.py:57
#: pedagogy/models.py:200 trombi/models.py:178
msgid "author"
msgstr "auteur"
-#: com/models.py:153
+#: com/models.py:166
msgid "news_date"
msgstr "date de la nouvelle"
-#: com/models.py:156
+#: com/models.py:169
msgid "start_date"
msgstr "date de début"
-#: com/models.py:157
+#: com/models.py:170
msgid "end_date"
msgstr "date de fin"
-#: com/models.py:175
+#: com/models.py:188
msgid "intro"
msgstr "intro"
-#: com/models.py:176
+#: com/models.py:189
msgid "joke"
msgstr "blague"
-#: com/models.py:177
+#: com/models.py:190
msgid "protip"
msgstr "astuce"
-#: com/models.py:178
+#: com/models.py:191
msgid "conclusion"
msgstr "conclusion"
-#: com/models.py:179
+#: com/models.py:192
msgid "sent"
msgstr "envoyé"
-#: com/models.py:244
+#: com/models.py:257
msgid "weekmail"
msgstr "weekmail"
-#: com/models.py:262
+#: com/models.py:275
msgid "rank"
msgstr "rang"
-#: com/models.py:295 core/models.py:922 core/models.py:972
+#: com/models.py:308 core/models.py:879 core/models.py:929
msgid "file"
msgstr "fichier"
-#: com/models.py:307
+#: com/models.py:320
msgid "display time"
msgstr "temps d'affichage"
-#: com/models.py:338
+#: com/models.py:349
msgid "Begin date should be before end date"
msgstr "La date de début doit être avant celle de fin"
@@ -1690,15 +1706,15 @@ msgstr "Éditer (sera soumise de nouveau à la modération)"
msgid "Edit news"
msgstr "Éditer la nouvelle"
-#: com/templates/com/news_edit.jinja:39
+#: com/templates/com/news_edit.jinja:41
msgid "Notice: Information, election result - no date"
msgstr "Information, résultat d'élection - sans date"
-#: com/templates/com/news_edit.jinja:40
+#: com/templates/com/news_edit.jinja:42
msgid "Event: punctual event, associated with one date"
msgstr "Événement : événement ponctuel associé à une date"
-#: com/templates/com/news_edit.jinja:41
+#: com/templates/com/news_edit.jinja:44
msgid ""
"Weekly: recurrent event, associated with many dates (specify the first one, "
"and a deadline)"
@@ -1706,14 +1722,14 @@ msgstr ""
"Hebdomadaire : événement récurrent, associé à plusieurs dates (spécifier la "
"première, ainsi que la date de fin)"
-#: com/templates/com/news_edit.jinja:42
+#: com/templates/com/news_edit.jinja:50
msgid ""
-"Call: long time event, associated with a long date (election appliance, ...)"
+"Call: long time event, associated with a long date (like election appliance)"
msgstr ""
-"Appel : événement de longue durée, associé à une longue date (candidature, "
-"concours, ...)"
+"Appel : événement de longue durée, associé à une longue date (comme des "
+"candidatures à une élection)"
-#: com/templates/com/news_edit.jinja:56 com/templates/com/weekmail.jinja:10
+#: com/templates/com/news_edit.jinja:102 com/templates/com/weekmail.jinja:10
msgid "Preview"
msgstr "Prévisualiser"
@@ -1750,7 +1766,7 @@ msgstr "Anniversaires"
msgid "%(age)s year old"
msgstr "%(age)s ans"
-#: com/templates/com/news_list.jinja:156 com/tests.py:103 com/tests.py:113
+#: com/templates/com/news_list.jinja:156 com/tests.py:101 com/tests.py:111
msgid "You need an up to date subscription to access this content"
msgstr "Votre cotisation doit être à jour pour accéder à cette section"
@@ -1952,261 +1968,219 @@ msgstr "Jusqu'à"
msgid "Automoderation"
msgstr "Automodération"
-#: com/views.py:213 com/views.py:217 com/views.py:231
+#: com/views.py:213 com/views.py:217 com/views.py:229
msgid "This field is required."
msgstr "Ce champ est obligatoire."
-#: com/views.py:227
-msgid "You crazy? You can not finish an event before starting it."
-msgstr "T'es fou? Un événement ne peut pas finir avant même de commencer."
+#: com/views.py:226
+msgid "An event cannot end before its beginning."
+msgstr "Un évènement ne peut pas se finir avant d'avoir commencé."
-#: com/views.py:451
+#: com/views.py:445
msgid "Delete and save to regenerate"
msgstr "Supprimer et sauver pour régénérer"
-#: com/views.py:466
+#: com/views.py:460
msgid "Weekmail of the "
msgstr "Weekmail du "
-#: com/views.py:570
+#: com/views.py:564
msgid ""
"You must be a board member of the selected club to post in the Weekmail."
msgstr ""
"Vous devez êtres un membre du bureau du club sélectionné pour poster dans le "
"Weekmail."
-#: core/models.py:85
+#: core/models.py:79
msgid "meta group status"
msgstr "status du meta-groupe"
-#: core/models.py:87
+#: core/models.py:81
msgid "Whether a group is a meta group or not"
msgstr "Si un groupe est un meta-groupe ou pas"
-#: core/models.py:173
+#: core/models.py:167
#, 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:257
-msgid "username"
-msgstr "nom d'utilisateur"
-
-#: 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:267
-msgid ""
-"Enter a valid username. This value may contain only letters, numbers and ./"
-"+/-/_ characters."
-msgstr ""
-"Entrez un nom d'utilisateur correct. Uniquement des lettres, numéros, et ./"
-"+/-/_"
-
-#: core/models.py:273
-msgid "A user with that username already exists."
-msgstr "Un utilisateur de ce nom existe déjà"
-
-#: core/models.py:275
+#: core/models.py:250
msgid "first name"
msgstr "Prénom"
-#: core/models.py:276
+#: core/models.py:251
msgid "last name"
msgstr "Nom"
-#: core/models.py:277
+#: core/models.py:252
msgid "email address"
msgstr "adresse email"
-#: core/models.py:278
+#: core/models.py:253
msgid "date of birth"
msgstr "date de naissance"
-#: core/models.py:279
+#: core/models.py:254
msgid "nick name"
msgstr "surnom"
-#: core/models.py:281
-msgid "staff status"
-msgstr "status \"staff\""
-
-#: 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:286
-msgid "active"
-msgstr "actif"
-
-#: core/models.py:289
-msgid ""
-"Designates whether this user should be treated as active. Unselect this "
-"instead of deleting accounts."
-msgstr ""
-"Est-ce que l'utilisateur doit être traité comme actif. Désélectionnez au "
-"lieu de supprimer les comptes."
-
-#: core/models.py:293
-msgid "date joined"
-msgstr "date d'inscription"
-
-#: core/models.py:294
+#: core/models.py:255
msgid "last update"
msgstr "dernière mise à jour"
-#: core/models.py:296
-msgid "superuser"
-msgstr "super-utilisateur"
+#: core/models.py:258
+msgid "groups"
+msgstr "groupes"
-#: core/models.py:298
-msgid "Designates whether this user is a superuser. "
-msgstr "Est-ce que l'utilisateur est super-utilisateur."
+#: core/models.py:260
+msgid ""
+"The groups this user belongs to. A user will get all permissions granted to "
+"each of their groups."
+msgstr ""
+"Les groupes auxquels cet utilisateur appartient. Un utilisateur aura toutes les "
+"permissions de chacun de ses groupes."
-#: core/models.py:312
+#: core/models.py:277
msgid "profile"
msgstr "profil"
-#: core/models.py:320
+#: core/models.py:285
msgid "avatar"
msgstr "avatar"
-#: core/models.py:328
+#: core/models.py:293
msgid "scrub"
msgstr "blouse"
-#: core/models.py:334
+#: core/models.py:299
msgid "sex"
msgstr "Genre"
-#: core/models.py:338
+#: core/models.py:303
msgid "Man"
msgstr "Homme"
-#: core/models.py:338
+#: core/models.py:303
msgid "Woman"
msgstr "Femme"
-#: core/models.py:340
+#: core/models.py:305
msgid "pronouns"
msgstr "pronoms"
-#: core/models.py:342
+#: core/models.py:307
msgid "tshirt size"
msgstr "taille de t-shirt"
-#: core/models.py:345
+#: core/models.py:310
msgid "-"
msgstr "-"
-#: core/models.py:346
+#: core/models.py:311
msgid "XS"
msgstr "XS"
-#: core/models.py:347
+#: core/models.py:312
msgid "S"
msgstr "S"
-#: core/models.py:348
+#: core/models.py:313
msgid "M"
msgstr "M"
-#: core/models.py:349
+#: core/models.py:314
msgid "L"
msgstr "L"
-#: core/models.py:350
+#: core/models.py:315
msgid "XL"
msgstr "XL"
-#: core/models.py:351
+#: core/models.py:316
msgid "XXL"
msgstr "XXL"
-#: core/models.py:352
+#: core/models.py:317
msgid "XXXL"
msgstr "XXXL"
-#: core/models.py:360
+#: core/models.py:325
msgid "Student"
msgstr "Étudiant"
-#: core/models.py:361
+#: core/models.py:326
msgid "Administrative agent"
msgstr "Personnel administratif"
-#: core/models.py:362
+#: core/models.py:327
msgid "Teacher"
msgstr "Enseignant"
-#: core/models.py:363
+#: core/models.py:328
msgid "Agent"
msgstr "Personnel"
-#: core/models.py:364
+#: core/models.py:329
msgid "Doctor"
msgstr "Doctorant"
-#: core/models.py:365
+#: core/models.py:330
msgid "Former student"
msgstr "Ancien étudiant"
-#: core/models.py:366
+#: core/models.py:331
msgid "Service"
msgstr "Service"
-#: core/models.py:372
+#: core/models.py:337
msgid "department"
msgstr "département"
-#: core/models.py:379
+#: core/models.py:344
msgid "dpt option"
msgstr "Filière"
-#: core/models.py:381 pedagogy/models.py:70 pedagogy/models.py:294
+#: core/models.py:346 pedagogy/models.py:70 pedagogy/models.py:294
msgid "semester"
msgstr "semestre"
-#: core/models.py:382
+#: core/models.py:347
msgid "quote"
msgstr "citation"
-#: core/models.py:383
+#: core/models.py:348
msgid "school"
msgstr "école"
-#: core/models.py:385
+#: core/models.py:350
msgid "promo"
msgstr "promo"
-#: core/models.py:388
+#: core/models.py:353
msgid "forum signature"
msgstr "signature du forum"
-#: core/models.py:390
+#: core/models.py:355
msgid "second email address"
msgstr "adresse email secondaire"
-#: core/models.py:392
+#: core/models.py:357
msgid "parent phone"
msgstr "téléphone des parents"
-#: core/models.py:395
+#: core/models.py:360
msgid "parent address"
msgstr "adresse des parents"
-#: core/models.py:398
+#: core/models.py:363
msgid "is subscriber viewable"
msgstr "profil visible par les cotisants"
-#: core/models.py:594
+#: core/models.py:556
msgid "A user with that username already exists"
msgstr "Un utilisateur de ce nom d'utilisateur existe déjà"
-#: core/models.py:761 core/templates/core/macros.jinja:80
+#: core/models.py:718 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
@@ -2226,101 +2200,101 @@ msgstr "Un utilisateur de ce nom d'utilisateur existe déjà"
msgid "Profile"
msgstr "Profil"
-#: core/models.py:872
+#: core/models.py:829
msgid "Visitor"
msgstr "Visiteur"
-#: core/models.py:879
+#: core/models.py:836
msgid "receive the Weekmail"
msgstr "recevoir le Weekmail"
-#: core/models.py:880
+#: core/models.py:837
msgid "show your stats to others"
msgstr "montrez vos statistiques aux autres"
-#: core/models.py:882
+#: core/models.py:839
msgid "get a notification for every click"
msgstr "avoir une notification pour chaque click"
-#: core/models.py:885
+#: core/models.py:842
msgid "get a notification for every refilling"
msgstr "avoir une notification pour chaque rechargement"
-#: core/models.py:911 sas/forms.py:81
+#: core/models.py:868 sas/forms.py:81
msgid "file name"
msgstr "nom du fichier"
-#: core/models.py:915 core/models.py:1268
+#: core/models.py:872 core/models.py:1223
msgid "parent"
msgstr "parent"
-#: core/models.py:929
+#: core/models.py:886
msgid "compressed file"
msgstr "version allégée"
-#: core/models.py:936
+#: core/models.py:893
msgid "thumbnail"
msgstr "miniature"
-#: core/models.py:944 core/models.py:961
+#: core/models.py:901 core/models.py:918
msgid "owner"
msgstr "propriétaire"
-#: core/models.py:948 core/models.py:1285
+#: core/models.py:905 core/models.py:1240
msgid "edit group"
msgstr "groupe d'édition"
-#: core/models.py:951 core/models.py:1288
+#: core/models.py:908 core/models.py:1243
msgid "view group"
msgstr "groupe de vue"
-#: core/models.py:953
+#: core/models.py:910
msgid "is folder"
msgstr "est un dossier"
-#: core/models.py:954
+#: core/models.py:911
msgid "mime type"
msgstr "type mime"
-#: core/models.py:955
+#: core/models.py:912
msgid "size"
msgstr "taille"
-#: core/models.py:966
+#: core/models.py:923
msgid "asked for removal"
msgstr "retrait demandé"
-#: core/models.py:968
+#: core/models.py:925
msgid "is in the SAS"
msgstr "est dans le SAS"
-#: core/models.py:1037
+#: core/models.py:992
msgid "Character '/' not authorized in name"
msgstr "Le caractère '/' n'est pas autorisé dans les noms de fichier"
-#: core/models.py:1039 core/models.py:1043
+#: core/models.py:994 core/models.py:998
msgid "Loop in folder tree"
msgstr "Boucle dans l'arborescence des dossiers"
-#: core/models.py:1046
+#: core/models.py:1001
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:1057
+#: core/models.py:1012
msgid "Duplicate file"
msgstr "Un fichier de ce nom existe déjà"
-#: core/models.py:1074
+#: core/models.py:1029
msgid "You must provide a file"
msgstr "Vous devez fournir un fichier"
-#: core/models.py:1251
+#: core/models.py:1206
msgid "page unix name"
msgstr "nom unix de la page"
-#: core/models.py:1257
+#: core/models.py:1212
msgid ""
"Enter a valid page name. This value may contain only unaccented letters, "
"numbers and ./+/-/_ characters."
@@ -2328,55 +2302,55 @@ msgstr ""
"Entrez un nom de page correct. Uniquement des lettres non accentuées, "
"numéros, et ./+/-/_"
-#: core/models.py:1275
+#: core/models.py:1230
msgid "page name"
msgstr "nom de la page"
-#: core/models.py:1280
+#: core/models.py:1235
msgid "owner group"
msgstr "groupe propriétaire"
-#: core/models.py:1293
+#: core/models.py:1248
msgid "lock user"
msgstr "utilisateur bloquant"
-#: core/models.py:1300
+#: core/models.py:1255
msgid "lock_timeout"
msgstr "décompte du déblocage"
-#: core/models.py:1350
+#: core/models.py:1305
msgid "Duplicate page"
msgstr "Une page de ce nom existe déjà"
-#: core/models.py:1353
+#: core/models.py:1308
msgid "Loop in page tree"
msgstr "Boucle dans l'arborescence des pages"
-#: core/models.py:1464
+#: core/models.py:1419
msgid "revision"
msgstr "révision"
-#: core/models.py:1465
+#: core/models.py:1420
msgid "page title"
msgstr "titre de la page"
-#: core/models.py:1466
+#: core/models.py:1421
msgid "page content"
msgstr "contenu de la page"
-#: core/models.py:1507
+#: core/models.py:1462
msgid "url"
msgstr "url"
-#: core/models.py:1508
+#: core/models.py:1463
msgid "param"
msgstr "param"
-#: core/models.py:1513
+#: core/models.py:1468
msgid "viewed"
msgstr "vue"
-#: core/models.py:1571
+#: core/models.py:1526
msgid "operation type"
msgstr "type d'opération"
@@ -2501,7 +2475,7 @@ msgstr "Forum"
msgid "Gallery"
msgstr "Photos"
-#: core/templates/core/base/navbar.jinja:22 counter/models.py:490
+#: core/templates/core/base/navbar.jinja:22 counter/models.py:491
#: counter/templates/counter/counter_list.jinja:11
#: eboutic/templates/eboutic/eboutic_main.jinja:4
#: eboutic/templates/eboutic/eboutic_main.jinja:22
@@ -2527,7 +2501,7 @@ msgid "Launderette"
msgstr "Laverie"
#: core/templates/core/base/navbar.jinja:28 core/templates/core/file.jinja:24
-#: core/views/files.py:121
+#: core/views/files.py:122
msgid "Files"
msgstr "Fichiers"
@@ -3492,16 +3466,16 @@ msgid_plural "%(nb_days)d days, %(remainder)s"
msgstr[0] ""
msgstr[1] ""
-#: core/views/files.py:118
+#: core/views/files.py:119
msgid "Add a new folder"
msgstr "Ajouter un nouveau dossier"
-#: core/views/files.py:138
+#: core/views/files.py:139
#, python-format
msgid "Error creating folder %(folder_name)s: %(msg)s"
msgstr "Erreur de création du dossier %(folder_name)s : %(msg)s"
-#: core/views/files.py:158 core/views/forms.py:272 core/views/forms.py:279
+#: core/views/files.py:159 core/views/forms.py:270 core/views/forms.py:277
#: sas/forms.py:60
#, python-format
msgid "Error uploading file %(file_name)s: %(msg)s"
@@ -3523,7 +3497,7 @@ msgstr "Choisir un utilisateur"
msgid "Username, email, or account number"
msgstr "Nom d'utilisateur, email, ou numéro de compte AE"
-#: core/views/forms.py:222
+#: core/views/forms.py:220
msgid ""
"Profile: you need to be visible on the picture, in order to be recognized (e."
"g. by the barmen)"
@@ -3531,53 +3505,53 @@ msgstr ""
"Photo de profil: vous devez être visible sur la photo afin d'être reconnu "
"(par exemple par les barmen)"
-#: core/views/forms.py:227
+#: core/views/forms.py:225
msgid "Avatar: used on the forum"
msgstr "Avatar : utilisé sur le forum"
-#: core/views/forms.py:231
+#: core/views/forms.py:229
msgid "Scrub: let other know how your scrub looks like!"
msgstr "Blouse : montrez aux autres à quoi ressemble votre blouse !"
-#: core/views/forms.py:283
+#: core/views/forms.py:281
msgid "Bad image format, only jpeg, png, webp and gif are accepted"
msgstr "Mauvais format d'image, seuls les jpeg, png, webp et gif sont acceptés"
-#: core/views/forms.py:304
+#: core/views/forms.py:302
msgid "Godfather / Godmother"
msgstr "Parrain / Marraine"
-#: core/views/forms.py:305
+#: core/views/forms.py:303
msgid "Godchild"
msgstr "Fillot / Fillote"
-#: core/views/forms.py:310 counter/forms.py:78 trombi/views.py:151
+#: core/views/forms.py:308 counter/forms.py:78 trombi/views.py:151
msgid "Select user"
msgstr "Choisir un utilisateur"
-#: core/views/forms.py:324
+#: core/views/forms.py:322
msgid "This user does not exist"
msgstr "Cet utilisateur n'existe pas"
-#: core/views/forms.py:326
+#: core/views/forms.py:324
msgid "You cannot be related to yourself"
msgstr "Vous ne pouvez pas être relié à vous-même"
-#: core/views/forms.py:338
+#: core/views/forms.py:336
#, python-format
msgid "%s is already your godfather"
msgstr "%s est déjà votre parrain/marraine"
-#: core/views/forms.py:344
+#: core/views/forms.py:342
#, python-format
msgid "%s is already your godchild"
msgstr "%s est déjà votre fillot/fillote"
-#: core/views/group.py:41
+#: core/views/group.py:39
msgid "Users to add to group"
msgstr "Utilisateurs à ajouter au groupe"
-#: core/views/group.py:50
+#: core/views/group.py:48
msgid "Users to remove from group"
msgstr "Utilisateurs à retirer du groupe"
@@ -3607,13 +3581,13 @@ msgstr "Chèque"
msgid "Cash"
msgstr "Espèces"
-#: counter/apps.py:30 counter/models.py:801 sith/settings.py:415
+#: counter/apps.py:30 counter/models.py:802 sith/settings.py:415
#: sith/settings.py:420
msgid "Credit card"
msgstr "Carte bancaire"
-#: counter/apps.py:36 counter/models.py:506 counter/models.py:962
-#: counter/models.py:998 launderette/models.py:32
+#: counter/apps.py:36 counter/models.py:507 counter/models.py:963
+#: counter/models.py:999 launderette/models.py:32
msgid "counter"
msgstr "comptoir"
@@ -3633,184 +3607,184 @@ msgstr "Votre compte AE a été vidé"
msgid "Clearing of your AE account"
msgstr "Vidange de votre compte AE"
-#: counter/migrations/0013_customer_recorded_products.py:25
-msgid "Ecocup regularization"
-msgstr "Régularization des ecocups"
-
-#: counter/models.py:91
+#: counter/models.py:92
msgid "account id"
msgstr "numéro de compte"
-#: counter/models.py:93
+#: counter/models.py:94
msgid "recorded product"
msgstr "produits consignés"
-#: counter/models.py:98
+#: counter/models.py:99
msgid "customer"
msgstr "client"
-#: counter/models.py:99
+#: counter/models.py:100
msgid "customers"
msgstr "clients"
-#: counter/models.py:111 counter/views/click.py:68
+#: counter/models.py:112 counter/views/click.py:68
msgid "Not enough money"
msgstr "Solde insuffisant"
-#: counter/models.py:197
+#: counter/models.py:198
msgid "First name"
msgstr "Prénom"
-#: counter/models.py:198
+#: counter/models.py:199
msgid "Last name"
msgstr "Nom de famille"
-#: counter/models.py:199
+#: counter/models.py:200
msgid "Address 1"
msgstr "Adresse 1"
-#: counter/models.py:200
+#: counter/models.py:201
msgid "Address 2"
msgstr "Adresse 2"
-#: counter/models.py:201
+#: counter/models.py:202
msgid "Zip code"
msgstr "Code postal"
-#: counter/models.py:202
+#: counter/models.py:203
msgid "City"
msgstr "Ville"
-#: counter/models.py:203
+#: counter/models.py:204
msgid "Country"
msgstr "Pays"
-#: counter/models.py:211
+#: counter/models.py:212
msgid "Phone number"
msgstr "Numéro de téléphone"
-#: counter/models.py:253
+#: counter/models.py:254
msgid "When the mail warning that the account was about to be dumped was sent."
msgstr "Quand le mail d'avertissement de la vidange du compte a été envoyé."
-#: counter/models.py:258
+#: counter/models.py:259
msgid "Set this to True if the warning mail received an error"
msgstr "Mettre à True si le mail a reçu une erreur"
-#: counter/models.py:265
+#: counter/models.py:266
msgid "The operation that emptied the account."
msgstr "L'opération qui a vidé le compte."
-#: counter/models.py:310 counter/models.py:334
+#: counter/models.py:304
+msgid "A text that will be shown on the eboutic."
+msgstr "Un texte qui sera affiché sur l'eboutic."
+
+#: counter/models.py:311 counter/models.py:335
msgid "product type"
msgstr "type du produit"
-#: counter/models.py:341
+#: counter/models.py:342
msgid "purchase price"
msgstr "prix d'achat"
-#: counter/models.py:342
+#: counter/models.py:343
msgid "Initial cost of purchasing the product"
msgstr "Coût initial d'achat du produit"
-#: counter/models.py:344
+#: counter/models.py:345
msgid "selling price"
msgstr "prix de vente"
-#: counter/models.py:346
+#: counter/models.py:347
msgid "special selling price"
msgstr "prix de vente spécial"
-#: counter/models.py:347
+#: counter/models.py:348
msgid "Price for barmen during their permanence"
msgstr "Prix pour les barmen durant leur permanence"
-#: counter/models.py:355
+#: counter/models.py:356
msgid "icon"
msgstr "icône"
-#: counter/models.py:360
+#: counter/models.py:361
msgid "limit age"
msgstr "âge limite"
-#: counter/models.py:361
+#: counter/models.py:362
msgid "tray price"
msgstr "prix plateau"
-#: counter/models.py:363
+#: counter/models.py:364
msgid "buying groups"
msgstr "groupe d'achat"
-#: counter/models.py:365 election/models.py:50
+#: counter/models.py:366 election/models.py:50
msgid "archived"
msgstr "archivé"
-#: counter/models.py:368 counter/models.py:1096
+#: counter/models.py:369 counter/models.py:1097
msgid "product"
msgstr "produit"
-#: counter/models.py:485
+#: counter/models.py:486
msgid "products"
msgstr "produits"
-#: counter/models.py:488
+#: counter/models.py:489
msgid "counter type"
msgstr "type de comptoir"
-#: counter/models.py:490
+#: counter/models.py:491
msgid "Bar"
msgstr "Bar"
-#: counter/models.py:490
+#: counter/models.py:491
msgid "Office"
msgstr "Bureau"
-#: counter/models.py:493
+#: counter/models.py:494
msgid "sellers"
msgstr "vendeurs"
-#: counter/models.py:501 launderette/models.py:178
+#: counter/models.py:502 launderette/models.py:178
msgid "token"
msgstr "jeton"
-#: counter/models.py:701
+#: counter/models.py:702
msgid "bank"
msgstr "banque"
-#: counter/models.py:703 counter/models.py:804
+#: counter/models.py:704 counter/models.py:805
msgid "is validated"
msgstr "est validé"
-#: counter/models.py:708
+#: counter/models.py:709
msgid "refilling"
msgstr "rechargement"
-#: counter/models.py:781 eboutic/models.py:249
+#: counter/models.py:782 eboutic/models.py:249
msgid "unit price"
msgstr "prix unitaire"
-#: counter/models.py:782 counter/models.py:1076 eboutic/models.py:250
+#: counter/models.py:783 counter/models.py:1077 eboutic/models.py:250
msgid "quantity"
msgstr "quantité"
-#: counter/models.py:801
+#: counter/models.py:802
msgid "Sith account"
msgstr "Compte utilisateur"
-#: counter/models.py:809
+#: counter/models.py:810
msgid "selling"
msgstr "vente"
-#: counter/models.py:913
+#: counter/models.py:914
msgid "Unknown event"
msgstr "Événement inconnu"
-#: counter/models.py:914
+#: counter/models.py:915
#, python-format
msgid "Eticket bought for the event %(event)s"
msgstr "Eticket acheté pour l'événement %(event)s"
-#: counter/models.py:916 counter/models.py:929
+#: counter/models.py:917 counter/models.py:930
#, python-format
msgid ""
"You bought an eticket for the event %(event)s.\n"
@@ -3822,67 +3796,67 @@ msgstr ""
"Vous pouvez également retrouver tous vos e-tickets sur votre page de compte "
"%(url)s."
-#: counter/models.py:967
+#: counter/models.py:968
msgid "last activity date"
msgstr "dernière activité"
-#: counter/models.py:970
+#: counter/models.py:971
msgid "permanency"
msgstr "permanence"
-#: counter/models.py:1003
+#: counter/models.py:1004
msgid "emptied"
msgstr "coffre vidée"
-#: counter/models.py:1006
+#: counter/models.py:1007
msgid "cash register summary"
msgstr "relevé de caisse"
-#: counter/models.py:1072
+#: counter/models.py:1073
msgid "cash summary"
msgstr "relevé"
-#: counter/models.py:1075
+#: counter/models.py:1076
msgid "value"
msgstr "valeur"
-#: counter/models.py:1078
+#: counter/models.py:1079
msgid "check"
msgstr "chèque"
-#: counter/models.py:1080
+#: counter/models.py:1081
msgid "True if this is a bank check, else False"
msgstr "Vrai si c'est un chèque, sinon Faux."
-#: counter/models.py:1084
+#: counter/models.py:1085
msgid "cash register summary item"
msgstr "élément de relevé de caisse"
-#: counter/models.py:1100
+#: counter/models.py:1101
msgid "banner"
msgstr "bannière"
-#: counter/models.py:1102
+#: counter/models.py:1103
msgid "event date"
msgstr "date de l'événement"
-#: counter/models.py:1104
+#: counter/models.py:1105
msgid "event title"
msgstr "titre de l'événement"
-#: counter/models.py:1106
+#: counter/models.py:1107
msgid "secret"
msgstr "secret"
-#: counter/models.py:1145
+#: counter/models.py:1146
msgid "uid"
msgstr "uid"
-#: counter/models.py:1150 counter/models.py:1155
+#: counter/models.py:1151 counter/models.py:1156
msgid "student card"
msgstr "carte étudiante"
-#: counter/models.py:1156
+#: counter/models.py:1157
msgid "student cards"
msgstr "cartes étudiantes"
@@ -4193,17 +4167,30 @@ msgstr "Sans catégorie"
msgid "There is no products in this website."
msgstr "Il n'y a pas de produits dans ce site web."
-#: counter/templates/counter/producttype_list.jinja:4
-#: counter/templates/counter/producttype_list.jinja:10
+#: counter/templates/counter/product_type_list.jinja:4
+#: counter/templates/counter/product_type_list.jinja:42
msgid "Product type list"
msgstr "Liste des types de produit"
-#: counter/templates/counter/producttype_list.jinja:8
+#: counter/templates/counter/product_type_list.jinja:18
msgid "New product type"
msgstr "Nouveau type de produit"
-#: counter/templates/counter/producttype_list.jinja:17
-msgid "There is no product types in this website."
+#: counter/templates/counter/product_type_list.jinja:25
+msgid "Product types are in the same order on this page and on the eboutic."
+msgstr ""
+"Les types de produit sont dans le même ordre sur cette page et sur l'eboutic."
+
+#: counter/templates/counter/product_type_list.jinja:28
+msgid ""
+"You can reorder them here by drag-and-drop. The changes will then be applied "
+"globally immediately."
+msgstr ""
+"Vous pouvez les réorganiser ici. Les changements seront alors immédiatement "
+"appliqués globalement."
+
+#: counter/templates/counter/product_type_list.jinja:61
+msgid "There are no product types in this website."
msgstr "Il n'y a pas de types de produit dans ce site web."
#: counter/templates/counter/refilling_list.jinja:15
diff --git a/locale/fr/LC_MESSAGES/djangojs.po b/locale/fr/LC_MESSAGES/djangojs.po
index e907e571..414bb603 100644
--- a/locale/fr/LC_MESSAGES/djangojs.po
+++ b/locale/fr/LC_MESSAGES/djangojs.po
@@ -7,7 +7,7 @@
msgid ""
msgstr ""
"Report-Msgid-Bugs-To: \n"
-"POT-Creation-Date: 2024-11-14 10:24+0100\n"
+"POT-Creation-Date: 2024-12-17 00:46+0100\n"
"PO-Revision-Date: 2024-09-17 11:54+0200\n"
"Last-Translator: Sli \n"
"Language-Team: AE info \n"
@@ -17,119 +17,128 @@ msgstr ""
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=(n > 1);\n"
+#: core/static/bundled/core/components/ajax-select-base.ts:68
+msgid "Remove"
+msgstr "Retirer"
+
+#: core/static/bundled/core/components/ajax-select-base.ts:90
+msgid "You need to type %(number)s more characters"
+msgstr "Vous devez taper %(number)s caractères de plus"
+
+#: core/static/bundled/core/components/ajax-select-base.ts:94
+msgid "No results found"
+msgstr "Aucun résultat trouvé"
+
+#: core/static/bundled/core/components/easymde-index.ts:38
+msgid "Heading"
+msgstr "Titre"
+
+#: core/static/bundled/core/components/easymde-index.ts:44
+msgid "Italic"
+msgstr "Italique"
+
+#: core/static/bundled/core/components/easymde-index.ts:50
+msgid "Bold"
+msgstr "Gras"
+
+#: core/static/bundled/core/components/easymde-index.ts:56
+msgid "Strikethrough"
+msgstr "Barré"
+
+#: core/static/bundled/core/components/easymde-index.ts:65
+msgid "Underline"
+msgstr "Souligné"
+
+#: core/static/bundled/core/components/easymde-index.ts:74
+msgid "Superscript"
+msgstr "Exposant"
+
+#: core/static/bundled/core/components/easymde-index.ts:83
+msgid "Subscript"
+msgstr "Indice"
+
+#: core/static/bundled/core/components/easymde-index.ts:89
+msgid "Code"
+msgstr "Code"
+
+#: core/static/bundled/core/components/easymde-index.ts:96
+msgid "Quote"
+msgstr "Citation"
+
+#: core/static/bundled/core/components/easymde-index.ts:102
+msgid "Unordered list"
+msgstr "Liste non ordonnée"
+
+#: core/static/bundled/core/components/easymde-index.ts:108
+msgid "Ordered list"
+msgstr "Liste ordonnée"
+
+#: core/static/bundled/core/components/easymde-index.ts:115
+msgid "Insert link"
+msgstr "Insérer lien"
+
+#: core/static/bundled/core/components/easymde-index.ts:121
+msgid "Insert image"
+msgstr "Insérer image"
+
+#: core/static/bundled/core/components/easymde-index.ts:127
+msgid "Insert table"
+msgstr "Insérer tableau"
+
+#: core/static/bundled/core/components/easymde-index.ts:134
+msgid "Clean block"
+msgstr "Nettoyer bloc"
+
+#: core/static/bundled/core/components/easymde-index.ts:141
+msgid "Toggle preview"
+msgstr "Activer la prévisualisation"
+
+#: core/static/bundled/core/components/easymde-index.ts:147
+msgid "Toggle side by side"
+msgstr "Activer la vue côte à côte"
+
+#: core/static/bundled/core/components/easymde-index.ts:153
+msgid "Toggle fullscreen"
+msgstr "Activer le plein écran"
+
+#: core/static/bundled/core/components/easymde-index.ts:160
+msgid "Markdown guide"
+msgstr "Guide markdown"
+
+#: core/static/bundled/core/components/nfc-input-index.ts:26
+msgid "Unsupported NFC card"
+msgstr "Carte NFC non supportée"
+
+#: core/static/bundled/user/family-graph-index.js:233
+msgid "family_tree.%(extension)s"
+msgstr "arbre_genealogique.%(extension)s"
+
+#: core/static/bundled/user/pictures-index.js:76
+msgid "pictures.%(extension)s"
+msgstr "photos.%(extension)s"
+
#: core/static/user/js/user_edit.js:91
#, javascript-format
msgid "captured.%s"
msgstr "capture.%s"
-#: core/static/webpack/core/components/ajax-select-base.ts:68
-msgid "Remove"
-msgstr "Retirer"
+#: counter/static/bundled/counter/product-type-index.ts:36
+msgid "Products types reordered!"
+msgstr "Types de produits réordonnés !"
-#: core/static/webpack/core/components/ajax-select-base.ts:88
-msgid "You need to type %(number)s more characters"
-msgstr "Vous devez taper %(number)s caractères de plus"
-
-#: core/static/webpack/core/components/ajax-select-base.ts:92
-msgid "No results found"
-msgstr "Aucun résultat trouvé"
-
-#: core/static/webpack/core/components/easymde-index.ts:38
-msgid "Heading"
-msgstr "Titre"
-
-#: core/static/webpack/core/components/easymde-index.ts:44
-msgid "Italic"
-msgstr "Italique"
-
-#: core/static/webpack/core/components/easymde-index.ts:50
-msgid "Bold"
-msgstr "Gras"
-
-#: core/static/webpack/core/components/easymde-index.ts:56
-msgid "Strikethrough"
-msgstr "Barré"
-
-#: core/static/webpack/core/components/easymde-index.ts:65
-msgid "Underline"
-msgstr "Souligné"
-
-#: core/static/webpack/core/components/easymde-index.ts:74
-msgid "Superscript"
-msgstr "Exposant"
-
-#: core/static/webpack/core/components/easymde-index.ts:83
-msgid "Subscript"
-msgstr "Indice"
-
-#: core/static/webpack/core/components/easymde-index.ts:89
-msgid "Code"
-msgstr "Code"
-
-#: core/static/webpack/core/components/easymde-index.ts:96
-msgid "Quote"
-msgstr "Citation"
-
-#: core/static/webpack/core/components/easymde-index.ts:102
-msgid "Unordered list"
-msgstr "Liste non ordonnée"
-
-#: core/static/webpack/core/components/easymde-index.ts:108
-msgid "Ordered list"
-msgstr "Liste ordonnée"
-
-#: core/static/webpack/core/components/easymde-index.ts:115
-msgid "Insert link"
-msgstr "Insérer lien"
-
-#: core/static/webpack/core/components/easymde-index.ts:121
-msgid "Insert image"
-msgstr "Insérer image"
-
-#: core/static/webpack/core/components/easymde-index.ts:127
-msgid "Insert table"
-msgstr "Insérer tableau"
-
-#: core/static/webpack/core/components/easymde-index.ts:134
-msgid "Clean block"
-msgstr "Nettoyer bloc"
-
-#: core/static/webpack/core/components/easymde-index.ts:141
-msgid "Toggle preview"
-msgstr "Activer la prévisualisation"
-
-#: core/static/webpack/core/components/easymde-index.ts:147
-msgid "Toggle side by side"
-msgstr "Activer la vue côte à côte"
-
-#: core/static/webpack/core/components/easymde-index.ts:153
-msgid "Toggle fullscreen"
-msgstr "Activer le plein écran"
-
-#: core/static/webpack/core/components/easymde-index.ts:160
-msgid "Markdown guide"
-msgstr "Guide markdown"
-
-#: core/static/webpack/core/components/nfc-input-index.ts:24
-msgid "Unsupported NFC card"
-msgstr "Carte NFC non supportée"
-
-#: core/static/webpack/user/family-graph-index.js:233
-msgid "family_tree.%(extension)s"
-msgstr "arbre_genealogique.%(extension)s"
-
-#: core/static/webpack/user/pictures-index.js:76
-msgid "pictures.%(extension)s"
-msgstr "photos.%(extension)s"
+#: counter/static/bundled/counter/product-type-index.ts:40
+#, javascript-format
+msgid "Product type reorganisation failed with status code : %d"
+msgstr "La réorganisation des types de produit a échoué avec le code : %d"
#: eboutic/static/eboutic/js/makecommand.js:56
msgid "Incorrect value"
msgstr "Valeur incorrecte"
-#: sas/static/webpack/sas/viewer-index.ts:271
+#: sas/static/bundled/sas/viewer-index.ts:271
msgid "Couldn't moderate picture"
msgstr "Il n'a pas été possible de modérer l'image"
-#: sas/static/webpack/sas/viewer-index.ts:284
+#: sas/static/bundled/sas/viewer-index.ts:284
msgid "Couldn't delete picture"
msgstr "Il n'a pas été possible de supprimer l'image"
diff --git a/package-lock.json b/package-lock.json
index 05418a69..c46ef180 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -9,12 +9,13 @@
"version": "3",
"license": "GPL-3.0-only",
"dependencies": {
+ "@alpinejs/sort": "^3.14.7",
"@fortawesome/fontawesome-free": "^6.6.0",
"@hey-api/client-fetch": "^0.4.0",
"@sentry/browser": "^8.34.0",
"@zip.js/zip.js": "^2.7.52",
"3d-force-graph": "^1.73.4",
- "alpinejs": "^3.14.1",
+ "alpinejs": "^3.14.7",
"chart.js": "^4.4.4",
"cytoscape": "^3.30.2",
"cytoscape-cxtmenu": "^3.5.0",
@@ -44,6 +45,12 @@
"vite-plugin-static-copy": "^2.1.0"
}
},
+ "node_modules/@alpinejs/sort": {
+ "version": "3.14.7",
+ "resolved": "https://registry.npmjs.org/@alpinejs/sort/-/sort-3.14.7.tgz",
+ "integrity": "sha512-EJzxTBSoKvOxKHAUFeTSgxJR4rJQQPm10b4dB38kGcsxjUtOeNkbBF3xV4nlc0ZyTv7DarTWdppdoR/iP8jfdQ==",
+ "license": "MIT"
+ },
"node_modules/@ampproject/remapping": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz",
@@ -3064,9 +3071,10 @@
}
},
"node_modules/alpinejs": {
- "version": "3.14.1",
- "resolved": "https://registry.npmjs.org/alpinejs/-/alpinejs-3.14.1.tgz",
- "integrity": "sha512-ICar8UsnRZAYvv/fCNfNeKMXNoXGUfwHrjx7LqXd08zIP95G2d9bAOuaL97re+1mgt/HojqHsfdOLo/A5LuWgQ==",
+ "version": "3.14.7",
+ "resolved": "https://registry.npmjs.org/alpinejs/-/alpinejs-3.14.7.tgz",
+ "integrity": "sha512-ScnbydNBcWVnCiVupD3wWUvoMPm8244xkvDNMxVCspgmap9m4QuJ7pjc+77UtByU+1+Ejg0wzYkP4mQaOMcvng==",
+ "license": "MIT",
"dependencies": {
"@vue/reactivity": "~3.1.1"
}
diff --git a/package.json b/package.json
index 2ca46967..77572a6f 100644
--- a/package.json
+++ b/package.json
@@ -33,12 +33,13 @@
"vite-plugin-static-copy": "^2.1.0"
},
"dependencies": {
+ "@alpinejs/sort": "^3.14.7",
"@fortawesome/fontawesome-free": "^6.6.0",
"@hey-api/client-fetch": "^0.4.0",
"@sentry/browser": "^8.34.0",
"@zip.js/zip.js": "^2.7.52",
"3d-force-graph": "^1.73.4",
- "alpinejs": "^3.14.1",
+ "alpinejs": "^3.14.7",
"chart.js": "^4.4.4",
"cytoscape": "^3.30.2",
"cytoscape-cxtmenu": "^3.5.0",
diff --git a/pedagogy/migrations/0002_auto_20190827_2251.py b/pedagogy/migrations/0002_auto_20190827_2251.py
index 2b1f4b36..7fbe05a8 100644
--- a/pedagogy/migrations/0002_auto_20190827_2251.py
+++ b/pedagogy/migrations/0002_auto_20190827_2251.py
@@ -25,26 +25,11 @@ from __future__ import unicode_literals
from django.db import migrations
-from core.models import User
-
-
-def remove_multiples_comments_from_same_user(apps, schema_editor):
- for user in User.objects.exclude(uv_comments=None).prefetch_related("uv_comments"):
- for uv in user.uv_comments.values("uv").distinct():
- last = (
- user.uv_comments.filter(uv__id=uv["uv"])
- .order_by("-publish_date")
- .first()
- )
- user.uv_comments.filter(uv__id=uv["uv"]).exclude(pk=last.pk).delete()
-
class Migration(migrations.Migration):
dependencies = [("pedagogy", "0001_initial")]
- operations = [
- migrations.RunPython(
- remove_multiples_comments_from_same_user,
- reverse_code=migrations.RunPython.noop,
- )
- ]
+ # This migration contained just a RunPython operation
+ # Which has since been removed.
+ # The migration itself is kept in order not to break the migration tree
+ operations = []
diff --git a/pedagogy/tests/test_api.py b/pedagogy/tests/test_api.py
index b8fb90b4..cbb99c18 100644
--- a/pedagogy/tests/test_api.py
+++ b/pedagogy/tests/test_api.py
@@ -8,7 +8,7 @@ from model_bakery import baker
from model_bakery.recipe import Recipe
from core.baker_recipes import subscriber_user
-from core.models import RealGroup, User
+from core.models import Group, User
from pedagogy.models import UV
@@ -80,9 +80,7 @@ class TestUVSearch(TestCase):
subscriber_user.make(),
baker.make(
User,
- groups=[
- RealGroup.objects.get(pk=settings.SITH_GROUP_PEDAGOGY_ADMIN_ID)
- ],
+ groups=[Group.objects.get(pk=settings.SITH_GROUP_PEDAGOGY_ADMIN_ID)],
),
):
# users that have right
diff --git a/pedagogy/views.py b/pedagogy/views.py
index ca2c712e..99dd8168 100644
--- a/pedagogy/views.py
+++ b/pedagogy/views.py
@@ -24,6 +24,7 @@
from django.conf import settings
from django.contrib.auth.mixins import LoginRequiredMixin
from django.core.exceptions import PermissionDenied
+from django.db.models import Exists, OuterRef
from django.shortcuts import get_object_or_404
from django.urls import reverse, reverse_lazy
from django.views.generic import (
@@ -34,7 +35,7 @@ from django.views.generic import (
UpdateView,
)
-from core.models import Notification, RealGroup
+from core.models import Notification, User
from core.views import (
CanCreateMixin,
CanEditPropMixin,
@@ -156,21 +157,19 @@ class UVCommentReportCreateView(CanCreateMixin, CreateView):
def form_valid(self, form):
resp = super().form_valid(form)
-
# Send a message to moderation admins
- for user in (
- RealGroup.objects.filter(id=settings.SITH_GROUP_PEDAGOGY_ADMIN_ID)
- .first()
- .users.all()
+ unread_notif_subquery = Notification.objects.filter(
+ user=OuterRef("pk"), type="PEDAGOGY_MODERATION", viewed=False
+ )
+ for user in User.objects.filter(
+ ~Exists(unread_notif_subquery),
+ groups__id__in=[settings.SITH_GROUP_PEDAGOGY_ADMIN_ID],
):
- if not user.notifications.filter(
- type="PEDAGOGY_MODERATION", viewed=False
- ).exists():
- Notification(
- user=user,
- url=reverse("pedagogy:moderation"),
- type="PEDAGOGY_MODERATION",
- ).save()
+ Notification.objects.create(
+ user=user,
+ url=reverse("pedagogy:moderation"),
+ type="PEDAGOGY_MODERATION",
+ )
return resp
diff --git a/rootplace/tests.py b/rootplace/tests.py
index 0d0f1542..a2bbee81 100644
--- a/rootplace/tests.py
+++ b/rootplace/tests.py
@@ -19,7 +19,7 @@ from django.urls import reverse
from django.utils.timezone import localtime, now
from club.models import Club
-from core.models import RealGroup, User
+from core.models import Group, User
from counter.models import Counter, Customer, Product, Refilling, Selling
from subscription.models import Subscription
@@ -50,9 +50,9 @@ class TestMergeUser(TestCase):
self.to_keep.address = "Jerusalem"
self.to_delete.parent_address = "Rome"
self.to_delete.address = "Rome"
- subscribers = RealGroup.objects.get(name="Subscribers")
- mde_admin = RealGroup.objects.get(name="MDE admin")
- sas_admin = RealGroup.objects.get(name="SAS admin")
+ subscribers = Group.objects.get(name="Subscribers")
+ mde_admin = Group.objects.get(name="MDE admin")
+ sas_admin = Group.objects.get(name="SAS admin")
self.to_keep.groups.add(subscribers.id)
self.to_delete.groups.add(mde_admin.id)
self.to_keep.groups.add(sas_admin.id)
diff --git a/sas/tests/test_api.py b/sas/tests/test_api.py
index 733838d2..9b24688b 100644
--- a/sas/tests/test_api.py
+++ b/sas/tests/test_api.py
@@ -7,7 +7,7 @@ from model_bakery import baker
from model_bakery.recipe import Recipe
from core.baker_recipes import old_subscriber_user, subscriber_user
-from core.models import RealGroup, SithFile, User
+from core.models import Group, SithFile, User
from sas.baker_recipes import picture_recipe
from sas.models import Album, PeoplePictureRelation, Picture, PictureModerationRequest
@@ -155,7 +155,7 @@ class TestPictureRelation(TestSas):
def test_delete_relation_with_authorized_users(self):
"""Test that deletion works as intended when called by an authorized user."""
relation: PeoplePictureRelation = self.user_a.pictures.first()
- sas_admin_group = RealGroup.objects.get(pk=settings.SITH_GROUP_SAS_ADMIN_ID)
+ sas_admin_group = Group.objects.get(pk=settings.SITH_GROUP_SAS_ADMIN_ID)
sas_admin = baker.make(User, groups=[sas_admin_group])
root = baker.make(User, is_superuser=True)
for user in sas_admin, root, self.user_a:
@@ -189,7 +189,7 @@ class TestPictureModeration(TestSas):
def setUpTestData(cls):
super().setUpTestData()
cls.sas_admin = baker.make(
- User, groups=[RealGroup.objects.get(pk=settings.SITH_GROUP_SAS_ADMIN_ID)]
+ User, groups=[Group.objects.get(pk=settings.SITH_GROUP_SAS_ADMIN_ID)]
)
cls.picture = Picture.objects.filter(parent=cls.album_a)[0]
cls.picture.is_moderated = False
diff --git a/sas/tests/test_views.py b/sas/tests/test_views.py
index 2a73b90b..ff8dd21d 100644
--- a/sas/tests/test_views.py
+++ b/sas/tests/test_views.py
@@ -23,7 +23,7 @@ from model_bakery import baker
from pytest_django.asserts import assertInHTML, assertRedirects
from core.baker_recipes import old_subscriber_user, subscriber_user
-from core.models import RealGroup, User
+from core.models import Group, User
from sas.baker_recipes import picture_recipe
from sas.models import Album, Picture
@@ -38,7 +38,7 @@ from sas.models import Album, Picture
old_subscriber_user.make,
lambda: baker.make(User, is_superuser=True),
lambda: baker.make(
- User, groups=[RealGroup.objects.get(pk=settings.SITH_GROUP_SAS_ADMIN_ID)]
+ User, groups=[Group.objects.get(pk=settings.SITH_GROUP_SAS_ADMIN_ID)]
),
lambda: baker.make(User),
],
@@ -80,7 +80,7 @@ class TestSasModeration(TestCase):
cls.to_moderate.is_moderated = False
cls.to_moderate.save()
cls.moderator = baker.make(
- User, groups=[RealGroup.objects.get(pk=settings.SITH_GROUP_SAS_ADMIN_ID)]
+ User, groups=[Group.objects.get(pk=settings.SITH_GROUP_SAS_ADMIN_ID)]
)
cls.simple_user = subscriber_user.make()
diff --git a/subscription/tests/test_new_susbcription.py b/subscription/tests/test_new_susbcription.py
index 8ea51d68..ccdff407 100644
--- a/subscription/tests/test_new_susbcription.py
+++ b/subscription/tests/test_new_susbcription.py
@@ -90,13 +90,20 @@ def test_form_new_user(settings: SettingsWrapper):
@pytest.mark.django_db
@pytest.mark.parametrize(
- "user_factory", [lambda: baker.make(User, is_superuser=True), board_user.make]
+ ("user_factory", "status_code"),
+ [
+ (lambda: baker.make(User, is_superuser=True), 200),
+ (board_user.make, 200),
+ (subscriber_user.make, 403),
+ ],
)
-def test_load_page(client: Client, user_factory: Callable[[], User]):
- """Just check the page doesn't crash."""
+def test_page_access(
+ client: Client, user_factory: Callable[[], User], status_code: int
+):
+ """Check that only authorized users may access this page."""
client.force_login(user_factory())
res = client.get(reverse("subscription:subscription"))
- assert res.status_code == 200
+ assert res.status_code == status_code
@pytest.mark.django_db