diff --git a/.github/actions/setup_project/action.yml b/.github/actions/setup_project/action.yml index afd2ddb2..951aba32 100644 --- a/.github/actions/setup_project/action.yml +++ b/.github/actions/setup_project/action.yml @@ -6,15 +6,9 @@ runs: - name: Install apt packages uses: awalsh128/cache-apt-pkgs-action@latest with: - packages: gettext + packages: gettext pipx version: 1.0 # increment to reset cache - - name: Install dependencies - run: | - sudo apt update - sudo apt install gettext - shell: bash - - name: Set up python uses: actions/setup-python@v5 with: @@ -30,7 +24,7 @@ runs: - name: Install Poetry if: steps.cached-poetry.outputs.cache-hit != 'true' shell: bash - run: curl -sSL https://install.python-poetry.org | python3 - + run: pipx install poetry - name: Check pyproject.toml syntax shell: bash diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 514d2a06..141f8e53 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -14,7 +14,7 @@ jobs: steps: - name: SSH Remote Commands - uses: appleboy/ssh-action@dce9d565de8d876c11d93fa4fe677c0285a66d78 + uses: appleboy/ssh-action@v1.1.0 with: # Proxy proxy_host : ${{secrets.PROXY_HOST}} @@ -33,8 +33,7 @@ jobs: # See https://github.com/ae-utbm/sith/wiki/GitHub-Actions#deployment-action script: | - export PATH="/home/sith/.local/bin:$PATH" - pushd ${{secrets.SITH_PATH}} + cd ${{secrets.SITH_PATH}} git fetch git reset --hard origin/master @@ -42,7 +41,7 @@ jobs: npm install poetry run ./manage.py install_xapian poetry run ./manage.py migrate - poetry run ./manage.py collectstatic --clear --clear-generated --noinput + poetry run ./manage.py collectstatic --clear --noinput poetry run ./manage.py compilemessages sudo systemctl restart uwsgi diff --git a/.github/workflows/taiste.yml b/.github/workflows/taiste.yml index c6eadafc..a032924b 100644 --- a/.github/workflows/taiste.yml +++ b/.github/workflows/taiste.yml @@ -13,7 +13,7 @@ jobs: steps: - name: SSH Remote Commands - uses: appleboy/ssh-action@dce9d565de8d876c11d93fa4fe677c0285a66d78 + uses: appleboy/ssh-action@v1.1.0 with: # Proxy proxy_host : ${{secrets.PROXY_HOST}} @@ -32,8 +32,7 @@ jobs: # See https://github.com/ae-utbm/sith/wiki/GitHub-Actions#deployment-action script: | - export PATH="$HOME/.poetry/bin:$PATH" - pushd ${{secrets.SITH_PATH}} + cd ${{secrets.SITH_PATH}} git fetch git reset --hard origin/taiste @@ -41,7 +40,7 @@ jobs: npm install poetry run ./manage.py install_xapian poetry run ./manage.py migrate - poetry run ./manage.py collectstatic --clear --clear-generated --noinput + poetry run ./manage.py collectstatic --clear --noinput poetry run ./manage.py compilemessages sudo systemctl restart uwsgi diff --git a/accounting/api.py b/accounting/api.py new file mode 100644 index 00000000..a16fb7ab --- /dev/null +++ b/accounting/api.py @@ -0,0 +1,23 @@ +from typing import Annotated + +from annotated_types import MinLen +from ninja_extra import ControllerBase, api_controller, paginate, route +from ninja_extra.pagination import PageNumberPaginationExtra +from ninja_extra.schemas import PaginatedResponseSchema + +from accounting.models import ClubAccount, Company +from accounting.schemas import ClubAccountSchema, CompanySchema +from core.api_permissions import CanAccessLookup + + +@api_controller("/lookup", permissions=[CanAccessLookup]) +class AccountingController(ControllerBase): + @route.get("/club-account", response=PaginatedResponseSchema[ClubAccountSchema]) + @paginate(PageNumberPaginationExtra, page_size=50) + def search_club_account(self, search: Annotated[str, MinLen(1)]): + return ClubAccount.objects.filter(name__icontains=search).values() + + @route.get("/company", response=PaginatedResponseSchema[CompanySchema]) + @paginate(PageNumberPaginationExtra, page_size=50) + def search_company(self, search: Annotated[str, MinLen(1)]): + return Company.objects.filter(name__icontains=search).values() diff --git a/accounting/schemas.py b/accounting/schemas.py new file mode 100644 index 00000000..3d9edbcc --- /dev/null +++ b/accounting/schemas.py @@ -0,0 +1,15 @@ +from ninja import ModelSchema + +from accounting.models import ClubAccount, Company + + +class ClubAccountSchema(ModelSchema): + class Meta: + model = ClubAccount + fields = ["id", "name"] + + +class CompanySchema(ModelSchema): + class Meta: + model = Company + fields = ["id", "name"] diff --git a/accounting/static/webpack/accounting/components/ajax-select-index.ts b/accounting/static/webpack/accounting/components/ajax-select-index.ts new file mode 100644 index 00000000..3fc93cf3 --- /dev/null +++ b/accounting/static/webpack/accounting/components/ajax-select-index.ts @@ -0,0 +1,60 @@ +import { AjaxSelect } from "#core:core/components/ajax-select-base"; +import { registerComponent } from "#core:utils/web-components"; +import type { TomOption } from "tom-select/dist/types/types"; +import type { escape_html } from "tom-select/dist/types/utils"; +import { + type ClubAccountSchema, + type CompanySchema, + accountingSearchClubAccount, + accountingSearchCompany, +} from "#openapi"; + +@registerComponent("club-account-ajax-select") +export class ClubAccountAjaxSelect extends AjaxSelect { + protected valueField = "id"; + protected labelField = "name"; + protected searchField = ["code", "name"]; + + protected async search(query: string): Promise { + const resp = await accountingSearchClubAccount({ query: { search: query } }); + if (resp.data) { + return resp.data.results; + } + return []; + } + + protected renderOption(item: ClubAccountSchema, sanitize: typeof escape_html) { + return `
+ ${sanitize(item.name)} +
`; + } + + protected renderItem(item: ClubAccountSchema, sanitize: typeof escape_html) { + return `${sanitize(item.name)}`; + } +} + +@registerComponent("company-ajax-select") +export class CompanyAjaxSelect extends AjaxSelect { + protected valueField = "id"; + protected labelField = "name"; + protected searchField = ["code", "name"]; + + protected async search(query: string): Promise { + const resp = await accountingSearchCompany({ query: { search: query } }); + if (resp.data) { + return resp.data.results; + } + return []; + } + + protected renderOption(item: CompanySchema, sanitize: typeof escape_html) { + return `
+ ${sanitize(item.name)} +
`; + } + + protected renderItem(item: CompanySchema, sanitize: typeof escape_html) { + return `${sanitize(item.name)}`; + } +} diff --git a/accounting/templates/accounting/operation_edit.jinja b/accounting/templates/accounting/operation_edit.jinja index e8c34364..4a75cb83 100644 --- a/accounting/templates/accounting/operation_edit.jinja +++ b/accounting/templates/accounting/operation_edit.jinja @@ -61,10 +61,10 @@ + @@ -301,8 +302,6 @@ {% endif %} {% block script %} - - - - + {% if widget.value %}{{ widget.value }}{% endif %} + + diff --git a/core/tests/test_core.py b/core/tests/test_core.py index b803cefa..05501136 100644 --- a/core/tests/test_core.py +++ b/core/tests/test_core.py @@ -146,39 +146,20 @@ class TestUserLogin: """Should not login a user correctly.""" response = client.post( reverse("core:login"), - { - "username": user.username, - "password": "wrong-password", - settings.HONEYPOT_FIELD_NAME: settings.HONEYPOT_VALUE, - }, + {"username": user.username, "password": "wrong-password"}, ) assert response.status_code == 200 assert ( '

Votre nom d\'utilisateur ' "et votre mot de passe ne correspondent pas. Merci de réessayer.

" ) in str(response.content.decode()) - - def test_login_honeypot(self, client, user): - response = client.post( - reverse("core:login"), - { - "username": user.username, - "password": "wrong-password", - settings.HONEYPOT_FIELD_NAME: settings.HONEYPOT_VALUE + "incorrect", - }, - ) - assert response.status_code == 200 assert response.wsgi_request.user.is_anonymous def test_login_success(self, client, user): """Should login a user correctly.""" response = client.post( reverse("core:login"), - { - "username": user.username, - "password": "plop", - settings.HONEYPOT_FIELD_NAME: settings.HONEYPOT_VALUE, - }, + {"username": user.username, "password": "plop"}, ) assertRedirects(response, reverse("core:index")) assert response.wsgi_request.user == user diff --git a/core/views/files.py b/core/views/files.py index 3df5c014..d70991b7 100644 --- a/core/views/files.py +++ b/core/views/files.py @@ -18,7 +18,6 @@ from urllib.parse import quote, urljoin # This file contains all the views that concern the page model from wsgiref.util import FileWrapper -from ajax_select import make_ajax_field from django import forms from django.conf import settings from django.core.exceptions import PermissionDenied @@ -39,6 +38,11 @@ from core.views import ( CanViewMixin, can_view, ) +from core.views.widgets.select import ( + AutoCompleteSelectMultipleGroup, + AutoCompleteSelectSithFile, + AutoCompleteSelectUser, +) from counter.utils import is_logged_in_counter @@ -217,14 +221,13 @@ class FileEditPropForm(forms.ModelForm): class Meta: model = SithFile fields = ["parent", "owner", "edit_groups", "view_groups"] + widgets = { + "parent": AutoCompleteSelectSithFile, + "owner": AutoCompleteSelectUser, + "edit_groups": AutoCompleteSelectMultipleGroup, + "view_groups": AutoCompleteSelectMultipleGroup, + } - parent = make_ajax_field(SithFile, "parent", "files", help_text="") - edit_groups = make_ajax_field( - SithFile, "edit_groups", "groups", help_text="", label=_("edit group") - ) - view_groups = make_ajax_field( - SithFile, "view_groups", "groups", help_text="", label=_("view group") - ) recursive = forms.BooleanField(label=_("Apply rights recursively"), required=False) diff --git a/core/views/forms.py b/core/views/forms.py index 232c938b..cfd1b92c 100644 --- a/core/views/forms.py +++ b/core/views/forms.py @@ -23,20 +23,16 @@ import re from io import BytesIO -from ajax_select import make_ajax_field -from ajax_select.fields import AutoCompleteSelectField from captcha.fields import CaptchaField from django import forms from django.conf import settings from django.contrib.auth.forms import AuthenticationForm, UserCreationForm -from django.contrib.staticfiles.storage import staticfiles_storage from django.core.exceptions import ValidationError from django.db import transaction from django.forms import ( CheckboxSelectMultiple, DateInput, DateTimeInput, - Textarea, TextInput, ) from django.utils.translation import gettext @@ -47,6 +43,12 @@ from PIL import Image from antispam.forms import AntiSpamEmailField from core.models import Gift, Page, SithFile, User from core.utils import resize_image +from core.views.widgets.select import ( + AutoCompleteSelect, + AutoCompleteSelectGroup, + AutoCompleteSelectMultipleGroup, + AutoCompleteSelectUser, +) # Widgets @@ -65,19 +67,6 @@ class SelectDate(DateInput): super().__init__(attrs=attrs, format=format or "%Y-%m-%d") -class MarkdownInput(Textarea): - template_name = "core/widgets/markdown_textarea.jinja" - - def get_context(self, name, value, attrs): - context = super().get_context(name, value, attrs) - - context["statics"] = { - "js": staticfiles_storage.url("webpack/easymde-index.ts"), - "css": staticfiles_storage.url("webpack/easymde-index.css"), - } - return context - - class NFCTextInput(TextInput): template_name = "core/widgets/nfc.jinja" @@ -311,8 +300,12 @@ class UserGodfathersForm(forms.Form): ], label=_("Add"), ) - user = AutoCompleteSelectField( - "users", required=True, label=_("Select user"), help_text="" + user = forms.ModelChoiceField( + label=_("Select user"), + help_text=None, + required=True, + widget=AutoCompleteSelectUser, + queryset=User.objects.all(), ) def __init__(self, *args, user: User, **kwargs): @@ -354,13 +347,12 @@ class PagePropForm(forms.ModelForm): class Meta: model = Page fields = ["parent", "name", "owner_group", "edit_groups", "view_groups"] - - edit_groups = make_ajax_field( - Page, "edit_groups", "groups", help_text="", label=_("edit groups") - ) - view_groups = make_ajax_field( - Page, "view_groups", "groups", help_text="", label=_("view groups") - ) + widgets = { + "parent": AutoCompleteSelect, + "owner_group": AutoCompleteSelectGroup, + "edit_groups": AutoCompleteSelectMultipleGroup, + "view_groups": AutoCompleteSelectMultipleGroup, + } def __init__(self, *arg, **kwargs): super().__init__(*arg, **kwargs) @@ -372,13 +364,12 @@ class PageForm(forms.ModelForm): class Meta: model = Page fields = ["parent", "name", "owner_group", "edit_groups", "view_groups"] - - edit_groups = make_ajax_field( - Page, "edit_groups", "groups", help_text="", label=_("edit groups") - ) - view_groups = make_ajax_field( - Page, "view_groups", "groups", help_text="", label=_("view groups") - ) + widgets = { + "parent": AutoCompleteSelect, + "owner_group": AutoCompleteSelectGroup, + "edit_groups": AutoCompleteSelectMultipleGroup, + "view_groups": AutoCompleteSelectMultipleGroup, + } def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) diff --git a/core/views/group.py b/core/views/group.py index b5fe495e..abb0097f 100644 --- a/core/views/group.py +++ b/core/views/group.py @@ -15,7 +15,6 @@ """Views to manage Groups.""" -from ajax_select.fields import AutoCompleteSelectMultipleField from django import forms from django.urls import reverse_lazy from django.utils.translation import gettext_lazy as _ @@ -24,6 +23,9 @@ 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, +) # Forms @@ -34,6 +36,15 @@ class EditMembersForm(forms.Form): def __init__(self, *args, **kwargs): self.current_users = kwargs.pop("users", []) super().__init__(*args, **kwargs) + + self.fields["users_added"] = forms.ModelMultipleChoiceField( + label=_("Users to add to group"), + help_text=_("Search users to add (one or more)."), + required=False, + widget=AutoCompleteSelectMultipleUser, + queryset=User.objects.exclude(id__in=self.current_users).all(), + ) + self.fields["users_removed"] = forms.ModelMultipleChoiceField( User.objects.filter(id__in=self.current_users).all(), label=_("Users to remove from group"), @@ -41,31 +52,6 @@ class EditMembersForm(forms.Form): widget=forms.CheckboxSelectMultiple, ) - users_added = AutoCompleteSelectMultipleField( - "users", - label=_("Users to add to group"), - help_text=_("Search users to add (one or more)."), - required=False, - ) - - def clean_users_added(self): - """Check that the user is not trying to add an user already in the group.""" - cleaned_data = super().clean() - users_added = cleaned_data.get("users_added", None) - if not users_added: - return users_added - - current_users = [ - str(id_) for id_ in self.current_users.values_list("id", flat=True) - ] - for user in users_added: - if user in current_users: - raise forms.ValidationError( - _("You can not add the same user twice"), code="invalid" - ) - - return users_added - # Views @@ -110,10 +96,12 @@ class GroupTemplateView(CanEditMixin, DetailFormView): data = form.clean() group = self.get_object() - for user in data["users_removed"]: - group.users.remove(user) - for user in data["users_added"]: - group.users.add(user) + if data["users_removed"]: + for user in data["users_removed"]: + group.users.remove(user) + if data["users_added"]: + for user in data["users_added"]: + group.users.add(user) group.save() return resp diff --git a/core/views/page.py b/core/views/page.py index 01fd59f6..e33e84ba 100644 --- a/core/views/page.py +++ b/core/views/page.py @@ -23,7 +23,8 @@ from django.views.generic.edit import CreateView, DeleteView, UpdateView from core.models import LockError, Page, PageRev from core.views import CanCreateMixin, CanEditMixin, CanEditPropMixin, CanViewMixin -from core.views.forms import MarkdownInput, PageForm, PagePropForm +from core.views.forms import PageForm, PagePropForm +from core.views.widgets.markdown import MarkdownInput class CanEditPagePropMixin(CanEditPropMixin): diff --git a/core/views/user.py b/core/views/user.py index 98ebcce6..e9694a92 100644 --- a/core/views/user.py +++ b/core/views/user.py @@ -77,7 +77,6 @@ from subscription.models import Subscription from trombi.views import UserTrombiForm -@method_decorator(check_honeypot, name="post") class SithLoginView(views.LoginView): """The login View.""" diff --git a/core/views/widgets/markdown.py b/core/views/widgets/markdown.py new file mode 100644 index 00000000..bd442b20 --- /dev/null +++ b/core/views/widgets/markdown.py @@ -0,0 +1,15 @@ +from django.contrib.staticfiles.storage import staticfiles_storage +from django.forms import Textarea + + +class MarkdownInput(Textarea): + template_name = "core/widgets/markdown_textarea.jinja" + + def get_context(self, name, value, attrs): + context = super().get_context(name, value, attrs) + + context["statics"] = { + "js": staticfiles_storage.url("webpack/core/components/easymde-index.ts"), + "css": staticfiles_storage.url("webpack/core/components/easymde-index.css"), + } + return context diff --git a/core/views/widgets/select.py b/core/views/widgets/select.py new file mode 100644 index 00000000..a7f600be --- /dev/null +++ b/core/views/widgets/select.py @@ -0,0 +1,111 @@ +from collections.abc import Collection +from typing import Any + +from django.contrib.staticfiles.storage import staticfiles_storage +from django.db.models import Model, QuerySet +from django.forms import Select, SelectMultiple +from ninja import ModelSchema +from pydantic import TypeAdapter + +from core.models import Group, SithFile, User +from core.schemas import GroupSchema, SithFileSchema, UserProfileSchema + + +class AutoCompleteSelectMixin: + component_name = "autocomplete-select" + template_name = "core/widgets/autocomplete_select.jinja" + model: type[Model] | None = None + adapter: TypeAdapter[Collection[ModelSchema]] | None = None + pk = "id" + + js = [ + "webpack/core/components/ajax-select-index.ts", + ] + css = [ + "webpack/core/components/ajax-select-index.css", + "core/components/ajax-select.scss", + ] + + def get_queryset(self, pks: Collection[Any]) -> QuerySet: + return self.model.objects.filter( + **{ + f"{self.pk}__in": [ + pk + for pk in pks + if str(pk).isdigit() # We filter empty values for create views + ] + } + ).all() + + def __init__(self, attrs=None, choices=()): + if self.is_ajax: + choices = () # Avoid computing anything when in ajax mode + super().__init__(attrs=attrs, choices=choices) + + @property + def is_ajax(self): + return self.adapter and self.model + + def optgroups(self, name, value, attrs=None): + """Don't create option groups when doing ajax""" + if self.is_ajax: + return [] + return super().optgroups(name, value, attrs=attrs) + + def get_context(self, name, value, attrs): + context = super().get_context(name, value, attrs) + context["widget"]["attrs"]["autocomplete"] = "off" + context["component"] = self.component_name + context["statics"] = { + "js": [staticfiles_storage.url(file) for file in self.js], + "css": [staticfiles_storage.url(file) for file in self.css], + } + if self.is_ajax: + context["initial"] = self.adapter.dump_json( + self.adapter.validate_python( + self.get_queryset(context["widget"]["value"]) + ) + ).decode("utf-8") + return context + + +class AutoCompleteSelect(AutoCompleteSelectMixin, Select): ... + + +class AutoCompleteSelectMultiple(AutoCompleteSelectMixin, SelectMultiple): ... + + +class AutoCompleteSelectUser(AutoCompleteSelect): + component_name = "user-ajax-select" + model = User + adapter = TypeAdapter(list[UserProfileSchema]) + + +class AutoCompleteSelectMultipleUser(AutoCompleteSelectMultiple): + component_name = "user-ajax-select" + model = User + adapter = TypeAdapter(list[UserProfileSchema]) + + +class AutoCompleteSelectGroup(AutoCompleteSelect): + component_name = "group-ajax-select" + model = Group + adapter = TypeAdapter(list[GroupSchema]) + + +class AutoCompleteSelectMultipleGroup(AutoCompleteSelectMultiple): + component_name = "group-ajax-select" + model = Group + adapter = TypeAdapter(list[GroupSchema]) + + +class AutoCompleteSelectSithFile(AutoCompleteSelect): + component_name = "sith-file-ajax-select" + model = SithFile + adapter = TypeAdapter(list[SithFileSchema]) + + +class AutoCompleteSelectMultipleSithFile(AutoCompleteSelectMultiple): + component_name = "sith-file-ajax-select" + model = SithFile + adapter = TypeAdapter(list[SithFileSchema]) diff --git a/counter/api.py b/counter/api.py index 834852d4..f3f0f101 100644 --- a/counter/api.py +++ b/counter/api.py @@ -12,11 +12,23 @@ # OR WITHIN THE LOCAL FILE "LICENSE" # # -from ninja_extra import ControllerBase, api_controller, route +from typing import Annotated -from core.api_permissions import CanView, IsRoot -from counter.models import Counter -from counter.schemas import CounterSchema +from annotated_types import MinLen +from django.db.models import Q +from ninja import Query +from ninja_extra import ControllerBase, api_controller, paginate, route +from ninja_extra.pagination import PageNumberPaginationExtra +from ninja_extra.schemas import PaginatedResponseSchema + +from core.api_permissions import CanAccessLookup, CanView, IsRoot +from counter.models import Counter, Product +from counter.schemas import ( + CounterFilterSchema, + CounterSchema, + ProductSchema, + SimplifiedCounterSchema, +) @api_controller("/counter") @@ -37,3 +49,30 @@ class CounterController(ControllerBase): for c in counters: self.check_object_permissions(c) return counters + + @route.get( + "/search", + response=PaginatedResponseSchema[SimplifiedCounterSchema], + permissions=[CanAccessLookup], + ) + @paginate(PageNumberPaginationExtra, page_size=50) + def search_counter(self, filters: Query[CounterFilterSchema]): + return filters.filter(Counter.objects.all()) + + +@api_controller("/product") +class ProductController(ControllerBase): + @route.get( + "/search", + response=PaginatedResponseSchema[ProductSchema], + 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() + ) diff --git a/counter/forms.py b/counter/forms.py index c43de059..84a92512 100644 --- a/counter/forms.py +++ b/counter/forms.py @@ -1,10 +1,15 @@ -from ajax_select import make_ajax_field -from ajax_select.fields import AutoCompleteSelectField, AutoCompleteSelectMultipleField from django import forms from django.utils.translation import gettext_lazy as _ from phonenumber_field.widgets import RegionalPhoneNumberWidget +from club.widgets.select import AutoCompleteSelectClub from core.views.forms import NFCTextInput, SelectDate, SelectDateTime +from core.views.widgets.select import ( + AutoCompleteSelect, + AutoCompleteSelectMultipleGroup, + AutoCompleteSelectMultipleUser, + AutoCompleteSelectUser, +) from counter.models import ( BillingInfo, Counter, @@ -14,6 +19,11 @@ from counter.models import ( Refilling, StudentCard, ) +from counter.widgets.select import ( + AutoCompleteSelectMultipleCounter, + AutoCompleteSelectMultipleProduct, + AutoCompleteSelectProduct, +) class BillingInfoForm(forms.ModelForm): @@ -68,8 +78,11 @@ class GetUserForm(forms.Form): required=False, widget=NFCTextInput, ) - id = AutoCompleteSelectField( - "users", required=False, label=_("Select user"), help_text=None + id = forms.CharField( + label=_("Select user"), + help_text=None, + widget=AutoCompleteSelectUser, + required=False, ) def as_p(self): @@ -81,9 +94,13 @@ class GetUserForm(forms.Form): cus = None if cleaned_data["code"] != "": if len(cleaned_data["code"]) == StudentCard.UID_SIZE: - card = StudentCard.objects.filter(uid=cleaned_data["code"]) - if card.exists(): - cus = card.first().customer + card = ( + StudentCard.objects.filter(uid=cleaned_data["code"]) + .select_related("customer") + .first() + ) + if card is not None: + cus = card.customer if cus is None: cus = Customer.objects.filter( account_id__iexact=cleaned_data["code"] @@ -122,8 +139,10 @@ class CounterEditForm(forms.ModelForm): model = Counter fields = ["sellers", "products"] - sellers = make_ajax_field(Counter, "sellers", "users", help_text="") - products = make_ajax_field(Counter, "products", "products", help_text="") + widgets = { + "sellers": AutoCompleteSelectMultipleUser, + "products": AutoCompleteSelectMultipleProduct, + } class ProductEditForm(forms.ModelForm): @@ -145,44 +164,37 @@ class ProductEditForm(forms.ModelForm): "tray", "archived", ] + widgets = { + "parent_product": AutoCompleteSelectMultipleProduct, + "product_type": AutoCompleteSelect, + "buying_groups": AutoCompleteSelectMultipleGroup, + "club": AutoCompleteSelectClub, + } - parent_product = AutoCompleteSelectField( - "products", show_help_text=False, label=_("Parent product"), required=False - ) - buying_groups = AutoCompleteSelectMultipleField( - "groups", - show_help_text=False, - help_text="", - label=_("Buying groups"), - required=True, - ) - club = AutoCompleteSelectField("clubs", show_help_text=False) - counters = AutoCompleteSelectMultipleField( - "counters", - show_help_text=False, - help_text="", + counters = forms.ModelMultipleChoiceField( + help_text=None, label=_("Counters"), required=False, + widget=AutoCompleteSelectMultipleCounter, + queryset=Counter.objects.all(), ) def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) if self.instance.id: - self.fields["counters"].initial = [ - str(c.id) for c in self.instance.counters.all() - ] + self.fields["counters"].initial = self.instance.counters.all() def save(self, *args, **kwargs): ret = super().save(*args, **kwargs) if self.fields["counters"].initial: - for cid in self.fields["counters"].initial: - c = Counter.objects.filter(id=int(cid)).first() - c.products.remove(self.instance) - c.save() - for cid in self.cleaned_data["counters"]: - c = Counter.objects.filter(id=int(cid)).first() - c.products.add(self.instance) - c.save() + # Remove the product from all counter it was added to + # It will then only be added to selected counters + for counter in self.fields["counters"].initial: + counter.products.remove(self.instance) + counter.save() + for counter in self.cleaned_data["counters"]: + counter.products.add(self.instance) + counter.save() return ret @@ -199,8 +211,7 @@ class EticketForm(forms.ModelForm): class Meta: model = Eticket fields = ["product", "banner", "event_title", "event_date"] - widgets = {"event_date": SelectDate} - - product = AutoCompleteSelectField( - "products", show_help_text=False, label=_("Product"), required=True - ) + widgets = { + "product": AutoCompleteSelectProduct, + "event_date": SelectDate, + } diff --git a/counter/management/commands/dump_warning_mail.py b/counter/management/commands/dump_warning_mail.py index 2b8fbfdd..9b966494 100644 --- a/counter/management/commands/dump_warning_mail.py +++ b/counter/management/commands/dump_warning_mail.py @@ -1,4 +1,5 @@ import logging +import time from smtplib import SMTPException from django.conf import settings @@ -25,9 +26,34 @@ class Command(BaseCommand): self.logger.setLevel(logging.INFO) super().__init__(*args, **kwargs) + def add_arguments(self, parser): + parser.add_argument( + "--dry-run", + action="store_true", + help="Do not send the mails, just display the number of users concerned", + ) + parser.add_argument( + "-d", + "--delay", + type=float, + default=0, + help="Delay in seconds between each mail sent", + ) + def handle(self, *args, **options): - users = list(self._get_users()) + users: list[User] = list(self._get_users()) self.stdout.write(f"{len(users)} users will be warned of their account dump") + if options["verbosity"] > 1: + self.stdout.write("Users concerned:\n") + self.stdout.write( + "\n".join( + f" - {user.get_display_name()} ({user.email}) : " + f"{user.customer.amount} €" + for user in users + ) + ) + if options["dry_run"]: + return dumps = [] for user in users: is_success = self._send_mail(user) @@ -38,6 +64,10 @@ class Command(BaseCommand): warning_mail_error=not is_success, ) ) + if options["delay"]: + # avoid spamming the mail server too much + time.sleep(options["delay"]) + AccountDump.objects.bulk_create(dumps) self.stdout.write("Finished !") diff --git a/counter/schemas.py b/counter/schemas.py index afe2455d..ec1a842d 100644 --- a/counter/schemas.py +++ b/counter/schemas.py @@ -1,7 +1,10 @@ -from ninja import ModelSchema +from typing import Annotated + +from annotated_types import MinLen +from ninja import Field, FilterSchema, ModelSchema from core.schemas import SimpleUserSchema -from counter.models import Counter +from counter.models import Counter, Product class CounterSchema(ModelSchema): @@ -11,3 +14,19 @@ class CounterSchema(ModelSchema): class Meta: model = Counter fields = ["id", "name", "type", "club", "products"] + + +class CounterFilterSchema(FilterSchema): + search: Annotated[str, MinLen(1)] = Field(None, q="name__icontains") + + +class SimplifiedCounterSchema(ModelSchema): + class Meta: + model = Counter + fields = ["id", "name"] + + +class ProductSchema(ModelSchema): + class Meta: + model = Product + fields = ["id", "name", "code"] diff --git a/counter/static/webpack/counter/components/ajax-select-index.ts b/counter/static/webpack/counter/components/ajax-select-index.ts new file mode 100644 index 00000000..147e4733 --- /dev/null +++ b/counter/static/webpack/counter/components/ajax-select-index.ts @@ -0,0 +1,60 @@ +import { AjaxSelect } from "#core:core/components/ajax-select-base"; +import { registerComponent } from "#core:utils/web-components"; +import type { TomOption } from "tom-select/dist/types/types"; +import type { escape_html } from "tom-select/dist/types/utils"; +import { + type CounterSchema, + type ProductSchema, + counterSearchCounter, + productSearchProducts, +} from "#openapi"; + +@registerComponent("product-ajax-select") +export class ProductAjaxSelect extends AjaxSelect { + protected valueField = "id"; + protected labelField = "name"; + protected searchField = ["code", "name"]; + + protected async search(query: string): Promise { + const resp = await productSearchProducts({ query: { search: query } }); + if (resp.data) { + return resp.data.results; + } + return []; + } + + protected renderOption(item: ProductSchema, sanitize: typeof escape_html) { + return `
+ ${sanitize(item.code)} - ${sanitize(item.name)} +
`; + } + + protected renderItem(item: ProductSchema, sanitize: typeof escape_html) { + return `${sanitize(item.code)} - ${sanitize(item.name)}`; + } +} + +@registerComponent("counter-ajax-select") +export class CounterAjaxSelect extends AjaxSelect { + protected valueField = "id"; + protected labelField = "name"; + protected searchField = ["code", "name"]; + + protected async search(query: string): Promise { + const resp = await counterSearchCounter({ query: { search: query } }); + if (resp.data) { + return resp.data.results; + } + return []; + } + + protected renderOption(item: CounterSchema, sanitize: typeof escape_html) { + return `
+ ${sanitize(item.name)} +
`; + } + + protected renderItem(item: CounterSchema, sanitize: typeof escape_html) { + return `${sanitize(item.name)}`; + } +} diff --git a/counter/templates/counter/account_dump_warning_mail.jinja b/counter/templates/counter/account_dump_warning_mail.jinja index 1d7dc8ed..b1374fc0 100644 --- a/counter/templates/counter/account_dump_warning_mail.jinja +++ b/counter/templates/counter/account_dump_warning_mail.jinja @@ -1,43 +1,40 @@ -

- Bonjour, -

+{% trans %}Hello{% endtrans %}, -

- {%- trans date=last_subscription_date|date(DATETIME_FORMAT) -%} - You received this email because your last subscription to the - Students' association ended on {{ date }}. - {%- endtrans -%} -

+{% trans trimmed date=last_subscription_date|date(DATETIME_FORMAT) -%} + You received this email because your last subscription to the + Students' association ended on {{ date }}. +{%- endtrans %} -

- {%- trans date=dump_date|date(DATETIME_FORMAT), amount=balance -%} - In accordance with the Internal Regulations, the balance of any - inactive AE account for more than 2 years automatically goes back - to the AE. - The money present on your account will therefore be recovered in full - on {{ date }}, for a total of {{ amount }} €. - {%- endtrans -%} -

+{% trans trimmed date=dump_date|date(DATETIME_FORMAT), amount=balance -%} + In accordance with the Internal Regulations, the balance of any + inactive AE account for more than 2 years automatically goes back + to the AE. + The money present on your account will therefore be recovered in full + on {{ date }}, for a total of {{ amount }} €. +{%- endtrans %} -

- {%- trans -%}However, if your subscription is renewed by this date, - your right to keep the money in your AE account will be renewed.{%- endtrans -%} -

+{% trans trimmed -%} + However, if your subscription is renewed by this date, + your right to keep the money in your AE account will be renewed. +{%- endtrans %} -{% if balance >= 10 %} -

- {%- trans -%}You can also request a refund by sending an email to - ae@utbm.fr - before the aforementioned date.{%- endtrans -%} -

-{% endif %} +{% if balance >= 10 -%} + {% trans trimmed -%} + You can also request a refund by sending an email to ae@utbm.fr + before the aforementioned date. + {%- endtrans %} +{%- endif %} -

- {% trans %}Sincerely{% endtrans %}, -

+{% trans trimmed -%} + Whatever you decide, you won't be expelled from the association, + and you won't lose your rights. + You will always be able to renew your subscription later. + If you don't renew your subscription, there will be no consequences + other than the loss of the money currently in your AE account. +{%- endtrans %} -

- L'association des étudiants de l'UTBM
- 6, Boulevard Anatole France
- 90000 Belfort -

+{% trans %}Sincerely{% endtrans %}, + +L'association des étudiants de l'UTBM +6, Boulevard Anatole France +90000 Belfort diff --git a/counter/templates/counter/product_list.jinja b/counter/templates/counter/product_list.jinja index 959d6797..881d7800 100644 --- a/counter/templates/counter/product_list.jinja +++ b/counter/templates/counter/product_list.jinja @@ -13,7 +13,7 @@

{{ product_type or _("Uncategorized") }}

{%- else -%} diff --git a/counter/views.py b/counter/views.py index 275eb1fa..9483d335 100644 --- a/counter/views.py +++ b/counter/views.py @@ -17,7 +17,7 @@ import re from datetime import datetime, timedelta from datetime import timezone as tz from http import HTTPStatus -from operator import attrgetter +from operator import itemgetter from typing import TYPE_CHECKING from urllib.parse import parse_qs @@ -801,7 +801,7 @@ class ProductTypeEditView(CounterAdminTabsMixin, CounterAdminMixin, UpdateView): class ProductListView(CounterAdminTabsMixin, CounterAdminMixin, ListView): model = Product - queryset = Product.objects.annotate(type_name=F("product_type__name")) + 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), @@ -812,7 +812,7 @@ class ProductListView(CounterAdminTabsMixin, CounterAdminMixin, ListView): def get_context_data(self, **kwargs): res = super().get_context_data(**kwargs) res["object_list"] = itertools.groupby( - res["object_list"], key=attrgetter("type_name") + res["object_list"], key=itemgetter("product_type__name") ) return res diff --git a/counter/widgets/select.py b/counter/widgets/select.py new file mode 100644 index 00000000..53c0bc9f --- /dev/null +++ b/counter/widgets/select.py @@ -0,0 +1,35 @@ +from pydantic import TypeAdapter + +from core.views.widgets.select import AutoCompleteSelect, AutoCompleteSelectMultiple +from counter.models import Counter, Product +from counter.schemas import ProductSchema, SimplifiedCounterSchema + +_js = ["webpack/counter/components/ajax-select-index.ts"] + + +class AutoCompleteSelectCounter(AutoCompleteSelect): + component_name = "counter-ajax-select" + model = Counter + adapter = TypeAdapter(list[SimplifiedCounterSchema]) + js = _js + + +class AutoCompleteSelectMultipleCounter(AutoCompleteSelectMultiple): + component_name = "counter-ajax-select" + model = Counter + adapter = TypeAdapter(list[SimplifiedCounterSchema]) + js = _js + + +class AutoCompleteSelectProduct(AutoCompleteSelect): + component_name = "product-ajax-select" + model = Product + adapter = TypeAdapter(list[ProductSchema]) + js = _js + + +class AutoCompleteSelectMultipleProduct(AutoCompleteSelectMultiple): + component_name = "product-ajax-select" + model = Product + adapter = TypeAdapter(list[ProductSchema]) + js = _js diff --git a/docs/explanation/index.md b/docs/explanation/index.md index 6f6e6e82..e3fe89ab 100644 --- a/docs/explanation/index.md +++ b/docs/explanation/index.md @@ -1,4 +1,4 @@ -gq## Objectifs +## Objectifs Le but de ce projet est de fournir à l'Association des Étudiants de l'UTBM diff --git a/docs/howto/statics.md b/docs/howto/statics.md index 2dbacb09..de513b77 100644 --- a/docs/howto/statics.md +++ b/docs/howto/statics.md @@ -62,5 +62,5 @@ Le post processing est géré par le module `staticfiles`. Les fichiers sont compilés à la volée en mode développement. Pour la production, ils sont compilés uniquement lors du `./manage.py collectstatic`. -Les fichiers générés sont ajoutés dans le dossier `sith/generated`. Celui-ci est +Les fichiers générés sont ajoutés dans le dossier `staticfiles/generated`. Celui-ci est ensuite enregistré comme dossier supplémentaire à collecter dans Django. \ No newline at end of file diff --git a/election/views.py b/election/views.py index 65aaf363..422205fd 100644 --- a/election/views.py +++ b/election/views.py @@ -1,7 +1,5 @@ from typing import TYPE_CHECKING -from ajax_select import make_ajax_field -from ajax_select.fields import AutoCompleteSelectField from django import forms from django.core.exceptions import PermissionDenied from django.db import transaction @@ -13,7 +11,13 @@ from django.views.generic import DetailView, ListView from django.views.generic.edit import CreateView, DeleteView, FormView, UpdateView from core.views import CanCreateMixin, CanEditMixin, CanViewMixin -from core.views.forms import MarkdownInput, SelectDateTime +from core.views.forms import SelectDateTime +from core.views.widgets.markdown import MarkdownInput +from core.views.widgets.select import ( + AutoCompleteSelect, + AutoCompleteSelectMultipleGroup, + AutoCompleteSelectUser, +) from election.models import Candidature, Election, ElectionList, Role, Vote if TYPE_CHECKING: @@ -51,11 +55,15 @@ class CandidateForm(forms.ModelForm): class Meta: model = Candidature fields = ["user", "role", "program", "election_list"] - widgets = {"program": MarkdownInput} - - user = AutoCompleteSelectField( - "users", label=_("User to candidate"), help_text=None, required=True - ) + labels = { + "user": _("User to candidate"), + } + widgets = { + "program": MarkdownInput, + "user": AutoCompleteSelectUser, + "role": AutoCompleteSelect, + "election_list": AutoCompleteSelect, + } def __init__(self, *args, **kwargs): election_id = kwargs.pop("election_id", None) @@ -97,6 +105,7 @@ class RoleForm(forms.ModelForm): class Meta: model = Role fields = ["title", "election", "description", "max_choice"] + widgets = {"election": AutoCompleteSelect} def __init__(self, *args, **kwargs): election_id = kwargs.pop("election_id", None) @@ -120,6 +129,7 @@ class ElectionListForm(forms.ModelForm): class Meta: model = ElectionList fields = ("title", "election") + widgets = {"election": AutoCompleteSelect} def __init__(self, *args, **kwargs): election_id = kwargs.pop("election_id", None) @@ -146,23 +156,12 @@ class ElectionForm(forms.ModelForm): "vote_groups", "candidature_groups", ] - - edit_groups = make_ajax_field( - Election, "edit_groups", "groups", help_text="", label=_("edit groups") - ) - view_groups = make_ajax_field( - Election, "view_groups", "groups", help_text="", label=_("view groups") - ) - vote_groups = make_ajax_field( - Election, "vote_groups", "groups", help_text="", label=_("vote groups") - ) - candidature_groups = make_ajax_field( - Election, - "candidature_groups", - "groups", - help_text="", - label=_("candidature groups"), - ) + widgets = { + "edit_groups": AutoCompleteSelectMultipleGroup, + "view_groups": AutoCompleteSelectMultipleGroup, + "vote_groups": AutoCompleteSelectMultipleGroup, + "candidature_groups": AutoCompleteSelectMultipleGroup, + } start_date = forms.DateTimeField( label=_("Start date"), widget=SelectDateTime, required=True @@ -328,6 +327,7 @@ class CandidatureCreateView(CanCreateMixin, CreateView): """Verify that the selected user is in candidate group.""" obj = form.instance obj.election = Election.objects.get(id=self.election.id) + obj.user = obj.user if hasattr(obj, "user") else self.request.user if (obj.election.can_candidate(obj.user)) and ( obj.user == self.request.user or self.can_edit ): diff --git a/forum/views.py b/forum/views.py index 052eb068..074f496d 100644 --- a/forum/views.py +++ b/forum/views.py @@ -25,7 +25,6 @@ import logging import math from functools import partial -from ajax_select import make_ajax_field from django import forms from django.conf import settings from django.contrib.auth.mixins import LoginRequiredMixin @@ -43,6 +42,7 @@ from django.views.generic.edit import CreateView, DeleteView, UpdateView from haystack.query import RelatedSearchQuerySet from honeypot.decorators import check_honeypot +from club.widgets.select import AutoCompleteSelectClub from core.views import ( CanCreateMixin, CanEditMixin, @@ -50,7 +50,11 @@ from core.views import ( CanViewMixin, can_view, ) -from core.views.forms import MarkdownInput +from core.views.widgets.markdown import MarkdownInput +from core.views.widgets.select import ( + AutoCompleteSelect, + AutoCompleteSelectMultipleGroup, +) from forum.models import Forum, ForumMessage, ForumMessageMeta, ForumTopic @@ -165,10 +169,15 @@ class ForumForm(forms.ModelForm): "edit_groups", "view_groups", ] + widgets = { + "edit_groups": AutoCompleteSelectMultipleGroup, + "view_groups": AutoCompleteSelectMultipleGroup, + "owner_club": AutoCompleteSelectClub, + } - edit_groups = make_ajax_field(Forum, "edit_groups", "groups", help_text="") - view_groups = make_ajax_field(Forum, "view_groups", "groups", help_text="") - parent = ForumNameField(Forum.objects.all()) + parent = ForumNameField( + Forum.objects.all(), widget=AutoCompleteSelect, required=False + ) class ForumCreateView(CanCreateMixin, CreateView): diff --git a/locale/fr/LC_MESSAGES/django.po b/locale/fr/LC_MESSAGES/django.po index e548dfba..c932c8c3 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-10-16 01:51+0200\n" +"POT-Creation-Date: 2024-11-10 15:57+0100\n" "PO-Revision-Date: 2016-07-18\n" "Last-Translator: Maréchal \n" @@ -218,7 +218,7 @@ msgstr "Compte" msgid "Company" msgstr "Entreprise" -#: accounting/models.py:307 core/models.py:337 sith/settings.py:420 +#: accounting/models.py:307 core/models.py:337 sith/settings.py:419 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:549 +#: accounting/views.py:574 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:549 +#: accounting/views.py:574 msgid "Debit" msgstr "Débit" @@ -379,13 +379,13 @@ msgstr "Compte en banque : " #: pedagogy/templates/pedagogy/guide.jinja:114 #: pedagogy/templates/pedagogy/uv_detail.jinja:189 #: sas/templates/sas/album.jinja:36 sas/templates/sas/moderation.jinja:18 -#: sas/templates/sas/picture.jinja:70 trombi/templates/trombi/detail.jinja:35 +#: sas/templates/sas/picture.jinja:71 trombi/templates/trombi/detail.jinja:35 #: trombi/templates/trombi/edit_profile.jinja:35 msgid "Delete" msgstr "Supprimer" #: accounting/templates/accounting/bank_account_details.jinja:18 -#: club/views.py:79 core/views/user.py:202 sas/templates/sas/picture.jinja:90 +#: club/views.py:79 core/views/user.py:202 sas/templates/sas/picture.jinja:91 msgid "Infos" msgstr "Infos" @@ -517,7 +517,7 @@ msgid "Effective amount" msgstr "Montant effectif" #: accounting/templates/accounting/club_account_details.jinja:36 -#: sith/settings.py:466 +#: sith/settings.py:465 msgid "Closed" msgstr "Fermé" @@ -616,7 +616,7 @@ msgstr "No" #: counter/templates/counter/last_ops.jinja:20 #: counter/templates/counter/last_ops.jinja:45 #: counter/templates/counter/refilling_list.jinja:16 -#: rootplace/templates/rootplace/logs.jinja:12 sas/forms.py:90 +#: rootplace/templates/rootplace/logs.jinja:12 sas/forms.py:82 #: trombi/templates/trombi/user_profile.jinja:40 msgid "Date" msgstr "Date" @@ -783,7 +783,7 @@ msgstr "Sauver" #: accounting/templates/accounting/refound_account.jinja:4 #: accounting/templates/accounting/refound_account.jinja:9 -#: accounting/views.py:863 +#: accounting/views.py:892 msgid "Refound account" msgstr "Remboursement de compte" @@ -804,87 +804,87 @@ msgstr "Types simplifiés" msgid "New simplified type" msgstr "Nouveau type simplifié" -#: accounting/views.py:208 accounting/views.py:218 accounting/views.py:524 +#: accounting/views.py:215 accounting/views.py:225 accounting/views.py:549 msgid "Journal" msgstr "Classeur" -#: accounting/views.py:228 +#: accounting/views.py:235 msgid "Statement by nature" msgstr "Bilan par nature" -#: accounting/views.py:238 +#: accounting/views.py:245 msgid "Statement by person" msgstr "Bilan par personne" -#: accounting/views.py:248 +#: accounting/views.py:255 msgid "Accounting statement" msgstr "Bilan comptable" -#: accounting/views.py:344 +#: accounting/views.py:369 msgid "Link this operation to the target account" msgstr "Lier cette opération au compte cible" -#: accounting/views.py:374 +#: accounting/views.py:399 msgid "The target must be set." msgstr "La cible doit être indiquée." -#: accounting/views.py:389 +#: accounting/views.py:414 msgid "The amount must be set." msgstr "Le montant doit être indiqué." -#: accounting/views.py:518 accounting/views.py:524 +#: accounting/views.py:543 accounting/views.py:549 msgid "Operation" msgstr "Opération" -#: accounting/views.py:533 +#: accounting/views.py:558 msgid "Financial proof: " msgstr "Justificatif de libellé : " -#: accounting/views.py:536 +#: accounting/views.py:561 #, python-format msgid "Club: %(club_name)s" msgstr "Club : %(club_name)s" -#: accounting/views.py:541 +#: accounting/views.py:566 #, python-format msgid "Label: %(op_label)s" msgstr "Libellé : %(op_label)s" -#: accounting/views.py:544 +#: accounting/views.py:569 #, python-format msgid "Date: %(date)s" msgstr "Date : %(date)s" -#: accounting/views.py:552 +#: accounting/views.py:577 #, python-format msgid "Amount: %(amount).2f €" msgstr "Montant : %(amount).2f €" -#: accounting/views.py:567 +#: accounting/views.py:592 msgid "Debtor" msgstr "Débiteur" -#: accounting/views.py:567 +#: accounting/views.py:592 msgid "Creditor" msgstr "Créditeur" -#: accounting/views.py:572 +#: accounting/views.py:597 msgid "Comment:" msgstr "Commentaire :" -#: accounting/views.py:597 +#: accounting/views.py:622 msgid "Signature:" msgstr "Signature :" -#: accounting/views.py:661 +#: accounting/views.py:686 msgid "General statement" msgstr "Bilan général" -#: accounting/views.py:668 +#: accounting/views.py:693 msgid "No label operations" msgstr "Opérations sans étiquette" -#: accounting/views.py:821 +#: accounting/views.py:846 msgid "Refound this account" msgstr "Rembourser ce compte" @@ -907,97 +907,93 @@ msgstr "" "True si gardé à jour par le biais d'un fournisseur externe de domains " "toxics, False sinon" -#: club/forms.py:55 club/forms.py:185 +#: club/forms.py:54 club/forms.py:180 msgid "Users to add" msgstr "Utilisateurs à ajouter" -#: club/forms.py:56 club/forms.py:186 core/views/group.py:47 +#: club/forms.py:55 club/forms.py:181 core/views/group.py:42 msgid "Search users to add (one or more)." msgstr "Recherche les utilisateurs à ajouter (un ou plus)." -#: club/forms.py:65 +#: club/forms.py:66 msgid "New Mailing" msgstr "Nouvelle mailing liste" -#: club/forms.py:66 +#: club/forms.py:67 msgid "Subscribe" msgstr "S'abonner" -#: club/forms.py:67 club/forms.py:80 com/templates/com/news_admin_list.jinja:40 +#: club/forms.py:68 club/forms.py:81 com/templates/com/news_admin_list.jinja:40 #: com/templates/com/news_admin_list.jinja:116 #: com/templates/com/news_admin_list.jinja:198 #: com/templates/com/news_admin_list.jinja:274 msgid "Remove" msgstr "Retirer" -#: club/forms.py:70 launderette/views.py:212 +#: club/forms.py:71 launderette/views.py:212 #: pedagogy/templates/pedagogy/moderation.jinja:15 msgid "Action" msgstr "Action" -#: club/forms.py:108 club/tests.py:711 +#: club/forms.py:109 club/tests.py:711 msgid "This field is required" msgstr "Ce champ est obligatoire" -#: club/forms.py:118 club/forms.py:245 club/tests.py:724 -msgid "One of the selected users doesn't exist" -msgstr "Un des utilisateurs sélectionné n'existe pas" - -#: club/forms.py:122 club/tests.py:741 +#: club/forms.py:118 club/tests.py:742 msgid "One of the selected users doesn't have an email address" msgstr "Un des utilisateurs sélectionnés n'a pas d'adresse email" -#: club/forms.py:133 +#: club/forms.py:129 msgid "An action is required" msgstr "Une action est requise" -#: club/forms.py:144 club/tests.py:698 +#: club/forms.py:140 club/tests.py:698 club/tests.py:724 msgid "You must specify at least an user or an email address" msgstr "vous devez spécifier au moins un utilisateur ou une adresse email" -#: club/forms.py:153 counter/forms.py:191 +#: club/forms.py:149 counter/forms.py:203 msgid "Begin date" msgstr "Date de début" -#: club/forms.py:156 com/views.py:83 com/views.py:201 counter/forms.py:194 -#: election/views.py:171 subscription/views.py:38 +#: club/forms.py:152 com/views.py:84 com/views.py:202 counter/forms.py:206 +#: election/views.py:170 subscription/views.py:38 msgid "End date" msgstr "Date de fin" -#: club/forms.py:160 club/templates/club/club_sellings.jinja:49 +#: club/forms.py:156 club/templates/club/club_sellings.jinja:49 #: core/templates/core/user_account_detail.jinja:17 #: core/templates/core/user_account_detail.jinja:56 #: counter/templates/counter/cash_summary_list.jinja:33 counter/views.py:137 msgid "Counter" msgstr "Comptoir" -#: club/forms.py:167 counter/views.py:683 +#: club/forms.py:163 counter/views.py:683 msgid "Products" msgstr "Produits" -#: club/forms.py:172 counter/views.py:688 +#: club/forms.py:168 counter/views.py:688 msgid "Archived products" msgstr "Produits archivés" -#: club/forms.py:227 club/templates/club/club_members.jinja:22 +#: club/forms.py:224 club/templates/club/club_members.jinja:22 #: club/templates/club/club_members.jinja:48 #: core/templates/core/user_clubs.jinja:31 msgid "Mark as old" msgstr "Marquer comme ancien" -#: club/forms.py:249 +#: club/forms.py:241 msgid "User must be subscriber to take part to a club" msgstr "L'utilisateur doit être cotisant pour faire partie d'un club" -#: club/forms.py:253 core/views/group.py:64 +#: club/forms.py:245 msgid "You can not add the same user twice" msgstr "Vous ne pouvez pas ajouter deux fois le même utilisateur" -#: club/forms.py:272 +#: club/forms.py:264 msgid "You should specify a role" msgstr "Vous devez choisir un rôle" -#: club/forms.py:283 sas/views.py:58 sas/views.py:176 +#: club/forms.py:275 sas/views.py:58 sas/views.py:176 msgid "You do not have the permission to do that" msgstr "Vous n'avez pas la permission de faire cela" @@ -1092,7 +1088,7 @@ msgstr "Liste de diffusion" 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:769 +#: club/models.py:529 club/tests.py:770 msgid "This email is already suscribed in this mailing" msgstr "Cet email est déjà abonné à cette mailing" @@ -1150,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:312 +#: core/templates/core/file_detail.jinja:19 core/views/forms.py:301 #: launderette/views.py:210 trombi/templates/trombi/detail.jinja:19 msgid "Add" msgstr "Ajouter" @@ -1373,8 +1369,8 @@ msgstr "Anciens membres" msgid "History" msgstr "Historique" -#: club/views.py:116 core/templates/core/base.jinja:105 core/views/user.py:225 -#: sas/templates/sas/picture.jinja:109 trombi/views.py:62 +#: club/views.py:116 core/templates/core/base.jinja:106 core/views/user.py:225 +#: sas/templates/sas/picture.jinja:110 trombi/views.py:62 msgid "Tools" msgstr "Outils" @@ -1390,7 +1386,7 @@ msgstr "Vente" msgid "Mailing list" msgstr "Listes de diffusion" -#: club/views.py:161 com/views.py:133 +#: club/views.py:161 com/views.py:134 msgid "Posters list" msgstr "Liste d'affiches" @@ -1504,7 +1500,7 @@ msgstr "temps d'affichage" msgid "Begin date should be before end date" msgstr "La date de début doit être avant celle de fin" -#: com/templates/com/mailing_admin.jinja:4 com/views.py:126 +#: com/templates/com/mailing_admin.jinja:4 com/views.py:127 #: core/templates/core/user_tools.jinja:136 msgid "Mailing lists administration" msgstr "Administration des mailing listes" @@ -1517,7 +1513,7 @@ msgstr "Administration des mailing listes" #: com/templates/com/news_detail.jinja:39 #: core/templates/core/file_detail.jinja:65 #: core/templates/core/file_moderation.jinja:23 -#: sas/templates/sas/moderation.jinja:17 sas/templates/sas/picture.jinja:67 +#: sas/templates/sas/moderation.jinja:17 sas/templates/sas/picture.jinja:68 msgid "Moderate" msgstr "Modérer" @@ -1588,7 +1584,7 @@ msgstr "Type" #: com/templates/com/news_admin_list.jinja:286 #: com/templates/com/weekmail.jinja:19 com/templates/com/weekmail.jinja:48 #: forum/templates/forum/forum.jinja:32 forum/templates/forum/forum.jinja:51 -#: forum/templates/forum/main.jinja:34 forum/views.py:246 +#: forum/templates/forum/main.jinja:34 forum/views.py:255 #: pedagogy/templates/pedagogy/guide.jinja:92 msgid "Title" msgstr "Titre" @@ -1659,7 +1655,7 @@ msgid "Calls to moderate" msgstr "Appels à modérer" #: com/templates/com/news_admin_list.jinja:242 -#: core/templates/core/base.jinja:220 +#: core/templates/core/base.jinja:221 msgid "Events" msgstr "Événements" @@ -1819,7 +1815,7 @@ msgid "Slideshow" msgstr "Diaporama" #: com/templates/com/weekmail.jinja:5 com/templates/com/weekmail.jinja:9 -#: com/views.py:103 core/templates/core/user_tools.jinja:129 +#: com/views.py:104 core/templates/core/user_tools.jinja:129 msgid "Weekmail" msgstr "Weekmail" @@ -1915,60 +1911,60 @@ msgstr "Astuce" msgid "Final word" msgstr "Le mot de la fin" -#: com/views.py:74 +#: com/views.py:75 msgid "Format: 16:9 | Resolution: 1920x1080" msgstr "Format : 16:9 | Résolution : 1920x1080" -#: com/views.py:77 com/views.py:198 election/views.py:168 +#: com/views.py:78 com/views.py:199 election/views.py:167 #: subscription/views.py:35 msgid "Start date" msgstr "Date de début" -#: com/views.py:98 +#: com/views.py:99 msgid "Communication administration" msgstr "Administration de la communication" -#: com/views.py:109 core/templates/core/user_tools.jinja:130 +#: com/views.py:110 core/templates/core/user_tools.jinja:130 msgid "Weekmail destinations" msgstr "Destinataires du Weekmail" -#: com/views.py:113 +#: com/views.py:114 msgid "Info message" msgstr "Message d'info" -#: com/views.py:119 +#: com/views.py:120 msgid "Alert message" msgstr "Message d'alerte" -#: com/views.py:140 +#: com/views.py:141 msgid "Screens list" msgstr "Liste d'écrans" -#: com/views.py:203 +#: com/views.py:204 msgid "Until" msgstr "Jusqu'à" -#: com/views.py:205 +#: com/views.py:206 msgid "Automoderation" msgstr "Automodération" -#: com/views.py:212 com/views.py:216 com/views.py:230 +#: com/views.py:213 com/views.py:217 com/views.py:231 msgid "This field is required." msgstr "Ce champ est obligatoire." -#: com/views.py:226 +#: 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:450 +#: com/views.py:451 msgid "Delete and save to regenerate" msgstr "Supprimer et sauver pour régénérer" -#: com/views.py:465 +#: com/views.py:466 msgid "Weekmail of the " msgstr "Weekmail du " -#: com/views.py:569 +#: com/views.py:570 msgid "" "You must be a board member of the selected club to post in the Weekmail." msgstr "" @@ -2246,7 +2242,7 @@ msgstr "avoir une notification pour chaque click" msgid "get a notification for every refilling" msgstr "avoir une notification pour chaque rechargement" -#: core/models.py:900 sas/forms.py:89 +#: core/models.py:900 sas/forms.py:81 msgid "file name" msgstr "nom du fichier" @@ -2266,11 +2262,11 @@ msgstr "miniature" msgid "owner" msgstr "propriétaire" -#: core/models.py:937 core/models.py:1274 core/views/files.py:223 +#: core/models.py:937 core/models.py:1274 msgid "edit group" msgstr "groupe d'édition" -#: core/models.py:940 core/models.py:1277 core/views/files.py:226 +#: core/models.py:940 core/models.py:1277 msgid "view group" msgstr "groupe de vue" @@ -2396,18 +2392,18 @@ msgstr "500, Erreur Serveur" msgid "Welcome!" msgstr "Bienvenue !" -#: core/templates/core/base.jinja:57 core/templates/core/login.jinja:8 +#: core/templates/core/base.jinja:58 core/templates/core/login.jinja:8 #: core/templates/core/login.jinja:18 core/templates/core/login.jinja:51 #: core/templates/core/password_reset_complete.jinja:5 msgid "Login" msgstr "Connexion" -#: core/templates/core/base.jinja:58 core/templates/core/register.jinja:7 +#: core/templates/core/base.jinja:59 core/templates/core/register.jinja:7 #: core/templates/core/register.jinja:16 core/templates/core/register.jinja:22 msgid "Register" msgstr "Inscription" -#: core/templates/core/base.jinja:64 core/templates/core/base.jinja:65 +#: core/templates/core/base.jinja:65 core/templates/core/base.jinja:66 #: forum/templates/forum/macros.jinja:179 #: forum/templates/forum/macros.jinja:183 #: matmat/templates/matmat/search_form.jinja:39 @@ -2416,52 +2412,52 @@ msgstr "Inscription" msgid "Search" msgstr "Recherche" -#: core/templates/core/base.jinja:106 +#: core/templates/core/base.jinja:107 msgid "Logout" msgstr "Déconnexion" -#: core/templates/core/base.jinja:154 +#: core/templates/core/base.jinja:155 msgid "You do not have any unread notification" msgstr "Vous n'avez aucune notification non lue" -#: core/templates/core/base.jinja:159 +#: core/templates/core/base.jinja:160 msgid "View more" msgstr "Voir plus" -#: core/templates/core/base.jinja:162 +#: core/templates/core/base.jinja:163 #: forum/templates/forum/last_unread.jinja:21 msgid "Mark all as read" msgstr "Marquer tout comme lu" -#: core/templates/core/base.jinja:210 +#: core/templates/core/base.jinja:211 msgid "Main" msgstr "Accueil" -#: core/templates/core/base.jinja:212 +#: core/templates/core/base.jinja:213 msgid "Associations & Clubs" msgstr "Associations & Clubs" -#: core/templates/core/base.jinja:214 +#: core/templates/core/base.jinja:215 msgid "AE" msgstr "L'AE" -#: core/templates/core/base.jinja:215 +#: core/templates/core/base.jinja:216 msgid "AE's clubs" msgstr "Les clubs de L'AE" -#: core/templates/core/base.jinja:216 +#: core/templates/core/base.jinja:217 msgid "Others UTBM's Associations" msgstr "Les autres associations de l'UTBM" -#: core/templates/core/base.jinja:222 core/templates/core/user_tools.jinja:172 +#: core/templates/core/base.jinja:223 core/templates/core/user_tools.jinja:172 msgid "Elections" msgstr "Élections" -#: core/templates/core/base.jinja:223 +#: core/templates/core/base.jinja:224 msgid "Big event" msgstr "Grandes Activités" -#: core/templates/core/base.jinja:226 +#: core/templates/core/base.jinja:227 #: forum/templates/forum/favorite_topics.jinja:18 #: forum/templates/forum/last_unread.jinja:18 #: forum/templates/forum/macros.jinja:90 forum/templates/forum/main.jinja:6 @@ -2470,89 +2466,89 @@ msgstr "Grandes Activités" msgid "Forum" msgstr "Forum" -#: core/templates/core/base.jinja:227 +#: core/templates/core/base.jinja:228 msgid "Gallery" msgstr "Photos" -#: core/templates/core/base.jinja:228 counter/models.py:459 +#: core/templates/core/base.jinja:229 counter/models.py:459 #: counter/templates/counter/counter_list.jinja:11 #: eboutic/templates/eboutic/eboutic_main.jinja:4 #: eboutic/templates/eboutic/eboutic_main.jinja:22 #: eboutic/templates/eboutic/eboutic_makecommand.jinja:16 #: eboutic/templates/eboutic/eboutic_payment_result.jinja:4 -#: sith/settings.py:419 sith/settings.py:427 +#: sith/settings.py:418 sith/settings.py:426 msgid "Eboutic" msgstr "Eboutic" -#: core/templates/core/base.jinja:230 +#: core/templates/core/base.jinja:231 msgid "Services" msgstr "Services" -#: core/templates/core/base.jinja:232 +#: core/templates/core/base.jinja:233 msgid "Matmatronch" msgstr "Matmatronch" -#: core/templates/core/base.jinja:233 launderette/models.py:38 +#: core/templates/core/base.jinja:234 launderette/models.py:38 #: launderette/templates/launderette/launderette_book.jinja:5 #: launderette/templates/launderette/launderette_book_choose.jinja:4 #: launderette/templates/launderette/launderette_main.jinja:4 msgid "Launderette" msgstr "Laverie" -#: core/templates/core/base.jinja:234 core/templates/core/file.jinja:20 -#: core/views/files.py:116 +#: core/templates/core/base.jinja:235 core/templates/core/file.jinja:20 +#: core/views/files.py:120 msgid "Files" msgstr "Fichiers" -#: core/templates/core/base.jinja:235 core/templates/core/user_tools.jinja:163 +#: core/templates/core/base.jinja:236 core/templates/core/user_tools.jinja:163 msgid "Pedagogy" msgstr "Pédagogie" -#: core/templates/core/base.jinja:239 +#: core/templates/core/base.jinja:240 msgid "My Benefits" msgstr "Mes Avantages" -#: core/templates/core/base.jinja:241 +#: core/templates/core/base.jinja:242 msgid "Sponsors" msgstr "Partenaires" -#: core/templates/core/base.jinja:242 +#: core/templates/core/base.jinja:243 msgid "Subscriber benefits" msgstr "Les avantages cotisants" -#: core/templates/core/base.jinja:246 +#: core/templates/core/base.jinja:247 msgid "Help" msgstr "Aide" -#: core/templates/core/base.jinja:248 +#: core/templates/core/base.jinja:249 msgid "FAQ" msgstr "FAQ" -#: core/templates/core/base.jinja:249 core/templates/core/base.jinja:289 +#: core/templates/core/base.jinja:250 core/templates/core/base.jinja:290 msgid "Contacts" msgstr "Contacts" -#: core/templates/core/base.jinja:250 +#: core/templates/core/base.jinja:251 msgid "Wiki" msgstr "Wiki" -#: core/templates/core/base.jinja:290 +#: core/templates/core/base.jinja:291 msgid "Legal notices" msgstr "Mentions légales" -#: core/templates/core/base.jinja:291 +#: core/templates/core/base.jinja:292 msgid "Intellectual property" msgstr "Propriété intellectuelle" -#: core/templates/core/base.jinja:292 +#: core/templates/core/base.jinja:293 msgid "Help & Documentation" msgstr "Aide & Documentation" -#: core/templates/core/base.jinja:293 +#: core/templates/core/base.jinja:294 msgid "R&D" msgstr "R&D" -#: core/templates/core/base.jinja:296 +#: core/templates/core/base.jinja:297 msgid "Site created by the IT Department of the AE" msgstr "Site réalisé par le Pôle Informatique de l'AE" @@ -2615,7 +2611,7 @@ msgstr "Propriétés" #: core/templates/core/file_detail.jinja:13 #: core/templates/core/file_moderation.jinja:20 -#: sas/templates/sas/picture.jinja:102 +#: sas/templates/sas/picture.jinja:103 msgid "Owner: " msgstr "Propriétaire : " @@ -2643,7 +2639,7 @@ msgstr "Nom réel : " #: core/templates/core/file_detail.jinja:54 #: core/templates/core/file_moderation.jinja:21 -#: sas/templates/sas/picture.jinja:93 +#: sas/templates/sas/picture.jinja:94 msgid "Date: " msgstr "Date : " @@ -3330,7 +3326,7 @@ msgstr "Achats" msgid "Product top 10" msgstr "Top 10 produits" -#: core/templates/core/user_stats.jinja:43 counter/forms.py:205 +#: core/templates/core/user_stats.jinja:43 msgid "Product" msgstr "Produit" @@ -3375,7 +3371,7 @@ msgstr "Cotisations" msgid "Subscription stats" msgstr "Statistiques de cotisation" -#: core/templates/core/user_tools.jinja:48 counter/forms.py:164 +#: core/templates/core/user_tools.jinja:48 counter/forms.py:176 #: counter/views.py:678 msgid "Counters" msgstr "Comptoirs" @@ -3487,42 +3483,42 @@ msgid_plural "%(nb_days)d days, %(remainder)s" msgstr[0] "" msgstr[1] "" -#: core/views/files.py:113 +#: core/views/files.py:117 msgid "Add a new folder" msgstr "Ajouter un nouveau dossier" -#: core/views/files.py:133 +#: core/views/files.py:137 #, 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:153 core/views/forms.py:277 core/views/forms.py:284 +#: core/views/files.py:157 core/views/forms.py:266 core/views/forms.py:273 #: sas/forms.py:60 #, python-format msgid "Error uploading file %(file_name)s: %(msg)s" msgstr "Erreur d'envoi du fichier %(file_name)s : %(msg)s" -#: core/views/files.py:228 sas/forms.py:93 +#: core/views/files.py:231 sas/forms.py:83 msgid "Apply rights recursively" msgstr "Appliquer les droits récursivement" -#: core/views/forms.py:86 +#: core/views/forms.py:75 msgid "Unsupported NFC card" msgstr "Carte NFC non supportée" -#: core/views/forms.py:100 core/views/forms.py:108 +#: core/views/forms.py:89 core/views/forms.py:97 msgid "Choose file" msgstr "Choisir un fichier" -#: core/views/forms.py:124 core/views/forms.py:132 +#: core/views/forms.py:113 core/views/forms.py:121 msgid "Choose user" msgstr "Choisir un utilisateur" -#: core/views/forms.py:164 +#: core/views/forms.py:153 msgid "Username, email, or account number" msgstr "Nom d'utilisateur, email, ou numéro de compte AE" -#: core/views/forms.py:227 +#: core/views/forms.py:216 msgid "" "Profile: you need to be visible on the picture, in order to be recognized (e." "g. by the barmen)" @@ -3530,66 +3526,56 @@ msgstr "" "Photo de profil: vous devez être visible sur la photo afin d'être reconnu " "(par exemple par les barmen)" -#: core/views/forms.py:232 +#: core/views/forms.py:221 msgid "Avatar: used on the forum" msgstr "Avatar : utilisé sur le forum" -#: core/views/forms.py:236 +#: core/views/forms.py:225 msgid "Scrub: let other know how your scrub looks like!" msgstr "Blouse : montrez aux autres à quoi ressemble votre blouse !" -#: core/views/forms.py:288 +#: core/views/forms.py:277 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:309 +#: core/views/forms.py:298 msgid "Godfather / Godmother" msgstr "Parrain / Marraine" -#: core/views/forms.py:310 +#: core/views/forms.py:299 msgid "Godchild" msgstr "Fillot / Fillote" -#: core/views/forms.py:315 counter/forms.py:72 trombi/views.py:151 +#: core/views/forms.py:304 counter/forms.py:82 trombi/views.py:151 msgid "Select user" msgstr "Choisir un utilisateur" -#: core/views/forms.py:325 +#: core/views/forms.py:318 msgid "This user does not exist" msgstr "Cet utilisateur n'existe pas" -#: core/views/forms.py:327 +#: core/views/forms.py:320 msgid "You cannot be related to yourself" msgstr "Vous ne pouvez pas être relié à vous-même" -#: core/views/forms.py:339 +#: core/views/forms.py:332 #, python-format msgid "%s is already your godfather" msgstr "%s est déjà votre parrain/marraine" -#: core/views/forms.py:345 +#: core/views/forms.py:338 #, python-format msgid "%s is already your godchild" msgstr "%s est déjà votre fillot/fillote" -#: core/views/forms.py:359 core/views/forms.py:377 election/models.py:22 -#: election/views.py:151 -msgid "edit groups" -msgstr "groupe d'édition" - -#: core/views/forms.py:362 core/views/forms.py:380 election/models.py:29 -#: election/views.py:154 -msgid "view groups" -msgstr "groupe de vue" - -#: core/views/group.py:39 -msgid "Users to remove from group" -msgstr "Utilisateurs à retirer du groupe" - -#: core/views/group.py:46 +#: core/views/group.py:41 msgid "Users to add to group" msgstr "Utilisateurs à ajouter au groupe" +#: core/views/group.py:50 +msgid "Users to remove from group" +msgstr "Utilisateurs à retirer du groupe" + #: core/views/user.py:184 msgid "We couldn't verify that this email actually exists" msgstr "Nous n'avons pas réussi à vérifier que cette adresse mail existe." @@ -3613,23 +3599,15 @@ msgstr "Galaxie" msgid "counter" msgstr "comptoir" -#: counter/forms.py:53 +#: counter/forms.py:63 msgid "This UID is invalid" msgstr "Cet UID est invalide" -#: counter/forms.py:94 +#: counter/forms.py:111 msgid "User not found" msgstr "Utilisateur non trouvé" -#: counter/forms.py:150 -msgid "Parent product" -msgstr "Produit parent" - -#: counter/forms.py:156 -msgid "Buying groups" -msgstr "Groupes d'achat" - -#: counter/management/commands/dump_warning_mail.py:82 +#: counter/management/commands/dump_warning_mail.py:112 msgid "Clearing of your AE account" msgstr "Vidange de votre compte AE" @@ -3793,8 +3771,8 @@ msgstr "quantité" msgid "Sith account" msgstr "Compte utilisateur" -#: counter/models.py:765 sith/settings.py:412 sith/settings.py:417 -#: sith/settings.py:437 +#: counter/models.py:765 sith/settings.py:411 sith/settings.py:416 +#: sith/settings.py:436 msgid "Credit card" msgstr "Carte bancaire" @@ -3883,48 +3861,61 @@ msgstr "uid" msgid "student cards" msgstr "cartes étudiante" -#: counter/templates/counter/account_dump_warning_mail.jinja:6 +#: counter/templates/counter/account_dump_warning_mail.jinja:1 +msgid "Hello" +msgstr "Bonjour" + +#: counter/templates/counter/account_dump_warning_mail.jinja:3 #, python-format msgid "" -"You received this email because your last subscription to the\n" -" Students' association ended on %(date)s." +"You received this email because your last subscription to the Students' " +"association ended on %(date)s." msgstr "" "Vous recevez ce mail car votre dernière cotisation à l'assocation des " "étudiants de l'UTBM s'est achevée le %(date)s." -#: counter/templates/counter/account_dump_warning_mail.jinja:11 +#: counter/templates/counter/account_dump_warning_mail.jinja:6 #, python-format msgid "" -"In accordance with the Internal Regulations, the balance of any\n" -" inactive AE account for more than 2 years automatically goes back\n" -" to the AE.\n" -" The money present on your account will therefore be recovered in full\n" -" on %(date)s, for a total of %(amount)s €." +"In accordance with the Internal Regulations, the balance of any inactive AE " +"account for more than 2 years automatically goes back to the AE. The money " +"present on your account will therefore be recovered in full on %(date)s, for " +"a total of %(amount)s €." msgstr "" "Conformément au Règlement intérieur, le solde de tout compte AE inactif " "depuis plus de 2 ans revient de droit à l'AE. L'argent présent sur votre " "compte sera donc récupéré en totalité le %(date)s, pour un total de " "%(amount)s €. " -#: counter/templates/counter/account_dump_warning_mail.jinja:19 +#: counter/templates/counter/account_dump_warning_mail.jinja:12 msgid "" -"However, if your subscription is renewed by this date,\n" -" your right to keep the money in your AE account will be renewed." +"However, if your subscription is renewed by this date, your right to keep " +"the money in your AE account will be renewed." msgstr "" "Cependant, si votre cotisation est renouvelée d'ici cette date, votre droit " "à conserver l'argent de votre compte AE sera renouvelé." -#: counter/templates/counter/account_dump_warning_mail.jinja:25 +#: counter/templates/counter/account_dump_warning_mail.jinja:16 msgid "" -"You can also request a refund by sending an email to\n" -" ae@utbm.fr\n" -" before the aforementioned date." +"You can also request a refund by sending an email to ae@utbm.fr before the " +"aforementioned date." msgstr "" "Vous pouvez également effectuer une demande de remboursement par mail à " -"l'adresse ae@utbm.fr avant la date " -"susmentionnée." +"l'adresse ae@utbm.fr avant la date susmentionnée." -#: counter/templates/counter/account_dump_warning_mail.jinja:32 +#: counter/templates/counter/account_dump_warning_mail.jinja:20 +msgid "" +"Whatever you decide, you won't be expelled from the association, and you " +"won't lose your rights. You will always be able to renew your subscription " +"later. If you don't renew your subscription, there will be no consequences " +"other than the loss of the money currently in your AE account." +msgstr "" +"Quel que soit votre décision, vous ne serez pas exclu.e de l'association et " +"vous ne perdrez pas vos droits. Vous serez toujours en mesure de renouveler " +"votre cotisation. Si vous ne renouvelez pas votre cotisation, il n'y aura " +"aucune conséquence autre que le retrait de l'argent de votre compte." + +#: counter/templates/counter/account_dump_warning_mail.jinja:26 msgid "Sincerely" msgstr "Cordialement" @@ -4457,11 +4448,19 @@ msgstr "début des candidatures" msgid "end candidature" msgstr "fin des candidatures" -#: election/models.py:36 election/views.py:157 +#: election/models.py:22 +msgid "edit groups" +msgstr "groupe d'édition" + +#: election/models.py:29 +msgid "view groups" +msgstr "groupe de vue" + +#: election/models.py:36 msgid "vote groups" msgstr "groupe de vote" -#: election/models.py:43 election/views.py:164 +#: election/models.py:43 msgid "candidature groups" msgstr "groupe de candidature" @@ -4525,7 +4524,7 @@ msgstr "Vous avez déjà soumis votre vote." msgid "You have voted in this election." msgstr "Vous avez déjà voté pour cette élection." -#: election/templates/election/election_detail.jinja:49 election/views.py:90 +#: election/templates/election/election_detail.jinja:49 election/views.py:98 msgid "Blank vote" msgstr "Vote blanc" @@ -4589,23 +4588,23 @@ msgstr "au" msgid "Polls open from" msgstr "Votes ouverts du" -#: election/views.py:41 +#: election/views.py:45 msgid "You have selected too much candidates." msgstr "Vous avez sélectionné trop de candidats." -#: election/views.py:57 +#: election/views.py:59 msgid "User to candidate" msgstr "Utilisateur se présentant" -#: election/views.py:115 +#: election/views.py:124 msgid "This role already exists for this election" msgstr "Ce rôle existe déjà pour cette élection" -#: election/views.py:174 +#: election/views.py:173 msgid "Start candidature" msgstr "Début des candidatures" -#: election/views.py:177 +#: election/views.py:176 msgid "End candidature" msgstr "Fin des candidatures" @@ -4749,11 +4748,11 @@ msgstr "Enlever des favoris" msgid "Mark as favorite" msgstr "Ajouter aux favoris" -#: forum/views.py:192 +#: forum/views.py:201 msgid "Apply rights and club owner recursively" msgstr "Appliquer les droits et le club propriétaire récursivement" -#: forum/views.py:422 +#: forum/views.py:431 #, python-format msgid "%(author)s said" msgstr "Citation de %(author)s" @@ -4864,12 +4863,12 @@ msgid "Washing and drying" msgstr "Lavage et séchage" #: launderette/templates/launderette/launderette_book.jinja:27 -#: sith/settings.py:655 +#: sith/settings.py:654 msgid "Washing" msgstr "Lavage" #: launderette/templates/launderette/launderette_book.jinja:31 -#: sith/settings.py:655 +#: sith/settings.py:654 msgid "Drying" msgstr "Séchage" @@ -5260,11 +5259,11 @@ msgstr "Fusion" msgid "User that will be kept" msgstr "Utilisateur qui sera conservé" -#: rootplace/views.py:163 +#: rootplace/views.py:167 msgid "User that will be deleted" msgstr "Utilisateur qui sera supprimé" -#: rootplace/views.py:169 +#: rootplace/views.py:177 msgid "User to be selected" msgstr "Utilisateur à sélectionner" @@ -5281,11 +5280,7 @@ msgstr "Envoyer les images" msgid "Error creating album %(album)s: %(msg)s" msgstr "Erreur de création de l'album %(album)s : %(msg)s" -#: sas/forms.py:72 trombi/templates/trombi/detail.jinja:15 -msgid "Add user" -msgstr "Ajouter une personne" - -#: sas/forms.py:117 +#: sas/forms.py:107 msgid "You already requested moderation for this picture." msgstr "Vous avez déjà déposé une demande de retrait pour cette photo." @@ -5311,7 +5306,7 @@ msgstr "Demandes de modération de photo" #: sas/templates/sas/album.jinja:13 #: sas/templates/sas/ask_picture_removal.jinja:4 sas/templates/sas/main.jinja:8 -#: sas/templates/sas/main.jinja:17 sas/templates/sas/picture.jinja:14 +#: sas/templates/sas/main.jinja:17 sas/templates/sas/picture.jinja:15 msgid "SAS" msgstr "SAS" @@ -5351,11 +5346,11 @@ msgstr "Toutes les catégories" msgid "SAS moderation" msgstr "Modération du SAS" -#: sas/templates/sas/picture.jinja:37 +#: sas/templates/sas/picture.jinja:38 msgid "Asked for removal" msgstr "Retrait demandé" -#: sas/templates/sas/picture.jinja:40 +#: sas/templates/sas/picture.jinja:41 msgid "" "This picture can be viewed only by root users and by SAS admins. It will be " "hidden to other users until it has been moderated." @@ -5364,23 +5359,23 @@ msgstr "" "SAS. Elle sera cachée pour les autres utilisateurs tant qu'elle ne sera pas " "modérée." -#: sas/templates/sas/picture.jinja:48 +#: sas/templates/sas/picture.jinja:49 msgid "The following issues have been raised:" msgstr "Les problèmes suivants ont été remontés :" -#: sas/templates/sas/picture.jinja:113 +#: sas/templates/sas/picture.jinja:114 msgid "HD version" msgstr "Version HD" -#: sas/templates/sas/picture.jinja:117 +#: sas/templates/sas/picture.jinja:118 msgid "Ask for removal" msgstr "Demander le retrait" -#: sas/templates/sas/picture.jinja:138 sas/templates/sas/picture.jinja:149 +#: sas/templates/sas/picture.jinja:139 sas/templates/sas/picture.jinja:150 msgid "Previous picture" msgstr "Image précédente" -#: sas/templates/sas/picture.jinja:157 +#: sas/templates/sas/picture.jinja:158 msgid "People" msgstr "Personne(s)" @@ -5388,380 +5383,380 @@ msgstr "Personne(s)" msgid "Identify users on pictures" msgstr "Identifiez les utilisateurs sur les photos" -#: sith/settings.py:255 sith/settings.py:474 +#: sith/settings.py:254 sith/settings.py:473 msgid "English" msgstr "Anglais" -#: sith/settings.py:255 sith/settings.py:473 +#: sith/settings.py:254 sith/settings.py:472 msgid "French" msgstr "Français" -#: sith/settings.py:393 +#: sith/settings.py:392 msgid "TC" msgstr "TC" -#: sith/settings.py:394 +#: sith/settings.py:393 msgid "IMSI" msgstr "IMSI" -#: sith/settings.py:395 +#: sith/settings.py:394 msgid "IMAP" msgstr "IMAP" -#: sith/settings.py:396 +#: sith/settings.py:395 msgid "INFO" msgstr "INFO" -#: sith/settings.py:397 +#: sith/settings.py:396 msgid "GI" msgstr "GI" -#: sith/settings.py:398 sith/settings.py:484 +#: sith/settings.py:397 sith/settings.py:483 msgid "E" msgstr "E" -#: sith/settings.py:399 +#: sith/settings.py:398 msgid "EE" msgstr "EE" -#: sith/settings.py:400 +#: sith/settings.py:399 msgid "GESC" msgstr "GESC" -#: sith/settings.py:401 +#: sith/settings.py:400 msgid "GMC" msgstr "GMC" -#: sith/settings.py:402 +#: sith/settings.py:401 msgid "MC" msgstr "MC" -#: sith/settings.py:403 +#: sith/settings.py:402 msgid "EDIM" msgstr "EDIM" -#: sith/settings.py:404 +#: sith/settings.py:403 msgid "Humanities" msgstr "Humanités" -#: sith/settings.py:405 +#: sith/settings.py:404 msgid "N/A" msgstr "N/A" -#: sith/settings.py:409 sith/settings.py:416 sith/settings.py:435 +#: sith/settings.py:408 sith/settings.py:415 sith/settings.py:434 msgid "Check" msgstr "Chèque" -#: sith/settings.py:410 sith/settings.py:418 sith/settings.py:436 +#: sith/settings.py:409 sith/settings.py:417 sith/settings.py:435 msgid "Cash" msgstr "Espèces" -#: sith/settings.py:411 +#: sith/settings.py:410 msgid "Transfert" msgstr "Virement" -#: sith/settings.py:424 +#: sith/settings.py:423 msgid "Belfort" msgstr "Belfort" -#: sith/settings.py:425 +#: sith/settings.py:424 msgid "Sevenans" msgstr "Sevenans" -#: sith/settings.py:426 +#: sith/settings.py:425 msgid "Montbéliard" msgstr "Montbéliard" -#: sith/settings.py:454 +#: sith/settings.py:453 msgid "Free" msgstr "Libre" -#: sith/settings.py:455 +#: sith/settings.py:454 msgid "CS" msgstr "CS" -#: sith/settings.py:456 +#: sith/settings.py:455 msgid "TM" msgstr "TM" -#: sith/settings.py:457 +#: sith/settings.py:456 msgid "OM" msgstr "OM" -#: sith/settings.py:458 +#: sith/settings.py:457 msgid "QC" msgstr "QC" -#: sith/settings.py:459 +#: sith/settings.py:458 msgid "EC" msgstr "EC" -#: sith/settings.py:460 +#: sith/settings.py:459 msgid "RN" msgstr "RN" -#: sith/settings.py:461 +#: sith/settings.py:460 msgid "ST" msgstr "ST" -#: sith/settings.py:462 +#: sith/settings.py:461 msgid "EXT" msgstr "EXT" -#: sith/settings.py:467 +#: sith/settings.py:466 msgid "Autumn" msgstr "Automne" -#: sith/settings.py:468 +#: sith/settings.py:467 msgid "Spring" msgstr "Printemps" -#: sith/settings.py:469 +#: sith/settings.py:468 msgid "Autumn and spring" msgstr "Automne et printemps" -#: sith/settings.py:475 +#: sith/settings.py:474 msgid "German" msgstr "Allemand" -#: sith/settings.py:476 +#: sith/settings.py:475 msgid "Spanish" msgstr "Espagnol" -#: sith/settings.py:480 +#: sith/settings.py:479 msgid "A" msgstr "A" -#: sith/settings.py:481 +#: sith/settings.py:480 msgid "B" msgstr "B" -#: sith/settings.py:482 +#: sith/settings.py:481 msgid "C" msgstr "C" -#: sith/settings.py:483 +#: sith/settings.py:482 msgid "D" msgstr "D" -#: sith/settings.py:485 +#: sith/settings.py:484 msgid "FX" msgstr "FX" -#: sith/settings.py:486 +#: sith/settings.py:485 msgid "F" msgstr "F" -#: sith/settings.py:487 +#: sith/settings.py:486 msgid "Abs" msgstr "Abs" -#: sith/settings.py:491 +#: sith/settings.py:490 msgid "Selling deletion" msgstr "Suppression de vente" -#: sith/settings.py:492 +#: sith/settings.py:491 msgid "Refilling deletion" msgstr "Suppression de rechargement" -#: sith/settings.py:536 +#: sith/settings.py:535 msgid "One semester" msgstr "Un semestre, 20 €" -#: sith/settings.py:537 +#: sith/settings.py:536 msgid "Two semesters" msgstr "Deux semestres, 35 €" -#: sith/settings.py:539 +#: sith/settings.py:538 msgid "Common core cursus" msgstr "Cursus tronc commun, 60 €" -#: sith/settings.py:543 +#: sith/settings.py:542 msgid "Branch cursus" msgstr "Cursus branche, 60 €" -#: sith/settings.py:544 +#: sith/settings.py:543 msgid "Alternating cursus" msgstr "Cursus alternant, 30 €" -#: sith/settings.py:545 +#: sith/settings.py:544 msgid "Honorary member" msgstr "Membre honoraire, 0 €" -#: sith/settings.py:546 +#: sith/settings.py:545 msgid "Assidu member" msgstr "Membre d'Assidu, 0 €" -#: sith/settings.py:547 +#: sith/settings.py:546 msgid "Amicale/DOCEO member" msgstr "Membre de l'Amicale/DOCEO, 0 €" -#: sith/settings.py:548 +#: sith/settings.py:547 msgid "UT network member" msgstr "Cotisant du réseau UT, 0 €" -#: sith/settings.py:549 +#: sith/settings.py:548 msgid "CROUS member" msgstr "Membres du CROUS, 0 €" -#: sith/settings.py:550 +#: sith/settings.py:549 msgid "Sbarro/ESTA member" msgstr "Membre de Sbarro ou de l'ESTA, 20 €" -#: sith/settings.py:552 +#: sith/settings.py:551 msgid "One semester Welcome Week" msgstr "Un semestre Welcome Week" -#: sith/settings.py:556 +#: sith/settings.py:555 msgid "One month for free" msgstr "Un mois gratuit" -#: sith/settings.py:557 +#: sith/settings.py:556 msgid "Two months for free" msgstr "Deux mois gratuits" -#: sith/settings.py:558 +#: sith/settings.py:557 msgid "Eurok's volunteer" msgstr "Bénévole Eurockéennes" -#: sith/settings.py:560 +#: sith/settings.py:559 msgid "Six weeks for free" msgstr "6 semaines gratuites" -#: sith/settings.py:564 +#: sith/settings.py:563 msgid "One day" msgstr "Un jour" -#: sith/settings.py:565 +#: sith/settings.py:564 msgid "GA staff member" msgstr "Membre staff GA (2 semaines), 1 €" -#: sith/settings.py:568 +#: sith/settings.py:567 msgid "One semester (-20%)" msgstr "Un semestre (-20%), 12 €" -#: sith/settings.py:573 +#: sith/settings.py:572 msgid "Two semesters (-20%)" msgstr "Deux semestres (-20%), 22 €" -#: sith/settings.py:578 +#: sith/settings.py:577 msgid "Common core cursus (-20%)" msgstr "Cursus tronc commun (-20%), 36 €" -#: sith/settings.py:583 +#: sith/settings.py:582 msgid "Branch cursus (-20%)" msgstr "Cursus branche (-20%), 36 €" -#: sith/settings.py:588 +#: sith/settings.py:587 msgid "Alternating cursus (-20%)" msgstr "Cursus alternant (-20%), 24 €" -#: sith/settings.py:594 +#: sith/settings.py:593 msgid "One year for free(CA offer)" msgstr "Une année offerte (Offre CA)" -#: sith/settings.py:614 +#: sith/settings.py:613 msgid "President" msgstr "Président⸱e" -#: sith/settings.py:615 +#: sith/settings.py:614 msgid "Vice-President" msgstr "Vice-Président⸱e" -#: sith/settings.py:616 +#: sith/settings.py:615 msgid "Treasurer" msgstr "Trésorier⸱e" -#: sith/settings.py:617 +#: sith/settings.py:616 msgid "Communication supervisor" msgstr "Responsable communication" -#: sith/settings.py:618 +#: sith/settings.py:617 msgid "Secretary" msgstr "Secrétaire" -#: sith/settings.py:619 +#: sith/settings.py:618 msgid "IT supervisor" msgstr "Responsable info" -#: sith/settings.py:620 +#: sith/settings.py:619 msgid "Board member" msgstr "Membre du bureau" -#: sith/settings.py:621 +#: sith/settings.py:620 msgid "Active member" msgstr "Membre actif⸱ve" -#: sith/settings.py:622 +#: sith/settings.py:621 msgid "Curious" msgstr "Curieux⸱euse" -#: sith/settings.py:659 +#: sith/settings.py:658 msgid "A new poster needs to be moderated" msgstr "Une nouvelle affiche a besoin d'être modérée" -#: sith/settings.py:660 +#: sith/settings.py:659 msgid "A new mailing list needs to be moderated" msgstr "Une nouvelle mailing list a besoin d'être modérée" -#: sith/settings.py:663 +#: sith/settings.py:662 msgid "A new pedagogy comment has been signaled for moderation" msgstr "" "Un nouveau commentaire de la pédagogie a été signalé pour la modération" -#: sith/settings.py:665 +#: sith/settings.py:664 #, python-format msgid "There are %s fresh news to be moderated" msgstr "Il y a %s nouvelles toutes fraîches à modérer" -#: sith/settings.py:666 +#: sith/settings.py:665 msgid "New files to be moderated" msgstr "Nouveaux fichiers à modérer" -#: sith/settings.py:667 +#: sith/settings.py:666 #, python-format msgid "There are %s pictures to be moderated in the SAS" msgstr "Il y a %s photos à modérer dans le SAS" -#: sith/settings.py:668 +#: sith/settings.py:667 msgid "You've been identified on some pictures" msgstr "Vous avez été identifié sur des photos" -#: sith/settings.py:669 +#: sith/settings.py:668 #, python-format msgid "You just refilled of %s €" msgstr "Vous avez rechargé votre compte de %s€" -#: sith/settings.py:670 +#: sith/settings.py:669 #, python-format msgid "You just bought %s" msgstr "Vous avez acheté %s" -#: sith/settings.py:671 +#: sith/settings.py:670 msgid "You have a notification" msgstr "Vous avez une notification" -#: sith/settings.py:683 +#: sith/settings.py:682 msgid "Success!" msgstr "Succès !" -#: sith/settings.py:684 +#: sith/settings.py:683 msgid "Fail!" msgstr "Échec !" -#: sith/settings.py:685 +#: sith/settings.py:684 msgid "You successfully posted an article in the Weekmail" msgstr "Article posté avec succès dans le Weekmail" -#: sith/settings.py:686 +#: sith/settings.py:685 msgid "You successfully edited an article in the Weekmail" msgstr "Article édité avec succès dans le Weekmail" -#: sith/settings.py:687 +#: sith/settings.py:686 msgid "You successfully sent the Weekmail" msgstr "Weekmail envoyé avec succès" -#: sith/settings.py:695 +#: sith/settings.py:694 msgid "AE tee-shirt" msgstr "Tee-shirt AE" @@ -5936,6 +5931,10 @@ msgstr "Fin des commentaires : " msgid "Export" msgstr "Exporter" +#: trombi/templates/trombi/detail.jinja:15 +msgid "Add user" +msgstr "Ajouter une personne" + #: trombi/templates/trombi/detail.jinja:36 msgid "Add club membership" msgstr "Ajouter appartenance à un club" @@ -6042,15 +6041,15 @@ msgstr "Mes photos" msgid "Admin tools" msgstr "Admin Trombi" -#: trombi/views.py:215 +#: trombi/views.py:219 msgid "Explain why you rejected the comment" msgstr "Expliquez pourquoi vous refusez le commentaire" -#: trombi/views.py:246 +#: trombi/views.py:250 msgid "Rejected comment" msgstr "Commentaire rejeté" -#: trombi/views.py:248 +#: trombi/views.py:252 #, python-format msgid "" "Your comment to %(target)s on the Trombi \"%(trombi)s\" was rejected for the " @@ -6067,16 +6066,16 @@ msgstr "" "\n" "%(content)s" -#: trombi/views.py:280 +#: trombi/views.py:284 #, python-format msgid "%(name)s (deadline: %(date)s)" msgstr "%(name)s (date limite: %(date)s)" -#: trombi/views.py:290 +#: trombi/views.py:294 msgid "Select trombi" msgstr "Choisir un trombi" -#: trombi/views.py:292 +#: trombi/views.py:296 msgid "" "This allows you to subscribe to a Trombi. Be aware that you can subscribe " "only once, so don't play with that, or you will expose yourself to the " @@ -6086,19 +6085,19 @@ msgstr "" "pouvez vous inscrire qu'à un seul Trombi, donc ne jouez pas avec cet option " "ou vous encourerez la colère des admins!" -#: trombi/views.py:363 +#: trombi/views.py:367 msgid "Personal email (not UTBM)" msgstr "Email personnel (pas UTBM)" -#: trombi/views.py:364 +#: trombi/views.py:368 msgid "Phone" msgstr "Téléphone" -#: trombi/views.py:365 +#: trombi/views.py:369 msgid "Native town" msgstr "Ville d'origine" -#: trombi/views.py:473 +#: trombi/views.py:477 msgid "" "You can not yet write comment, you must wait for the subscription deadline " "to be passed." @@ -6106,11 +6105,11 @@ msgstr "" "Vous ne pouvez pas encore écrire de commentaires, vous devez attendre la fin " "des inscriptions" -#: trombi/views.py:480 +#: trombi/views.py:484 msgid "You can not write comment anymore, the deadline is already passed." msgstr "Vous ne pouvez plus écrire de commentaires, la date est passée." -#: trombi/views.py:493 +#: trombi/views.py:497 #, python-format msgid "Maximum characters: %(max_length)s" msgstr "Nombre de caractères max: %(max_length)s" diff --git a/locale/fr/LC_MESSAGES/djangojs.po b/locale/fr/LC_MESSAGES/djangojs.po index 644f4a43..3c93fbb7 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-10-16 02:19+0200\n" +"POT-Creation-Date: 2024-11-10 16:00+0100\n" "PO-Revision-Date: 2024-09-17 11:54+0200\n" "Last-Translator: Sli \n" "Language-Team: AE info \n" @@ -22,87 +22,94 @@ msgstr "" msgid "captured.%s" msgstr "capture.%s" -#: core/static/webpack/ajax-select-index.ts:73 +#: core/static/webpack/core/components/ajax-select-base.ts:68 +#: staticfiles/generated/webpack/core/static/webpack/core/components/ajax-select-base.js:57 +msgid "Remove" +msgstr "Retirer" + +#: core/static/webpack/core/components/ajax-select-base.ts:88 +#: staticfiles/generated/webpack/core/static/webpack/core/components/ajax-select-base.js:77 msgid "You need to type %(number)s more characters" msgstr "Vous devez taper %(number)s caractères de plus" -#: core/static/webpack/ajax-select-index.ts:76 +#: core/static/webpack/core/components/ajax-select-base.ts:92 +#: staticfiles/generated/webpack/core/static/webpack/core/components/ajax-select-base.js:81 msgid "No results found" msgstr "Aucun résultat trouvé" -#: core/static/webpack/easymde-index.ts:31 +#: core/static/webpack/core/components/easymde-index.ts:38 msgid "Heading" msgstr "Titre" -#: core/static/webpack/easymde-index.ts:37 +#: core/static/webpack/core/components/easymde-index.ts:44 msgid "Italic" msgstr "Italique" -#: core/static/webpack/easymde-index.ts:43 +#: core/static/webpack/core/components/easymde-index.ts:50 msgid "Bold" msgstr "Gras" -#: core/static/webpack/easymde-index.ts:49 +#: core/static/webpack/core/components/easymde-index.ts:56 msgid "Strikethrough" msgstr "Barré" -#: core/static/webpack/easymde-index.ts:58 +#: core/static/webpack/core/components/easymde-index.ts:65 msgid "Underline" msgstr "Souligné" -#: core/static/webpack/easymde-index.ts:67 +#: core/static/webpack/core/components/easymde-index.ts:74 msgid "Superscript" msgstr "Exposant" -#: core/static/webpack/easymde-index.ts:76 +#: core/static/webpack/core/components/easymde-index.ts:83 msgid "Subscript" msgstr "Indice" -#: core/static/webpack/easymde-index.ts:82 +#: core/static/webpack/core/components/easymde-index.ts:89 msgid "Code" msgstr "Code" -#: core/static/webpack/easymde-index.ts:89 +#: core/static/webpack/core/components/easymde-index.ts:96 msgid "Quote" msgstr "Citation" -#: core/static/webpack/easymde-index.ts:95 +#: core/static/webpack/core/components/easymde-index.ts:102 msgid "Unordered list" msgstr "Liste non ordonnée" -#: core/static/webpack/easymde-index.ts:101 +#: core/static/webpack/core/components/easymde-index.ts:108 msgid "Ordered list" msgstr "Liste ordonnée" -#: core/static/webpack/easymde-index.ts:108 +#: core/static/webpack/core/components/easymde-index.ts:115 msgid "Insert link" msgstr "Insérer lien" -#: core/static/webpack/easymde-index.ts:114 +#: core/static/webpack/core/components/easymde-index.ts:121 msgid "Insert image" msgstr "Insérer image" -#: core/static/webpack/easymde-index.ts:120 +#: core/static/webpack/core/components/easymde-index.ts:127 msgid "Insert table" msgstr "Insérer tableau" -#: core/static/webpack/easymde-index.ts:127 +#: core/static/webpack/core/components/easymde-index.ts:134 msgid "Clean block" msgstr "Nettoyer bloc" -#: core/static/webpack/easymde-index.ts:134 +#: core/static/webpack/core/components/easymde-index.ts:141 msgid "Toggle preview" msgstr "Activer la prévisualisation" -#: core/static/webpack/easymde-index.ts:140 +#: 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/easymde-index.ts:146 +#: core/static/webpack/core/components/easymde-index.ts:153 msgid "Toggle fullscreen" msgstr "Activer le plein écran" -#: core/static/webpack/easymde-index.ts:153 +#: core/static/webpack/core/components/easymde-index.ts:160 msgid "Markdown guide" msgstr "Guide markdown" @@ -119,9 +126,11 @@ msgid "Incorrect value" msgstr "Valeur incorrecte" #: sas/static/webpack/sas/viewer-index.ts:271 +#: staticfiles/generated/webpack/sas/static/webpack/sas/viewer-index.js:234 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 +#: staticfiles/generated/webpack/sas/static/webpack/sas/viewer-index.js:248 msgid "Couldn't delete picture" msgstr "Il n'a pas été possible de supprimer l'image" diff --git a/pedagogy/forms.py b/pedagogy/forms.py index 56a3dce7..9a182f92 100644 --- a/pedagogy/forms.py +++ b/pedagogy/forms.py @@ -25,7 +25,7 @@ from django import forms from django.utils.translation import gettext_lazy as _ from core.models import User -from core.views.forms import MarkdownInput +from core.views.widgets.markdown import MarkdownInput from pedagogy.models import UV, UVComment, UVCommentReport diff --git a/poetry.lock b/poetry.lock index c8c6f856..b622f423 100644 --- a/poetry.lock +++ b/poetry.lock @@ -520,16 +520,6 @@ tzdata = {version = "*", markers = "sys_platform == \"win32\""} argon2 = ["argon2-cffi (>=19.1.0)"] bcrypt = ["bcrypt"] -[[package]] -name = "django-ajax-selects" -version = "2.2.1" -description = "Edit ForeignKey, ManyToManyField and CharField in Django Admin using jQuery UI AutoComplete." -optional = false -python-versions = "*" -files = [ - {file = "django-ajax-selects-2.2.1.tar.gz", hash = "sha256:996ffb38dff1a621b358613afdf2681dbf261e5976da3c30a75e9b08fd81a887"}, -] - [[package]] name = "django-countries" version = "7.6.1" @@ -2691,4 +2681,4 @@ filelock = ">=3.4" [metadata] lock-version = "2.0" python-versions = "^3.12" -content-hash = "cb47f6409e629d8369a19d82f44a57dbe9414c79e6e72bd88a6bcb34d78f0bc0" +content-hash = "e64ed169395d2c32672a2f2ad6a40d0910e4a51941b564fbdc505db6332084d2" diff --git a/pyproject.toml b/pyproject.toml index 7dc2e8dd..40086159 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -30,7 +30,6 @@ django-jinja = "^2.11" cryptography = "^43.0.0" django-phonenumber-field = "^8.0.0" phonenumbers = "^8.13" -django-ajax-selects = "^2.2.1" reportlab = "^4.2" django-haystack = "^3.2.1" xapian-haystack = "^3.0.1" diff --git a/rootplace/views.py b/rootplace/views.py index 4aefb8c3..4bd3c882 100644 --- a/rootplace/views.py +++ b/rootplace/views.py @@ -23,7 +23,6 @@ # import logging -from ajax_select.fields import AutoCompleteSelectField from django import forms from django.core.exceptions import PermissionDenied from django.urls import reverse @@ -35,6 +34,7 @@ from django.views.generic.edit import FormView from core.models import OperationLog, SithFile, User from core.views import CanEditPropMixin +from core.views.widgets.select import AutoCompleteSelectUser from counter.models import Customer from forum.models import ForumMessageMeta @@ -156,17 +156,29 @@ def delete_all_forum_user_messages( class MergeForm(forms.Form): - user1 = AutoCompleteSelectField( - "users", label=_("User that will be kept"), help_text=None, required=True + user1 = forms.ModelChoiceField( + label=_("User that will be kept"), + help_text=None, + required=True, + widget=AutoCompleteSelectUser, + queryset=User.objects.all(), ) - user2 = AutoCompleteSelectField( - "users", label=_("User that will be deleted"), help_text=None, required=True + user2 = forms.ModelChoiceField( + label=_("User that will be deleted"), + help_text=None, + required=True, + widget=AutoCompleteSelectUser, + queryset=User.objects.all(), ) class SelectUserForm(forms.Form): - user = AutoCompleteSelectField( - "users", label=_("User to be selected"), help_text=None, required=True + user = forms.ModelChoiceField( + label=_("User to be selected"), + help_text=None, + required=True, + widget=AutoCompleteSelectUser, + queryset=User.objects.all(), ) diff --git a/sas/api.py b/sas/api.py index ca4c10c6..6a25607a 100644 --- a/sas/api.py +++ b/sas/api.py @@ -1,3 +1,6 @@ +from typing import Annotated + +from annotated_types import MinLen from django.conf import settings from django.db.models import F from django.urls import reverse @@ -9,10 +12,11 @@ from ninja_extra.permissions import IsAuthenticated from ninja_extra.schemas import PaginatedResponseSchema from pydantic import NonNegativeInt -from core.api_permissions import CanView, IsInGroup, IsRoot +from core.api_permissions import CanAccessLookup, CanView, IsInGroup, IsRoot from core.models import Notification, User -from sas.models import PeoplePictureRelation, Picture +from sas.models import Album, PeoplePictureRelation, Picture from sas.schemas import ( + AlbumSchema, IdentifiedUserSchema, ModerationRequestSchema, PictureFilterSchema, @@ -22,6 +26,18 @@ from sas.schemas import ( IsSasAdmin = IsRoot | IsInGroup(settings.SITH_GROUP_SAS_ADMIN_ID) +@api_controller("/sas/album") +class AlbumController(ControllerBase): + @route.get( + "/search", + response=PaginatedResponseSchema[AlbumSchema], + permissions=[CanAccessLookup], + ) + @paginate(PageNumberPaginationExtra, page_size=50) + def search_album(self, search: Annotated[str, MinLen(1)]): + return Album.objects.filter(name__icontains=search) + + @api_controller("/sas/picture") class PicturesController(ControllerBase): @route.get( diff --git a/sas/forms.py b/sas/forms.py index 6569e92a..926fe6ca 100644 --- a/sas/forms.py +++ b/sas/forms.py @@ -1,14 +1,14 @@ from typing import Any -from ajax_select import make_ajax_field -from ajax_select.fields import AutoCompleteSelectMultipleField from django import forms from django.utils.translation import gettext_lazy as _ from core.models import User from core.views import MultipleImageField from core.views.forms import SelectDate -from sas.models import Album, PeoplePictureRelation, Picture, PictureModerationRequest +from core.views.widgets.select import AutoCompleteSelectMultipleGroup +from sas.models import Album, Picture, PictureModerationRequest +from sas.widgets.select import AutoCompleteSelectAlbum class SASForm(forms.Form): @@ -62,34 +62,24 @@ class SASForm(forms.Form): ) -class RelationForm(forms.ModelForm): - class Meta: - model = PeoplePictureRelation - fields = ["picture"] - widgets = {"picture": forms.HiddenInput} - - users = AutoCompleteSelectMultipleField( - "users", show_help_text=False, help_text="", label=_("Add user"), required=False - ) - - class PictureEditForm(forms.ModelForm): class Meta: model = Picture fields = ["name", "parent"] - - parent = make_ajax_field(Picture, "parent", "files", help_text="") + widgets = {"parent": AutoCompleteSelectAlbum} class AlbumEditForm(forms.ModelForm): class Meta: model = Album fields = ["name", "date", "file", "parent", "edit_groups"] + widgets = { + "parent": AutoCompleteSelectAlbum, + "edit_groups": AutoCompleteSelectMultipleGroup, + } name = forms.CharField(max_length=Album.NAME_MAX_LENGTH, label=_("file name")) date = forms.DateField(label=_("Date"), widget=SelectDate, required=True) - parent = make_ajax_field(Album, "parent", "files", help_text="") - edit_groups = make_ajax_field(Album, "edit_groups", "groups", help_text="") recursive = forms.BooleanField(label=_("Apply rights recursively"), required=False) diff --git a/sas/schemas.py b/sas/schemas.py index 6647f7d1..5e049858 100644 --- a/sas/schemas.py +++ b/sas/schemas.py @@ -1,11 +1,24 @@ from datetime import datetime +from pathlib import Path from django.urls import reverse from ninja import FilterSchema, ModelSchema, Schema from pydantic import Field, NonNegativeInt from core.schemas import SimpleUserSchema, UserProfileSchema -from sas.models import Picture, PictureModerationRequest +from sas.models import Album, Picture, PictureModerationRequest + + +class AlbumSchema(ModelSchema): + class Meta: + model = Album + fields = ["id", "name"] + + path: str + + @staticmethod + def resolve_path(obj: Album) -> str: + return str(Path(obj.get_parent_path()) / obj.name) class PictureFilterSchema(FilterSchema): diff --git a/sas/static/sas/css/picture.scss b/sas/static/sas/css/picture.scss index f62bb8bf..b86bc90a 100644 --- a/sas/static/sas/css/picture.scss +++ b/sas/static/sas/css/picture.scss @@ -189,32 +189,12 @@ } >form { - >p { - box-sizing: border-box; - } - - >.results_on_deck>div { - position: relative; - display: flex; - align-items: center; - word-break: break-word; - - >span { - position: absolute; - top: 0; - right: 0; - } - } - - input { + input, .ts-wrapper { min-width: 100%; max-width: 100%; + margin: 5px; box-sizing: border-box; } - - button { - font-weight: bold; - } } } } diff --git a/sas/static/webpack/sas/components/ajax-select-index.ts b/sas/static/webpack/sas/components/ajax-select-index.ts new file mode 100644 index 00000000..5b811f52 --- /dev/null +++ b/sas/static/webpack/sas/components/ajax-select-index.ts @@ -0,0 +1,30 @@ +import { AjaxSelect } from "#core:core/components/ajax-select-base"; +import { registerComponent } from "#core:utils/web-components"; +import type { TomOption } from "tom-select/dist/types/types"; +import type { escape_html } from "tom-select/dist/types/utils"; +import { type AlbumSchema, albumSearchAlbum } from "#openapi"; + +@registerComponent("album-ajax-select") +export class AlbumAjaxSelect extends AjaxSelect { + protected valueField = "id"; + protected labelField = "path"; + protected searchField = ["path", "name"]; + + protected async search(query: string): Promise { + const resp = await albumSearchAlbum({ query: { search: query } }); + if (resp.data) { + return resp.data.results; + } + return []; + } + + protected renderOption(item: AlbumSchema, sanitize: typeof escape_html) { + return `
+ ${sanitize(item.path)} +
`; + } + + protected renderItem(item: AlbumSchema, sanitize: typeof escape_html) { + return `${sanitize(item.path)}`; + } +} diff --git a/sas/static/webpack/sas/viewer-index.ts b/sas/static/webpack/sas/viewer-index.ts index b084810c..faa9505a 100644 --- a/sas/static/webpack/sas/viewer-index.ts +++ b/sas/static/webpack/sas/viewer-index.ts @@ -177,7 +177,7 @@ exportToHtml("loadViewer", (config: ViewerConfig) => { } as PicturesFetchPicturesData) ).map(PictureWithIdentifications.fromPicture); this.selector = this.$refs.search; - this.selector.filter = (users: UserProfileSchema[]) => { + this.selector.setFilter((users: UserProfileSchema[]) => { const resp: UserProfileSchema[] = []; const ids = [ ...(this.currentPicture.identifications || []).map( @@ -190,7 +190,7 @@ exportToHtml("loadViewer", (config: ViewerConfig) => { } } return resp; - }; + }); this.currentPicture = this.pictures.find( (i: PictureSchema) => i.id === config.firstPictureId, ); diff --git a/sas/templates/sas/picture.jinja b/sas/templates/sas/picture.jinja index 43651383..c14fe72b 100644 --- a/sas/templates/sas/picture.jinja +++ b/sas/templates/sas/picture.jinja @@ -1,12 +1,13 @@ {% extends "core/base.jinja" %} {%- block additional_css -%} - - + + + {%- endblock -%} {%- block additional_js -%} - + {%- endblock -%} @@ -157,12 +158,12 @@
{% trans %}People{% endtrans %}
{% if user.was_subscribed %}
- + delay="300" + placeholder="{%- trans -%}Identify users on pictures{%- endtrans -%}" + >
{% endif %} diff --git a/sas/widgets/select.py b/sas/widgets/select.py new file mode 100644 index 00000000..be5c3ebb --- /dev/null +++ b/sas/widgets/select.py @@ -0,0 +1,26 @@ +from pydantic import TypeAdapter + +from core.views.widgets.select import ( + AutoCompleteSelect, + AutoCompleteSelectMultiple, +) +from sas.models import Album +from sas.schemas import AlbumSchema + +_js = ["webpack/sas/components/ajax-select-index.ts"] + + +class AutoCompleteSelectAlbum(AutoCompleteSelect): + component_name = "album-ajax-select" + model = Album + adapter = TypeAdapter(list[AlbumSchema]) + + js = _js + + +class AutoCompleteSelectMultipleAlbum(AutoCompleteSelectMultiple): + component_name = "album-ajax-select" + model = Album + adapter = TypeAdapter(list[AlbumSchema]) + + js = _js diff --git a/sith/settings.py b/sith/settings.py index eb2fdb2d..b21ef444 100644 --- a/sith/settings.py +++ b/sith/settings.py @@ -81,7 +81,6 @@ INSTALLED_APPS = ( "honeypot", "django_jinja", "ninja_extra", - "ajax_select", "haystack", "captcha", "core", diff --git a/sith/urls.py b/sith/urls.py index 1979d06c..95f22662 100644 --- a/sith/urls.py +++ b/sith/urls.py @@ -13,7 +13,6 @@ # # -from ajax_select import urls as ajax_select_urls from django.conf import settings from django.conf.urls.static import static from django.contrib import admin @@ -59,7 +58,6 @@ urlpatterns = [ path("matmatronch/", include(("matmat.urls", "matmat"), namespace="matmat")), path("pedagogy/", include(("pedagogy.urls", "pedagogy"), namespace="pedagogy")), path("admin/", admin.site.urls), - path("ajax_select/", include(ajax_select_urls)), path("i18n/", include("django.conf.urls.i18n")), path("jsi18n/", JavaScriptCatalog.as_view(), name="javascript-catalog"), path("captcha/", include("captcha.urls")), diff --git a/subscription/views.py b/subscription/views.py index a508513f..4abc8e83 100644 --- a/subscription/views.py +++ b/subscription/views.py @@ -15,7 +15,6 @@ import random -from ajax_select.fields import AutoCompleteSelectField from django import forms from django.conf import settings from django.core.exceptions import PermissionDenied, ValidationError @@ -25,6 +24,7 @@ from django.views.generic.edit import CreateView, FormView from core.models import User from core.views.forms import SelectDate, SelectDateTime +from core.views.widgets.select import AutoCompleteSelectUser from subscription.models import Subscription @@ -43,11 +43,11 @@ class SubscriptionForm(forms.ModelForm): class Meta: model = Subscription fields = ["member", "subscription_type", "payment_method", "location"] - - member = AutoCompleteSelectField("users", required=False, help_text=None) + widgets = {"member": AutoCompleteSelectUser} def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) + self.fields["member"].required = False self.fields |= forms.fields_for_model( User, fields=["first_name", "last_name", "email", "date_of_birth"], diff --git a/trombi/views.py b/trombi/views.py index 8dd889cc..c5a1d205 100644 --- a/trombi/views.py +++ b/trombi/views.py @@ -24,7 +24,6 @@ from datetime import date -from ajax_select.fields import AutoCompleteSelectField from django import forms from django.conf import settings from django.contrib.auth.mixins import LoginRequiredMixin @@ -49,6 +48,7 @@ from core.views import ( TabedViewMixin, ) from core.views.forms import SelectDate +from core.views.widgets.select import AutoCompleteSelectUser from trombi.models import Trombi, TrombiClubMembership, TrombiComment, TrombiUser @@ -147,8 +147,12 @@ class TrombiEditView(CanEditPropMixin, TrombiTabsMixin, UpdateView): class AddUserForm(forms.Form): - user = AutoCompleteSelectField( - "users", required=True, label=_("Select user"), help_text=None + user = forms.ModelChoiceField( + label=_("Select user"), + help_text=None, + required=True, + widget=AutoCompleteSelectUser, + queryset=User.objects.all(), ) diff --git a/webpack.config.js b/webpack.config.js index ca2b7046..5ff49e1f 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -10,7 +10,7 @@ module.exports = { .sync("./!(static)/static/webpack/**/*?(-)index.[j|t]s?(x)") .reduce((obj, el) => { // We include the path inside the webpack folder in the name - const relativePath = []; + let relativePath = []; const fullPath = path.parse(el); for (const dir of fullPath.dir.split("/").reverse()) { if (dir === "webpack") { @@ -18,6 +18,8 @@ module.exports = { } relativePath.push(dir); } + // We collected folders in reverse order, we put them back in the original order + relativePath = relativePath.reverse(); relativePath.push(fullPath.name); obj[relativePath.join("/")] = `./${el}`; return obj;