From b06a06f50cac1f237dfadedd831879559764bd3e Mon Sep 17 00:00:00 2001 From: imperosol Date: Sat, 7 Dec 2024 13:38:31 +0100 Subject: [PATCH 01/17] feat: add restore on backspace plugin for tom select --- core/static/bundled/core/components/ajax-select-base.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/core/static/bundled/core/components/ajax-select-base.ts b/core/static/bundled/core/components/ajax-select-base.ts index 674b7b73..525de097 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, From e680124d7b9ea9ae1058bbc32bfaea0155ec0379 Mon Sep 17 00:00:00 2001 From: imperosol Date: Fri, 13 Dec 2024 23:41:24 +0100 Subject: [PATCH 02/17] fix makemessages command in docs --- docs/howto/translation.md | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) 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 From 6c8a6008d591561367dde8cf646114de974adac4 Mon Sep 17 00:00:00 2001 From: imperosol Date: Fri, 13 Dec 2024 23:58:25 +0100 Subject: [PATCH 03/17] api route to search products with detailed infos. --- .../core/components/ajax-select-base.ts | 2 +- counter/api.py | 50 ++++++++++++----- counter/schemas.py | 48 +++++++++++++++- .../counter/components/ajax-select-index.ts | 6 +- counter/tests/test_product.py | 55 +++++++++++++++++++ counter/widgets/select.py | 6 +- 6 files changed, 144 insertions(+), 23 deletions(-) diff --git a/core/static/bundled/core/components/ajax-select-base.ts b/core/static/bundled/core/components/ajax-select-base.ts index 525de097..06c3508e 100644 --- a/core/static/bundled/core/components/ajax-select-base.ts +++ b/core/static/bundled/core/components/ajax-select-base.ts @@ -68,7 +68,7 @@ export class AutoCompleteSelectBase extends inheritHtmlElement("select") { title: gettext("Remove"), }, // biome-ignore lint/style/useNamingConvention: this is required by the api - restore_on_backspace: {} + restore_on_backspace: {}, }, persist: false, maxItems: this.node.multiple ? this.max : 1, diff --git a/counter/api.py b/counter/api.py index f3f0f101..7c181aa0 100644 --- a/counter/api.py +++ b/counter/api.py @@ -12,21 +12,21 @@ # 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 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 core.api_permissions import CanAccessLookup, CanView, IsInGroup, IsRoot from counter.models import Counter, Product from counter.schemas import ( CounterFilterSchema, CounterSchema, + ProductFilterSchema, ProductSchema, + SimpleProductSchema, SimplifiedCounterSchema, ) @@ -64,15 +64,39 @@ 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__priority").desc(nulls_last=True), + "product_type", + "name", + ).values() + ) + + @route.get( + "/search/detailed", + response=PaginatedResponseSchema[ProductSchema], + permissions=[ + IsRoot + | IsInGroup(settings.SITH_GROUP_COUNTER_ADMIN_ID) + | IsInGroup(settings.SITH_GROUP_ACCOUNTING_ADMIN_ID) + ], + 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__priority").desc(nulls_last=True), + "product_type", + "name", + ) ) diff --git a/counter/schemas.py b/counter/schemas.py index ec1a842d..7ecb346b 100644 --- a/counter/schemas.py +++ b/counter/schemas.py @@ -1,10 +1,12 @@ from typing import Annotated from annotated_types import MinLen +from django.urls import reverse from ninja import Field, FilterSchema, ModelSchema -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 +28,47 @@ class SimplifiedCounterSchema(ModelSchema): fields = ["id", "name"] -class ProductSchema(ModelSchema): +class ProductTypeSchema(ModelSchema): + class Meta: + model = ProductType + fields = ["id", "name"] + + +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: ProductTypeSchema | 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/tests/test_product.py b/counter/tests/test_product.py index d18c6f11..a5eb39c4 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 RealGroup, User from counter.models import Product, ProductType @@ -31,3 +39,50 @@ 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=[RealGroup.objects.get(pk=settings.SITH_GROUP_COUNTER_ADMIN_ID)], + ), + 200, + ), + ( + lambda: baker.make( + User, + groups=[ + RealGroup.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/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 From 483670e79863afa6f616d2a0643619c687a6d8fc Mon Sep 17 00:00:00 2001 From: imperosol Date: Sun, 15 Dec 2024 18:55:09 +0100 Subject: [PATCH 04/17] Make `ProductType` an `OrderedModel` --- counter/admin.py | 2 +- counter/api.py | 4 +- ...0028_alter_producttype_comment_and_more.py | 62 +++++++ counter/models.py | 17 +- counter/views/admin.py | 6 +- eboutic/models.py | 2 +- eboutic/templates/eboutic/eboutic_main.jinja | 2 +- locale/fr/LC_MESSAGES/django.po | 172 +++++++++--------- 8 files changed, 167 insertions(+), 100 deletions(-) create mode 100644 counter/migrations/0028_alter_producttype_comment_and_more.py 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 7c181aa0..8c37d0ac 100644 --- a/counter/api.py +++ b/counter/api.py @@ -71,7 +71,7 @@ class ProductController(ControllerBase): def search_products(self, filters: Query[ProductFilterSchema]): return filters.filter( Product.objects.order_by( - F("product_type__priority").desc(nulls_last=True), + F("product_type__order").asc(nulls_last=True), "product_type", "name", ).values() @@ -95,7 +95,7 @@ class ProductController(ControllerBase): .prefetch_related("buying_groups") .select_related("product_type") .order_by( - F("product_type__priority").desc(nulls_last=True), + F("product_type__order").asc(nulls_last=True), "product_type", "name", ) 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..b55207fb 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,26 +290,26 @@ 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 diff --git a/counter/views/admin.py b/counter/views/admin.py index fbf466b3..c1f5c63b 100644 --- a/counter/views/admin.py +++ b/counter/views/admin.py @@ -109,7 +109,7 @@ 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 +119,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 +129,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/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..20ddf28a 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-17 13:09+0100\n" "PO-Revision-Date: 2016-07-18\n" "Last-Translator: Maréchal \n" @@ -18,8 +18,8 @@ msgstr "" #: 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 +#: com/models.py:293 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" @@ -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:74 com/models.py:259 com/models.py:299 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" @@ -128,18 +128,18 @@ 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 +#: 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" @@ -166,7 +166,7 @@ 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 +#: counter/models.py:764 msgid "label" msgstr "étiquette" @@ -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" @@ -1041,7 +1041,7 @@ 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 @@ -1053,8 +1053,8 @@ msgstr "nom d'utilisateur" 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:90 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" @@ -2501,7 +2501,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 @@ -3607,13 +3607,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" @@ -3637,180 +3637,184 @@ msgstr "Vidange de votre compte AE" 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 +3826,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" @@ -4194,15 +4198,15 @@ 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/producttype_list.jinja:26 msgid "Product type list" msgstr "Liste des types de produit" -#: counter/templates/counter/producttype_list.jinja:8 +#: counter/templates/counter/producttype_list.jinja:16 msgid "New product type" msgstr "Nouveau type de produit" -#: counter/templates/counter/producttype_list.jinja:17 +#: counter/templates/counter/producttype_list.jinja:42 msgid "There is no product types in this website." msgstr "Il n'y a pas de types de produit dans ce site web." From c79c251ba7657adf641a42dcf2a537bc626952a1 Mon Sep 17 00:00:00 2001 From: imperosol Date: Mon, 16 Dec 2024 19:46:34 +0100 Subject: [PATCH 05/17] Add `ProductTypeController` --- counter/api.py | 54 ++++++++++++++++-- counter/schemas.py | 32 ++++++++++- counter/tests/test_product_type.py | 91 ++++++++++++++++++++++++++++++ 3 files changed, 168 insertions(+), 9 deletions(-) create mode 100644 counter/tests/test_product_type.py diff --git a/counter/api.py b/counter/api.py index 8c37d0ac..43fada5a 100644 --- a/counter/api.py +++ b/counter/api.py @@ -14,22 +14,31 @@ # 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, IsInGroup, IsRoot -from counter.models import Counter, Product +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): @@ -80,11 +89,7 @@ class ProductController(ControllerBase): @route.get( "/search/detailed", response=PaginatedResponseSchema[ProductSchema], - permissions=[ - IsRoot - | IsInGroup(settings.SITH_GROUP_COUNTER_ADMIN_ID) - | IsInGroup(settings.SITH_GROUP_ACCOUNTING_ADMIN_ID) - ], + permissions=[IsCounterAdmin], url_name="search_products_detailed", ) @paginate(PageNumberPaginationExtra, page_size=50) @@ -100,3 +105,40 @@ class ProductController(ControllerBase): "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/schemas.py b/counter/schemas.py index 7ecb346b..1b770a3d 100644 --- a/counter/schemas.py +++ b/counter/schemas.py @@ -1,8 +1,9 @@ -from typing import Annotated +from typing import Annotated, Self from annotated_types import MinLen from django.urls import reverse -from ninja import Field, FilterSchema, ModelSchema +from ninja import Field, FilterSchema, ModelSchema, Schema +from pydantic import model_validator from club.schemas import ClubSchema from core.schemas import GroupSchema, SimpleUserSchema @@ -29,11 +30,36 @@ class SimplifiedCounterSchema(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:producttype_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 @@ -57,7 +83,7 @@ class ProductSchema(ModelSchema): buying_groups: list[GroupSchema] club: ClubSchema - product_type: ProductTypeSchema | None + product_type: SimpleProductTypeSchema | None url: str @staticmethod diff --git a/counter/tests/test_product_type.py b/counter/tests/test_product_type.py new file mode 100644 index 00000000..45ed5797 --- /dev/null +++ b/counter/tests/test_product_type.py @@ -0,0 +1,91 @@ +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 RealGroup, 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=[RealGroup.objects.get(pk=settings.SITH_GROUP_COUNTER_ADMIN_ID)], + ), + 200, + ), + ( + lambda: baker.make( + User, + groups=[ + RealGroup.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 From 47876e3971cfafc42cb7770c23b7f195bc3828e3 Mon Sep 17 00:00:00 2001 From: imperosol Date: Tue, 17 Dec 2024 00:53:47 +0100 Subject: [PATCH 06/17] Make product types dynamically orderable. --- core/static/bundled/alpine-index.js | 2 + core/static/core/forms.scss | 18 ++ core/static/core/style.scss | 11 + .../bundled/counter/product-type-index.ts | 64 ++++++ counter/static/counter/css/product_type.scss | 15 ++ .../templates/counter/producttype_list.jinja | 40 +++- locale/fr/LC_MESSAGES/django.po | 2 +- locale/fr/LC_MESSAGES/djangojs.po | 211 +++++++++--------- package-lock.json | 16 +- package.json | 3 +- 10 files changed, 269 insertions(+), 113 deletions(-) create mode 100644 counter/static/bundled/counter/product-type-index.ts create mode 100644 counter/static/counter/css/product_type.scss 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/core/forms.scss b/core/static/core/forms.scss index 7dab0484..e439bd8d 100644 --- a/core/static/core/forms.scss +++ b/core/static/core/forms.scss @@ -87,3 +87,21 @@ a:not(.button) { color: $primary-color; } } + + +form { + .row { + label { + margin: unset; + } + } + + fieldset { + margin-bottom: 1rem; + } + + .helptext { + margin-top: .25rem; + font-size: 80%; + } +} diff --git a/core/static/core/style.scss b/core/static/core/style.scss index cbe8d326..dd44fda0 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; 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/producttype_list.jinja b/counter/templates/counter/producttype_list.jinja index 0c4ff0c5..5d7ddc26 100644 --- a/counter/templates/counter/producttype_list.jinja +++ b/counter/templates/counter/producttype_list.jinja @@ -4,21 +4,49 @@ {% trans %}Product type list{% endtrans %} {% endblock %} +{% block additional_css %} + +{% endblock %} + +{% block additional_js %} + +{% endblock %} + {% block content %}

{% trans %}New product type{% endtrans %}

{% if producttype_list %} -

{% trans %}Product type list{% endtrans %}

-
    - {% for t in producttype_list %} -
  • {{ t }}
  • - {% endfor %} -
+
+

+

{% 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 %} +{% block script %} + +{% endblock %} diff --git a/locale/fr/LC_MESSAGES/django.po b/locale/fr/LC_MESSAGES/django.po index 20ddf28a..fdfef6b9 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 13:09+0100\n" +"POT-Creation-Date: 2024-12-17 13:10+0100\n" "PO-Revision-Date: 2016-07-18\n" "Last-Translator: Maréchal \n" diff --git a/locale/fr/LC_MESSAGES/djangojs.po b/locale/fr/LC_MESSAGES/djangojs.po index e907e571..c89f7eb1 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 successfully 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", From 8d643fc6b449dab32f8b0e6f1e61de149e99ba3d Mon Sep 17 00:00:00 2001 From: imperosol Date: Tue, 17 Dec 2024 17:23:13 +0100 Subject: [PATCH 07/17] Apply review comments --- counter/api.py | 2 +- .../templates/counter/producttype_list.jinja | 26 ++++++++++++------- counter/tests/test_product_type.py | 2 +- locale/fr/LC_MESSAGES/django.po | 20 +++++++++++--- locale/fr/LC_MESSAGES/djangojs.po | 4 +-- 5 files changed, 37 insertions(+), 17 deletions(-) diff --git a/counter/api.py b/counter/api.py index 43fada5a..dd7b75f0 100644 --- a/counter/api.py +++ b/counter/api.py @@ -109,7 +109,7 @@ class ProductController(ControllerBase): @api_controller("/product-type", permissions=[IsCounterAdmin]) class ProductTypeController(ControllerBase): - @route.get("", response=list[ProductTypeSchema], url_name="fetch-product-types") + @route.get("", response=list[ProductTypeSchema], url_name="fetch_product_types") def fetch_all(self): return ProductType.objects.order_by("order") diff --git a/counter/templates/counter/producttype_list.jinja b/counter/templates/counter/producttype_list.jinja index 5d7ddc26..042925df 100644 --- a/counter/templates/counter/producttype_list.jinja +++ b/counter/templates/counter/producttype_list.jinja @@ -13,8 +13,24 @@ {% endblock %} {% block content %} -

{% trans %}New product type{% endtrans %}

+

+ + {% trans %}New product type{% endtrans %} + + +

{% if producttype_list %} +

\n" @@ -4198,15 +4198,27 @@ 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:26 +#: counter/templates/counter/producttype_list.jinja:42 msgid "Product type list" msgstr "Liste des types de produit" -#: counter/templates/counter/producttype_list.jinja:16 +#: counter/templates/counter/producttype_list.jinja:18 msgid "New product type" msgstr "Nouveau type de produit" -#: counter/templates/counter/producttype_list.jinja:42 +#: counter/templates/counter/producttype_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/producttype_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/producttype_list.jinja:58 msgid "There is no product types in this website." msgstr "Il n'y a pas de types de produit dans ce site web." diff --git a/locale/fr/LC_MESSAGES/djangojs.po b/locale/fr/LC_MESSAGES/djangojs.po index c89f7eb1..414bb603 100644 --- a/locale/fr/LC_MESSAGES/djangojs.po +++ b/locale/fr/LC_MESSAGES/djangojs.po @@ -123,8 +123,8 @@ msgid "captured.%s" msgstr "capture.%s" #: counter/static/bundled/counter/product-type-index.ts:36 -msgid "Products types successfully reordered" -msgstr "Types de produits réordonnés." +msgid "Products types reordered!" +msgstr "Types de produits réordonnés !" #: counter/static/bundled/counter/product-type-index.ts:40 #, javascript-format From be6a077c8e57b0146a053b26f2ba4af0b1d2e2f8 Mon Sep 17 00:00:00 2001 From: imperosol Date: Wed, 18 Dec 2024 14:13:39 +0100 Subject: [PATCH 08/17] fix access to the subscription page --- core/models.py | 6 ++---- subscription/tests/test_new_susbcription.py | 15 +++++++++++---- 2 files changed, 13 insertions(+), 8 deletions(-) diff --git a/core/models.py b/core/models.py index 7a9d3a46..2d578d6d 100644 --- a/core/models.py +++ b/core/models.py @@ -530,10 +530,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() 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 From 5da27bb26657a1012822579373beaea4065b82c4 Mon Sep 17 00:00:00 2001 From: imperosol Date: Wed, 18 Dec 2024 12:16:24 +0100 Subject: [PATCH 09/17] rename `producttype` to `product_type` --- core/templates/core/user_tools.jinja | 2 +- counter/models.py | 2 +- counter/schemas.py | 2 +- ...cttype_list.jinja => product_type_list.jinja} | 16 ++++++++++------ counter/urls.py | 12 ++++++------ counter/views/admin.py | 3 ++- counter/views/mixins.py | 2 +- locale/fr/LC_MESSAGES/django.po | 14 +++++++------- 8 files changed, 29 insertions(+), 24 deletions(-) rename counter/templates/counter/{producttype_list.jinja => product_type_list.jinja} (77%) 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/counter/models.py b/counter/models.py index b55207fb..48bb841f 100644 --- a/counter/models.py +++ b/counter/models.py @@ -315,7 +315,7 @@ class ProductType(OrderedModel): 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 1b770a3d..adc8094b 100644 --- a/counter/schemas.py +++ b/counter/schemas.py @@ -38,7 +38,7 @@ class ProductTypeSchema(ModelSchema): @staticmethod def resolve_url(obj: ProductType) -> str: - return reverse("counter:producttype_edit", kwargs={"type_id": obj.id}) + return reverse("counter:product_type_edit", kwargs={"type_id": obj.id}) class SimpleProductTypeSchema(ModelSchema): diff --git a/counter/templates/counter/producttype_list.jinja b/counter/templates/counter/product_type_list.jinja similarity index 77% rename from counter/templates/counter/producttype_list.jinja rename to counter/templates/counter/product_type_list.jinja index 042925df..68548829 100644 --- a/counter/templates/counter/producttype_list.jinja +++ b/counter/templates/counter/product_type_list.jinja @@ -14,12 +14,12 @@ {% block content %}

    - + {% trans %}New product type{% endtrans %}

    - {% if producttype_list %} + {% if product_types %}
    {% else %} - {% trans %}There is no product types in this website.{% endtrans %} +

    + {% trans %}There are no product types in this website.{% endtrans %} +

    {% endif %} {% endblock %} 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 c1f5c63b..aa7e2c50 100644 --- a/counter/views/admin.py +++ b/counter/views/admin.py @@ -101,8 +101,9 @@ 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): 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/locale/fr/LC_MESSAGES/django.po b/locale/fr/LC_MESSAGES/django.po index 98b4fbd7..8ef1ade5 100644 --- a/locale/fr/LC_MESSAGES/django.po +++ b/locale/fr/LC_MESSAGES/django.po @@ -4197,20 +4197,20 @@ 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:42 +#: 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:18 +#: counter/templates/counter/product_type_list.jinja:18 msgid "New product type" msgstr "Nouveau type de produit" -#: counter/templates/counter/producttype_list.jinja:25 +#: 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/producttype_list.jinja:28 +#: 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." @@ -4218,8 +4218,8 @@ msgstr "" "Vous pouvez les réorganiser ici. Les changements seront alors immédiatement " "appliqués globalement." -#: counter/templates/counter/producttype_list.jinja:58 -msgid "There is no product types in this website." +#: counter/templates/counter/product_type_list.jinja:58 +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 From 17e4c63737eb3b7d52bcf4e52417dcd955cd997c Mon Sep 17 00:00:00 2001 From: imperosol Date: Mon, 16 Dec 2024 16:32:07 +0100 Subject: [PATCH 10/17] refactor news model and creation form --- ...r_news_club_alter_news_content_and_more.py | 56 ++++++ com/models.py | 25 ++- com/templates/com/news_edit.jinja | 96 ++++++--- com/views.py | 6 +- core/static/core/forms.scss | 33 +++- core/static/core/style.scss | 15 -- locale/fr/LC_MESSAGES/django.po | 185 ++++++++++-------- 7 files changed, 276 insertions(+), 140 deletions(-) create mode 100644 com/migrations/0007_alter_news_club_alter_news_content_and_more.py 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..cf2cb961 100644 --- a/com/models.py +++ b/com/models.py @@ -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): 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/views.py b/com/views.py index 69ee7221..c3835605 100644 --- a/com/views.py +++ b/com/views.py @@ -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": diff --git a/core/static/core/forms.scss b/core/static/core/forms.scss index e439bd8d..42a4d719 100644 --- a/core/static/core/forms.scss +++ b/core/static/core/forms.scss @@ -88,20 +88,37 @@ a:not(.button) { } } - form { - .row { - label { - margin: unset; - } + margin: 0 auto 10px; + + .helptext { + margin-top: .25rem; + margin-bottom: .25rem; + font-size: 80%; } fieldset { margin-bottom: 1rem; } - .helptext { - margin-top: .25rem; - font-size: 80%; + .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 dd44fda0..d7a396d1 100644 --- a/core/static/core/style.scss +++ b/core/static/core/style.scss @@ -1412,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/locale/fr/LC_MESSAGES/django.po b/locale/fr/LC_MESSAGES/django.po index 8ef1ade5..ae6ed43a 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 17:04+0100\n" +"POT-Creation-Date: 2024-12-18 15:53+0100\n" "PO-Revision-Date: 2016-07-18\n" "Last-Translator: Maréchal \n" @@ -17,8 +17,8 @@ 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:299 counter/models.py:330 +#: accounting/models.py:190 club/models.py:55 com/models.py:289 +#: com/models.py:308 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" @@ -65,7 +65,7 @@ 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:359 +#: com/models.py:87 com/models.py:274 com/models.py:314 counter/models.py:359 #: counter/models.py:483 trombi/models.py:209 msgid "club" msgstr "club" @@ -126,8 +126,8 @@ 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 +#: accounting/models.py:256 core/models.py:954 core/models.py:1465 +#: core/models.py:1510 core/models.py:1539 core/models.py:1563 #: 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 @@ -165,7 +165,7 @@ msgid "accounting type" msgstr "type comptable" #: accounting/models.py:294 accounting/models.py:429 accounting/models.py:460 -#: accounting/models.py:492 core/models.py:1540 core/models.py:1566 +#: accounting/models.py:492 core/models.py:1538 core/models.py:1564 #: counter/models.py:764 msgid "label" msgstr "étiquette" @@ -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 @@ -1068,11 +1068,11 @@ 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:324 core/models.py:955 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:328 msgid "moderator" msgstr "modérateur" @@ -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:189 com/models.py:263 #: 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:264 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:1508 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:268 pedagogy/models.py:57 #: pedagogy/models.py:200 trombi/models.py:178 msgid "author" msgstr "auteur" -#: com/models.py:153 +#: com/models.py:168 msgid "news_date" msgstr "date de la nouvelle" -#: com/models.py:156 +#: com/models.py:171 msgid "start_date" msgstr "date de début" -#: com/models.py:157 +#: com/models.py:172 msgid "end_date" msgstr "date de fin" -#: com/models.py:175 +#: com/models.py:190 msgid "intro" msgstr "intro" -#: com/models.py:176 +#: com/models.py:191 msgid "joke" msgstr "blague" -#: com/models.py:177 +#: com/models.py:192 msgid "protip" msgstr "astuce" -#: com/models.py:178 +#: com/models.py:193 msgid "conclusion" msgstr "conclusion" -#: com/models.py:179 +#: com/models.py:194 msgid "sent" msgstr "envoyé" -#: com/models.py:244 +#: com/models.py:259 msgid "weekmail" msgstr "weekmail" -#: com/models.py:262 +#: com/models.py:277 msgid "rank" msgstr "rang" -#: com/models.py:295 core/models.py:922 core/models.py:972 +#: com/models.py:310 core/models.py:920 core/models.py:970 msgid "file" msgstr "fichier" -#: com/models.py:307 +#: com/models.py:322 msgid "display time" msgstr "temps d'affichage" -#: com/models.py:338 +#: com/models.py:353 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" @@ -1952,23 +1968,23 @@ 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:449 msgid "Delete and save to regenerate" msgstr "Supprimer et sauver pour régénérer" -#: com/views.py:466 +#: com/views.py:464 msgid "Weekmail of the " msgstr "Weekmail du " -#: com/views.py:570 +#: com/views.py:568 msgid "" "You must be a board member of the selected club to post in the Weekmail." msgstr "" @@ -2202,11 +2218,11 @@ msgstr "adresse des parents" msgid "is subscriber viewable" msgstr "profil visible par les cotisants" -#: core/models.py:594 +#: core/models.py:592 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:759 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 +2242,101 @@ msgstr "Un utilisateur de ce nom d'utilisateur existe déjà" msgid "Profile" msgstr "Profil" -#: core/models.py:872 +#: core/models.py:870 msgid "Visitor" msgstr "Visiteur" -#: core/models.py:879 +#: core/models.py:877 msgid "receive the Weekmail" msgstr "recevoir le Weekmail" -#: core/models.py:880 +#: core/models.py:878 msgid "show your stats to others" msgstr "montrez vos statistiques aux autres" -#: core/models.py:882 +#: core/models.py:880 msgid "get a notification for every click" msgstr "avoir une notification pour chaque click" -#: core/models.py:885 +#: core/models.py:883 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:909 sas/forms.py:81 msgid "file name" msgstr "nom du fichier" -#: core/models.py:915 core/models.py:1268 +#: core/models.py:913 core/models.py:1266 msgid "parent" msgstr "parent" -#: core/models.py:929 +#: core/models.py:927 msgid "compressed file" msgstr "version allégée" -#: core/models.py:936 +#: core/models.py:934 msgid "thumbnail" msgstr "miniature" -#: core/models.py:944 core/models.py:961 +#: core/models.py:942 core/models.py:959 msgid "owner" msgstr "propriétaire" -#: core/models.py:948 core/models.py:1285 +#: core/models.py:946 core/models.py:1283 msgid "edit group" msgstr "groupe d'édition" -#: core/models.py:951 core/models.py:1288 +#: core/models.py:949 core/models.py:1286 msgid "view group" msgstr "groupe de vue" -#: core/models.py:953 +#: core/models.py:951 msgid "is folder" msgstr "est un dossier" -#: core/models.py:954 +#: core/models.py:952 msgid "mime type" msgstr "type mime" -#: core/models.py:955 +#: core/models.py:953 msgid "size" msgstr "taille" -#: core/models.py:966 +#: core/models.py:964 msgid "asked for removal" msgstr "retrait demandé" -#: core/models.py:968 +#: core/models.py:966 msgid "is in the SAS" msgstr "est dans le SAS" -#: core/models.py:1037 +#: core/models.py:1035 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:1037 core/models.py:1041 msgid "Loop in folder tree" msgstr "Boucle dans l'arborescence des dossiers" -#: core/models.py:1046 +#: core/models.py:1044 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:1055 msgid "Duplicate file" msgstr "Un fichier de ce nom existe déjà" -#: core/models.py:1074 +#: core/models.py:1072 msgid "You must provide a file" msgstr "Vous devez fournir un fichier" -#: core/models.py:1251 +#: core/models.py:1249 msgid "page unix name" msgstr "nom unix de la page" -#: core/models.py:1257 +#: core/models.py:1255 msgid "" "Enter a valid page name. This value may contain only unaccented letters, " "numbers and ./+/-/_ characters." @@ -2328,55 +2344,55 @@ msgstr "" "Entrez un nom de page correct. Uniquement des lettres non accentuées, " "numéros, et ./+/-/_" -#: core/models.py:1275 +#: core/models.py:1273 msgid "page name" msgstr "nom de la page" -#: core/models.py:1280 +#: core/models.py:1278 msgid "owner group" msgstr "groupe propriétaire" -#: core/models.py:1293 +#: core/models.py:1291 msgid "lock user" msgstr "utilisateur bloquant" -#: core/models.py:1300 +#: core/models.py:1298 msgid "lock_timeout" msgstr "décompte du déblocage" -#: core/models.py:1350 +#: core/models.py:1348 msgid "Duplicate page" msgstr "Une page de ce nom existe déjà" -#: core/models.py:1353 +#: core/models.py:1351 msgid "Loop in page tree" msgstr "Boucle dans l'arborescence des pages" -#: core/models.py:1464 +#: core/models.py:1462 msgid "revision" msgstr "révision" -#: core/models.py:1465 +#: core/models.py:1463 msgid "page title" msgstr "titre de la page" -#: core/models.py:1466 +#: core/models.py:1464 msgid "page content" msgstr "contenu de la page" -#: core/models.py:1507 +#: core/models.py:1505 msgid "url" msgstr "url" -#: core/models.py:1508 +#: core/models.py:1506 msgid "param" msgstr "param" -#: core/models.py:1513 +#: core/models.py:1511 msgid "viewed" msgstr "vue" -#: core/models.py:1571 +#: core/models.py:1569 msgid "operation type" msgstr "type d'opération" @@ -4208,7 +4224,8 @@ msgstr "Nouveau type de produit" #: 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." +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 "" @@ -4218,7 +4235,7 @@ msgstr "" "Vous pouvez les réorganiser ici. Les changements seront alors immédiatement " "appliqués globalement." -#: counter/templates/counter/product_type_list.jinja:58 +#: 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." From 6ca641ab7fa1e0f788440c1b421931cb6c2cbd2f Mon Sep 17 00:00:00 2001 From: imperosol Date: Thu, 19 Dec 2024 10:32:02 +0100 Subject: [PATCH 11/17] fix: N+1 queries on page version list page --- core/templates/core/macros_pages.jinja | 23 ++++++++++++----------- 1 file changed, 12 insertions(+), 11 deletions(-) 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 %} From 8c660e9856f737e908a087ef34eb7fbc4b454cc5 Mon Sep 17 00:00:00 2001 From: imperosol Date: Wed, 20 Nov 2024 17:10:57 +0100 Subject: [PATCH 12/17] Make `core.User` inherit from `AbstractUser` instead of `AbstractBaseUser` --- accounting/tests.py | 13 ++- accounting/views.py | 20 ++--- club/models.py | 27 +++--- com/models.py | 26 +++--- com/tests.py | 6 +- com/views.py | 52 ++++++----- core/management/commands/populate.py | 10 +-- core/management/commands/populate_more.py | 6 +- ..._options_user_user_permissions_and_more.py | 82 ++++++++++++++++++ core/models.py | 86 ++++--------------- core/tests/test_core.py | 4 +- core/tests/test_files.py | 8 +- core/views/files.py | 26 +++--- core/views/forms.py | 4 +- core/views/group.py | 4 +- pedagogy/tests/test_api.py | 6 +- pedagogy/views.py | 27 +++--- rootplace/tests.py | 8 +- sas/tests/test_api.py | 6 +- sas/tests/test_views.py | 6 +- 20 files changed, 215 insertions(+), 212 deletions(-) create mode 100644 core/migrations/0040_alter_user_options_user_user_permissions_and_more.py 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/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/models.py b/com/models.py index cf2cb961..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): @@ -108,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}) @@ -336,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/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 c3835605..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, @@ -278,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) @@ -323,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 2d578d6d..48355b67 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,12 +385,6 @@ 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: return self.subscriptions.exists() @@ -599,11 +556,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: @@ -982,13 +934,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/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..ea9c27f0 100644 --- a/core/views/forms.py +++ b/core/views/forms.py @@ -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): 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/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() From 7e9071a533dffdbb6e8303c141d38f1853f5727a Mon Sep 17 00:00:00 2001 From: imperosol Date: Wed, 20 Nov 2024 17:25:39 +0100 Subject: [PATCH 13/17] optimize `User.is_subscribed` and `User.was_subscribed` --- core/models.py | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/core/models.py b/core/models.py index 48355b67..347e9bd4 100644 --- a/core/models.py +++ b/core/models.py @@ -387,14 +387,21 @@ class User(AbstractUser): @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): From 871ef60cf6f21e16ee8affbbc3d256f856e317c6 Mon Sep 17 00:00:00 2001 From: imperosol Date: Wed, 20 Nov 2024 18:35:55 +0100 Subject: [PATCH 14/17] remove obsolete RunPython operations --- club/migrations/0010_auto_20170912_2028.py | 20 ----------- .../0013_customer_recorded_products.py | 33 ------------------- .../migrations/0002_auto_20190827_2251.py | 23 +++---------- 3 files changed, 4 insertions(+), 72 deletions(-) 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/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/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 = [] From a7b1406e06c0559454a2e6393c048a07d6f4f9a5 Mon Sep 17 00:00:00 2001 From: imperosol Date: Thu, 19 Dec 2024 10:53:11 +0100 Subject: [PATCH 15/17] post-rebase fix --- counter/tests/test_product.py | 8 +- counter/tests/test_product_type.py | 8 +- locale/fr/LC_MESSAGES/django.po | 402 +++++++++++++---------------- 3 files changed, 184 insertions(+), 234 deletions(-) diff --git a/counter/tests/test_product.py b/counter/tests/test_product.py index a5eb39c4..8daab2b1 100644 --- a/counter/tests/test_product.py +++ b/counter/tests/test_product.py @@ -13,7 +13,7 @@ from PIL import Image from pytest_django.asserts import assertNumQueries from core.baker_recipes import board_user, subscriber_user -from core.models import RealGroup, User +from core.models import Group, User from counter.models import Product, ProductType @@ -51,16 +51,14 @@ def test_resize_product_icon(model): ( lambda: baker.make( User, - groups=[RealGroup.objects.get(pk=settings.SITH_GROUP_COUNTER_ADMIN_ID)], + groups=[Group.objects.get(pk=settings.SITH_GROUP_COUNTER_ADMIN_ID)], ), 200, ), ( lambda: baker.make( User, - groups=[ - RealGroup.objects.get(pk=settings.SITH_GROUP_ACCOUNTING_ADMIN_ID) - ], + groups=[Group.objects.get(pk=settings.SITH_GROUP_ACCOUNTING_ADMIN_ID)], ), 200, ), diff --git a/counter/tests/test_product_type.py b/counter/tests/test_product_type.py index 7976ef85..16dc40dd 100644 --- a/counter/tests/test_product_type.py +++ b/counter/tests/test_product_type.py @@ -6,7 +6,7 @@ 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 RealGroup, User +from core.models import Group, User from counter.api import ProductTypeController from counter.models import ProductType @@ -70,16 +70,14 @@ def test_move_above_product_type(product_types: list[ProductType]): ( lambda: baker.make( User, - groups=[RealGroup.objects.get(pk=settings.SITH_GROUP_COUNTER_ADMIN_ID)], + groups=[Group.objects.get(pk=settings.SITH_GROUP_COUNTER_ADMIN_ID)], ), 200, ), ( lambda: baker.make( User, - groups=[ - RealGroup.objects.get(pk=settings.SITH_GROUP_ACCOUNTING_ADMIN_ID) - ], + groups=[Group.objects.get(pk=settings.SITH_GROUP_ACCOUNTING_ADMIN_ID)], ), 200, ), diff --git a/locale/fr/LC_MESSAGES/django.po b/locale/fr/LC_MESSAGES/django.po index ae6ed43a..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-18 15: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,8 +17,8 @@ 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:289 -#: com/models.py:308 counter/models.py:299 counter/models.py:330 +#: 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" @@ -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,7 +65,7 @@ msgid "account number" msgstr "numéro de compte" #: accounting/models.py:107 accounting/models.py:136 club/models.py:345 -#: com/models.py:87 com/models.py:274 com/models.py:314 counter/models.py:359 +#: 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" @@ -126,8 +126,8 @@ msgstr "numéro" msgid "journal" msgstr "classeur" -#: accounting/models.py:256 core/models.py:954 core/models.py:1465 -#: core/models.py:1510 core/models.py:1539 core/models.py:1563 +#: 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 @@ -165,7 +165,7 @@ msgid "accounting type" msgstr "type comptable" #: accounting/models.py:294 accounting/models.py:429 accounting/models.py:460 -#: accounting/models.py:492 core/models.py:1538 core/models.py:1564 +#: 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" @@ -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" @@ -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" @@ -1046,20 +1046,20 @@ msgstr "Un club avec ce nom UNIX existe déjà." #: 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:300 +#: 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:97 com/models.py:324 core/models.py:955 +#: 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:101 com/models.py:328 +#: 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,7 +1426,7 @@ msgstr "Hebdomadaire" msgid "Call" msgstr "Appel" -#: com/models.py:67 com/models.py:189 com/models.py:263 +#: 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 @@ -1445,7 +1445,7 @@ 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:264 trombi/models.py:188 +#: com/models.py:76 com/models.py:262 trombi/models.py:188 msgid "content" msgstr "contenu" @@ -1453,7 +1453,7 @@ msgstr "contenu" 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:1508 launderette/models.py:88 +#: com/models.py:82 core/models.py:1465 launderette/models.py:88 #: launderette/models.py:124 launderette/models.py:167 msgid "type" msgstr "type" @@ -1462,60 +1462,60 @@ msgstr "type" msgid "The club which organizes the event." msgstr "Le club qui organise l'évènement." -#: com/models.py:94 com/models.py:268 pedagogy/models.py:57 +#: 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:168 +#: com/models.py:166 msgid "news_date" msgstr "date de la nouvelle" -#: com/models.py:171 +#: com/models.py:169 msgid "start_date" msgstr "date de début" -#: com/models.py:172 +#: com/models.py:170 msgid "end_date" msgstr "date de fin" -#: com/models.py:190 +#: com/models.py:188 msgid "intro" msgstr "intro" -#: com/models.py:191 +#: com/models.py:189 msgid "joke" msgstr "blague" -#: com/models.py:192 +#: com/models.py:190 msgid "protip" msgstr "astuce" -#: com/models.py:193 +#: com/models.py:191 msgid "conclusion" msgstr "conclusion" -#: com/models.py:194 +#: com/models.py:192 msgid "sent" msgstr "envoyé" -#: com/models.py:259 +#: com/models.py:257 msgid "weekmail" msgstr "weekmail" -#: com/models.py:277 +#: com/models.py:275 msgid "rank" msgstr "rang" -#: com/models.py:310 core/models.py:920 core/models.py:970 +#: com/models.py:308 core/models.py:879 core/models.py:929 msgid "file" msgstr "fichier" -#: com/models.py:322 +#: com/models.py:320 msgid "display time" msgstr "temps d'affichage" -#: com/models.py:353 +#: com/models.py:349 msgid "Begin date should be before end date" msgstr "La date de début doit être avant celle de fin" @@ -1766,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" @@ -1976,253 +1976,211 @@ msgstr "Ce champ est obligatoire." msgid "An event cannot end before its beginning." msgstr "Un évènement ne peut pas se finir avant d'avoir commencé." -#: com/views.py:449 +#: com/views.py:445 msgid "Delete and save to regenerate" msgstr "Supprimer et sauver pour régénérer" -#: com/views.py:464 +#: com/views.py:460 msgid "Weekmail of the " msgstr "Weekmail du " -#: com/views.py:568 +#: 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:592 +#: 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:759 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 @@ -2242,101 +2200,101 @@ msgstr "Un utilisateur de ce nom d'utilisateur existe déjà" msgid "Profile" msgstr "Profil" -#: core/models.py:870 +#: core/models.py:829 msgid "Visitor" msgstr "Visiteur" -#: core/models.py:877 +#: core/models.py:836 msgid "receive the Weekmail" msgstr "recevoir le Weekmail" -#: core/models.py:878 +#: core/models.py:837 msgid "show your stats to others" msgstr "montrez vos statistiques aux autres" -#: core/models.py:880 +#: core/models.py:839 msgid "get a notification for every click" msgstr "avoir une notification pour chaque click" -#: core/models.py:883 +#: core/models.py:842 msgid "get a notification for every refilling" msgstr "avoir une notification pour chaque rechargement" -#: core/models.py:909 sas/forms.py:81 +#: core/models.py:868 sas/forms.py:81 msgid "file name" msgstr "nom du fichier" -#: core/models.py:913 core/models.py:1266 +#: core/models.py:872 core/models.py:1223 msgid "parent" msgstr "parent" -#: core/models.py:927 +#: core/models.py:886 msgid "compressed file" msgstr "version allégée" -#: core/models.py:934 +#: core/models.py:893 msgid "thumbnail" msgstr "miniature" -#: core/models.py:942 core/models.py:959 +#: core/models.py:901 core/models.py:918 msgid "owner" msgstr "propriétaire" -#: core/models.py:946 core/models.py:1283 +#: core/models.py:905 core/models.py:1240 msgid "edit group" msgstr "groupe d'édition" -#: core/models.py:949 core/models.py:1286 +#: core/models.py:908 core/models.py:1243 msgid "view group" msgstr "groupe de vue" -#: core/models.py:951 +#: core/models.py:910 msgid "is folder" msgstr "est un dossier" -#: core/models.py:952 +#: core/models.py:911 msgid "mime type" msgstr "type mime" -#: core/models.py:953 +#: core/models.py:912 msgid "size" msgstr "taille" -#: core/models.py:964 +#: core/models.py:923 msgid "asked for removal" msgstr "retrait demandé" -#: core/models.py:966 +#: core/models.py:925 msgid "is in the SAS" msgstr "est dans le SAS" -#: core/models.py:1035 +#: 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:1037 core/models.py:1041 +#: core/models.py:994 core/models.py:998 msgid "Loop in folder tree" msgstr "Boucle dans l'arborescence des dossiers" -#: core/models.py:1044 +#: 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:1055 +#: core/models.py:1012 msgid "Duplicate file" msgstr "Un fichier de ce nom existe déjà" -#: core/models.py:1072 +#: core/models.py:1029 msgid "You must provide a file" msgstr "Vous devez fournir un fichier" -#: core/models.py:1249 +#: core/models.py:1206 msgid "page unix name" msgstr "nom unix de la page" -#: core/models.py:1255 +#: core/models.py:1212 msgid "" "Enter a valid page name. This value may contain only unaccented letters, " "numbers and ./+/-/_ characters." @@ -2344,55 +2302,55 @@ msgstr "" "Entrez un nom de page correct. Uniquement des lettres non accentuées, " "numéros, et ./+/-/_" -#: core/models.py:1273 +#: core/models.py:1230 msgid "page name" msgstr "nom de la page" -#: core/models.py:1278 +#: core/models.py:1235 msgid "owner group" msgstr "groupe propriétaire" -#: core/models.py:1291 +#: core/models.py:1248 msgid "lock user" msgstr "utilisateur bloquant" -#: core/models.py:1298 +#: core/models.py:1255 msgid "lock_timeout" msgstr "décompte du déblocage" -#: core/models.py:1348 +#: core/models.py:1305 msgid "Duplicate page" msgstr "Une page de ce nom existe déjà" -#: core/models.py:1351 +#: core/models.py:1308 msgid "Loop in page tree" msgstr "Boucle dans l'arborescence des pages" -#: core/models.py:1462 +#: core/models.py:1419 msgid "revision" msgstr "révision" -#: core/models.py:1463 +#: core/models.py:1420 msgid "page title" msgstr "titre de la page" -#: core/models.py:1464 +#: core/models.py:1421 msgid "page content" msgstr "contenu de la page" -#: core/models.py:1505 +#: core/models.py:1462 msgid "url" msgstr "url" -#: core/models.py:1506 +#: core/models.py:1463 msgid "param" msgstr "param" -#: core/models.py:1511 +#: core/models.py:1468 msgid "viewed" msgstr "vue" -#: core/models.py:1569 +#: core/models.py:1526 msgid "operation type" msgstr "type d'opération" @@ -2543,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" @@ -3508,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" @@ -3539,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)" @@ -3547,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" @@ -3649,10 +3607,6 @@ 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:92 msgid "account id" msgstr "numéro de compte" From 87b619794d29c075573e5af77f198f4ce3885aba Mon Sep 17 00:00:00 2001 From: Sli Date: Thu, 19 Dec 2024 18:57:50 +0100 Subject: [PATCH 16/17] Fix groups displayed on user profile group edition --- core/views/forms.py | 11 +++++++---- core/views/user.py | 6 ++---- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/core/views/forms.py b/core/views/forms.py index ea9c27f0..88963789 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, @@ -285,15 +285,18 @@ class UserProfileForm(forms.ModelForm): self._post_clean() -class UserPropForm(forms.ModelForm): +class UserRealGroupForm(forms.ModelForm): error_css_class = "error" required_css_class = "required" + groups = forms.ModelChoiceField( + RealGroup.objects.all(), + widget=CheckboxSelectMultiple, + ) + 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/user.py b/core/views/user.py index 9f724fca..7b6c146b 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 @@ -69,6 +68,7 @@ from core.views.forms import ( RegisteringForm, UserGodfathersForm, UserProfileForm, + UserRealGroupForm, ) from counter.models import Refilling, Selling from counter.views.student_card import StudentCardFormView @@ -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 = UserRealGroupForm context_object_name = "profile" current_tab = "groups" From 9f3a10ca71391e0d93c271e8e45011dafc02acca Mon Sep 17 00:00:00 2001 From: imperosol Date: Fri, 20 Dec 2024 11:00:57 +0100 Subject: [PATCH 17/17] fix user groups form --- core/views/forms.py | 7 ++++--- core/views/user.py | 4 ++-- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/core/views/forms.py b/core/views/forms.py index 88963789..8a998ab0 100644 --- a/core/views/forms.py +++ b/core/views/forms.py @@ -285,13 +285,14 @@ class UserProfileForm(forms.ModelForm): self._post_clean() -class UserRealGroupForm(forms.ModelForm): +class UserGroupsForm(forms.ModelForm): error_css_class = "error" required_css_class = "required" - groups = forms.ModelChoiceField( - RealGroup.objects.all(), + groups = forms.ModelMultipleChoiceField( + queryset=RealGroup.objects.all(), widget=CheckboxSelectMultiple, + label=_("Groups"), ) class Meta: diff --git a/core/views/user.py b/core/views/user.py index 7b6c146b..264a8dd6 100644 --- a/core/views/user.py +++ b/core/views/user.py @@ -67,8 +67,8 @@ from core.views.forms import ( LoginForm, RegisteringForm, UserGodfathersForm, + UserGroupsForm, UserProfileForm, - UserRealGroupForm, ) from counter.models import Refilling, Selling from counter.views.student_card import StudentCardFormView @@ -583,7 +583,7 @@ class UserUpdateGroupView(UserTabsMixin, CanEditPropMixin, UpdateView): model = User pk_url_kwarg = "user_id" template_name = "core/user_group.jinja" - form_class = UserRealGroupForm + form_class = UserGroupsForm context_object_name = "profile" current_tab = "groups"