mirror of
https://github.com/ae-utbm/sith.git
synced 2024-12-21 23:31:18 +00:00
Merge pull request #952 from ae-utbm/sort-producttypes
Sort product types
This commit is contained in:
commit
fad470b670
@ -1,5 +1,7 @@
|
||||
import sort from "@alpinejs/sort";
|
||||
import Alpine from "alpinejs";
|
||||
|
||||
Alpine.plugin(sort);
|
||||
window.Alpine = Alpine;
|
||||
|
||||
window.addEventListener("DOMContentLoaded", () => {
|
||||
|
@ -67,6 +67,8 @@ export class AutoCompleteSelectBase extends inheritHtmlElement("select") {
|
||||
remove_button: {
|
||||
title: gettext("Remove"),
|
||||
},
|
||||
// biome-ignore lint/style/useNamingConvention: this is required by the api
|
||||
restore_on_backspace: {},
|
||||
},
|
||||
persist: false,
|
||||
maxItems: this.node.multiple ? this.max : 1,
|
||||
|
@ -87,3 +87,21 @@ a:not(.button) {
|
||||
color: $primary-color;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
form {
|
||||
.row {
|
||||
label {
|
||||
margin: unset;
|
||||
}
|
||||
}
|
||||
|
||||
fieldset {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.helptext {
|
||||
margin-top: .25rem;
|
||||
font-size: 80%;
|
||||
}
|
||||
}
|
||||
|
@ -314,6 +314,17 @@ body {
|
||||
}
|
||||
}
|
||||
|
||||
.snackbar {
|
||||
width: 250px;
|
||||
margin-left: -125px;
|
||||
box-sizing: border-box;
|
||||
position: fixed;
|
||||
z-index: 1;
|
||||
left: 50%;
|
||||
top: 60px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.tabs {
|
||||
border-radius: 5px;
|
||||
|
||||
|
@ -52,7 +52,7 @@
|
||||
%}
|
||||
<li><a href="{{ url('counter:admin_list') }}">{% trans %}General counters management{% endtrans %}</a></li>
|
||||
<li><a href="{{ url('counter:product_list') }}">{% trans %}Products management{% endtrans %}</a></li>
|
||||
<li><a href="{{ url('counter:producttype_list') }}">{% trans %}Product types management{% endtrans %}</a></li>
|
||||
<li><a href="{{ url('counter:product_type_list') }}">{% trans %}Product types management{% endtrans %}</a></li>
|
||||
<li><a href="{{ url('counter:cash_summary_list') }}">{% trans %}Cash register summaries{% endtrans %}</a></li>
|
||||
<li><a href="{{ url('counter:invoices_call') }}">{% trans %}Invoices call{% endtrans %}</a></li>
|
||||
<li><a href="{{ url('counter:eticket_list') }}">{% trans %}Etickets{% endtrans %}</a></li>
|
||||
|
@ -129,7 +129,7 @@ class PermanencyAdmin(SearchModelAdmin):
|
||||
|
||||
@admin.register(ProductType)
|
||||
class ProductTypeAdmin(admin.ModelAdmin):
|
||||
list_display = ("name", "priority")
|
||||
list_display = ("name", "order")
|
||||
|
||||
|
||||
@admin.register(CashRegisterSummary)
|
||||
|
@ -12,24 +12,33 @@
|
||||
# OR WITHIN THE LOCAL FILE "LICENSE"
|
||||
#
|
||||
#
|
||||
from typing import Annotated
|
||||
|
||||
from annotated_types import MinLen
|
||||
from django.db.models import Q
|
||||
from django.conf import settings
|
||||
from django.db.models import F
|
||||
from django.shortcuts import get_object_or_404
|
||||
from ninja import Query
|
||||
from ninja_extra import ControllerBase, api_controller, paginate, route
|
||||
from ninja_extra.pagination import PageNumberPaginationExtra
|
||||
from ninja_extra.schemas import PaginatedResponseSchema
|
||||
|
||||
from core.api_permissions import CanAccessLookup, CanView, IsRoot
|
||||
from counter.models import Counter, Product
|
||||
from core.api_permissions import CanAccessLookup, CanView, IsInGroup, IsRoot
|
||||
from counter.models import Counter, Product, ProductType
|
||||
from counter.schemas import (
|
||||
CounterFilterSchema,
|
||||
CounterSchema,
|
||||
ProductFilterSchema,
|
||||
ProductSchema,
|
||||
ProductTypeSchema,
|
||||
ReorderProductTypeSchema,
|
||||
SimpleProductSchema,
|
||||
SimplifiedCounterSchema,
|
||||
)
|
||||
|
||||
IsCounterAdmin = (
|
||||
IsRoot
|
||||
| IsInGroup(settings.SITH_GROUP_COUNTER_ADMIN_ID)
|
||||
| IsInGroup(settings.SITH_GROUP_ACCOUNTING_ADMIN_ID)
|
||||
)
|
||||
|
||||
|
||||
@api_controller("/counter")
|
||||
class CounterController(ControllerBase):
|
||||
@ -64,15 +73,72 @@ class CounterController(ControllerBase):
|
||||
class ProductController(ControllerBase):
|
||||
@route.get(
|
||||
"/search",
|
||||
response=PaginatedResponseSchema[ProductSchema],
|
||||
response=PaginatedResponseSchema[SimpleProductSchema],
|
||||
permissions=[CanAccessLookup],
|
||||
)
|
||||
@paginate(PageNumberPaginationExtra, page_size=50)
|
||||
def search_products(self, search: Annotated[str, MinLen(1)]):
|
||||
return (
|
||||
Product.objects.filter(
|
||||
Q(name__icontains=search) | Q(code__icontains=search)
|
||||
)
|
||||
.filter(archived=False)
|
||||
.values()
|
||||
def search_products(self, filters: Query[ProductFilterSchema]):
|
||||
return filters.filter(
|
||||
Product.objects.order_by(
|
||||
F("product_type__order").asc(nulls_last=True),
|
||||
"product_type",
|
||||
"name",
|
||||
).values()
|
||||
)
|
||||
|
||||
@route.get(
|
||||
"/search/detailed",
|
||||
response=PaginatedResponseSchema[ProductSchema],
|
||||
permissions=[IsCounterAdmin],
|
||||
url_name="search_products_detailed",
|
||||
)
|
||||
@paginate(PageNumberPaginationExtra, page_size=50)
|
||||
def search_products_detailed(self, filters: Query[ProductFilterSchema]):
|
||||
"""Get the detailed information about the products."""
|
||||
return filters.filter(
|
||||
Product.objects.select_related("club")
|
||||
.prefetch_related("buying_groups")
|
||||
.select_related("product_type")
|
||||
.order_by(
|
||||
F("product_type__order").asc(nulls_last=True),
|
||||
"product_type",
|
||||
"name",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@api_controller("/product-type", permissions=[IsCounterAdmin])
|
||||
class ProductTypeController(ControllerBase):
|
||||
@route.get("", response=list[ProductTypeSchema], url_name="fetch_product_types")
|
||||
def fetch_all(self):
|
||||
return ProductType.objects.order_by("order")
|
||||
|
||||
@route.patch("/{type_id}/move")
|
||||
def reorder(self, type_id: int, other_id: Query[ReorderProductTypeSchema]):
|
||||
"""Change the order of a product type.
|
||||
|
||||
To use this route, give either the id of the product type
|
||||
this one should be above of,
|
||||
of the id of the product type this one should be below of.
|
||||
|
||||
Order affects the display order of the product types.
|
||||
|
||||
Examples:
|
||||
```
|
||||
GET /api/counter/product-type
|
||||
=> [<1: type A>, <2: type B>, <3: type C>]
|
||||
|
||||
PATCH /api/counter/product-type/3/move?below=1
|
||||
|
||||
GET /api/counter/product-type
|
||||
=> [<1: type A>, <3: type C>, <2: type B>]
|
||||
```
|
||||
"""
|
||||
product_type: ProductType = self.get_object_or_exception(
|
||||
ProductType, pk=type_id
|
||||
)
|
||||
other = get_object_or_404(ProductType, pk=other_id.above or other_id.below)
|
||||
if other_id.below is not None:
|
||||
product_type.below(other)
|
||||
else:
|
||||
product_type.above(other)
|
||||
|
@ -0,0 +1,62 @@
|
||||
# Generated by Django 4.2.17 on 2024-12-15 17:53
|
||||
|
||||
from django.db import migrations, models
|
||||
from django.db.migrations.state import StateApps
|
||||
|
||||
|
||||
def move_priority_to_order(apps: StateApps, schema_editor):
|
||||
"""Migrate the previous homemade `priority` to `OrderedModel.order`.
|
||||
|
||||
`priority` was a system were click managers set themselves the priority
|
||||
of a ProductType.
|
||||
The higher the priority, the higher it was to be displayed in the eboutic.
|
||||
Multiple product types could share the same priority, in which
|
||||
case they were ordered by alphabetic order.
|
||||
|
||||
The new field is unique per object, and works in the other way :
|
||||
the nearer from 0, the higher it should appear.
|
||||
"""
|
||||
ProductType = apps.get_model("counter", "ProductType")
|
||||
product_types = list(ProductType.objects.order_by("-priority", "name"))
|
||||
for order, product_type in enumerate(product_types):
|
||||
product_type.order = order
|
||||
ProductType.objects.bulk_update(product_types, ["order"])
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [("counter", "0027_alter_refilling_payment_method")]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="producttype",
|
||||
name="comment",
|
||||
field=models.TextField(
|
||||
default="",
|
||||
help_text="A text that will be shown on the eboutic.",
|
||||
verbose_name="comment",
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="producttype",
|
||||
name="description",
|
||||
field=models.TextField(default="", verbose_name="description"),
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name="producttype",
|
||||
options={"ordering": ["order"], "verbose_name": "product type"},
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="producttype",
|
||||
name="order",
|
||||
field=models.PositiveIntegerField(
|
||||
db_index=True, default=0, editable=False, verbose_name="order"
|
||||
),
|
||||
preserve_default=False,
|
||||
),
|
||||
migrations.RunPython(
|
||||
move_priority_to_order,
|
||||
reverse_code=migrations.RunPython.noop,
|
||||
elidable=True,
|
||||
),
|
||||
migrations.RemoveField(model_name="producttype", name="priority"),
|
||||
]
|
@ -35,6 +35,7 @@ from django.utils import timezone
|
||||
from django.utils.functional import cached_property
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django_countries.fields import CountryField
|
||||
from ordered_model.models import OrderedModel
|
||||
from phonenumber_field.modelfields import PhoneNumberField
|
||||
|
||||
from accounting.models import CurrencyField
|
||||
@ -289,32 +290,32 @@ class AccountDump(models.Model):
|
||||
)
|
||||
|
||||
|
||||
class ProductType(models.Model):
|
||||
class ProductType(OrderedModel):
|
||||
"""A product type.
|
||||
|
||||
Useful only for categorizing.
|
||||
"""
|
||||
|
||||
name = models.CharField(_("name"), max_length=30)
|
||||
description = models.TextField(_("description"), null=True, blank=True)
|
||||
comment = models.TextField(_("comment"), null=True, blank=True)
|
||||
description = models.TextField(_("description"), default="")
|
||||
comment = models.TextField(
|
||||
_("comment"),
|
||||
default="",
|
||||
help_text=_("A text that will be shown on the eboutic."),
|
||||
)
|
||||
icon = ResizedImageField(
|
||||
height=70, force_format="WEBP", upload_to="products", null=True, blank=True
|
||||
)
|
||||
|
||||
# priority holds no real backend logic but helps to handle the order in which
|
||||
# the items are to be shown to the user
|
||||
priority = models.PositiveIntegerField(default=0)
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("product type")
|
||||
ordering = ["-priority", "name"]
|
||||
ordering = ["order"]
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse("counter:producttype_list")
|
||||
return reverse("counter:product_type_list")
|
||||
|
||||
def is_owned_by(self, user):
|
||||
"""Method to see if that object can be edited by the given user."""
|
||||
|
@ -1,10 +1,13 @@
|
||||
from typing import Annotated
|
||||
from typing import Annotated, Self
|
||||
|
||||
from annotated_types import MinLen
|
||||
from ninja import Field, FilterSchema, ModelSchema
|
||||
from django.urls import reverse
|
||||
from ninja import Field, FilterSchema, ModelSchema, Schema
|
||||
from pydantic import model_validator
|
||||
|
||||
from core.schemas import SimpleUserSchema
|
||||
from counter.models import Counter, Product
|
||||
from club.schemas import ClubSchema
|
||||
from core.schemas import GroupSchema, SimpleUserSchema
|
||||
from counter.models import Counter, Product, ProductType
|
||||
|
||||
|
||||
class CounterSchema(ModelSchema):
|
||||
@ -26,7 +29,72 @@ class SimplifiedCounterSchema(ModelSchema):
|
||||
fields = ["id", "name"]
|
||||
|
||||
|
||||
class ProductSchema(ModelSchema):
|
||||
class ProductTypeSchema(ModelSchema):
|
||||
class Meta:
|
||||
model = ProductType
|
||||
fields = ["id", "name", "description", "comment", "icon", "order"]
|
||||
|
||||
url: str
|
||||
|
||||
@staticmethod
|
||||
def resolve_url(obj: ProductType) -> str:
|
||||
return reverse("counter:product_type_edit", kwargs={"type_id": obj.id})
|
||||
|
||||
|
||||
class SimpleProductTypeSchema(ModelSchema):
|
||||
class Meta:
|
||||
model = ProductType
|
||||
fields = ["id", "name"]
|
||||
|
||||
|
||||
class ReorderProductTypeSchema(Schema):
|
||||
below: int | None = None
|
||||
above: int | None = None
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_exclusive(self) -> Self:
|
||||
if self.below is None and self.above is None:
|
||||
raise ValueError("Either 'below' or 'above' must be set.")
|
||||
if self.below is not None and self.above is not None:
|
||||
raise ValueError("Only one of 'below' or 'above' must be set.")
|
||||
return self
|
||||
|
||||
|
||||
class SimpleProductSchema(ModelSchema):
|
||||
class Meta:
|
||||
model = Product
|
||||
fields = ["id", "name", "code"]
|
||||
|
||||
|
||||
class ProductSchema(ModelSchema):
|
||||
class Meta:
|
||||
model = Product
|
||||
fields = [
|
||||
"id",
|
||||
"name",
|
||||
"code",
|
||||
"description",
|
||||
"purchase_price",
|
||||
"selling_price",
|
||||
"icon",
|
||||
"limit_age",
|
||||
"archived",
|
||||
]
|
||||
|
||||
buying_groups: list[GroupSchema]
|
||||
club: ClubSchema
|
||||
product_type: SimpleProductTypeSchema | None
|
||||
url: str
|
||||
|
||||
@staticmethod
|
||||
def resolve_url(obj: Product) -> str:
|
||||
return reverse("counter:product_edit", kwargs={"product_id": obj.id})
|
||||
|
||||
|
||||
class ProductFilterSchema(FilterSchema):
|
||||
search: Annotated[str, MinLen(1)] | None = Field(
|
||||
None, q=["name__icontains", "code__icontains"]
|
||||
)
|
||||
is_archived: bool | None = Field(None, q="archived")
|
||||
buying_groups: set[int] | None = Field(None, q="buying_groups__in")
|
||||
product_type: set[int] | None = Field(None, q="product_type__in")
|
||||
|
@ -4,7 +4,7 @@ import type { TomOption } from "tom-select/dist/types/types";
|
||||
import type { escape_html } from "tom-select/dist/types/utils";
|
||||
import {
|
||||
type CounterSchema,
|
||||
type ProductSchema,
|
||||
type SimpleProductSchema,
|
||||
counterSearchCounter,
|
||||
productSearchProducts,
|
||||
} from "#openapi";
|
||||
@ -23,13 +23,13 @@ export class ProductAjaxSelect extends AjaxSelect {
|
||||
return [];
|
||||
}
|
||||
|
||||
protected renderOption(item: ProductSchema, sanitize: typeof escape_html) {
|
||||
protected renderOption(item: SimpleProductSchema, sanitize: typeof escape_html) {
|
||||
return `<div class="select-item">
|
||||
<span class="select-item-text">${sanitize(item.code)} - ${sanitize(item.name)}</span>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
protected renderItem(item: ProductSchema, sanitize: typeof escape_html) {
|
||||
protected renderItem(item: SimpleProductSchema, sanitize: typeof escape_html) {
|
||||
return `<span>${sanitize(item.code)} - ${sanitize(item.name)}</span>`;
|
||||
}
|
||||
}
|
||||
|
64
counter/static/bundled/counter/product-type-index.ts
Normal file
64
counter/static/bundled/counter/product-type-index.ts
Normal file
@ -0,0 +1,64 @@
|
||||
import Alpine from "alpinejs";
|
||||
import { producttypeReorder } from "#openapi";
|
||||
|
||||
document.addEventListener("alpine:init", () => {
|
||||
Alpine.data("productTypesList", () => ({
|
||||
loading: false,
|
||||
alertMessage: {
|
||||
open: false,
|
||||
success: true,
|
||||
content: "",
|
||||
timeout: null,
|
||||
},
|
||||
|
||||
async reorder(itemId: number, newPosition: number) {
|
||||
// The sort plugin of Alpine doesn't manage dynamic lists with x-sort
|
||||
// (cf. https://github.com/alpinejs/alpine/discussions/4157).
|
||||
// There is an open PR that fixes this issue
|
||||
// (cf. https://github.com/alpinejs/alpine/pull/4361).
|
||||
// However, it hasn't been merged yet.
|
||||
// To overcome this, I get the list of DOM elements
|
||||
// And fetch the `x-sort:item` attribute, which value is
|
||||
// the id of the object in database.
|
||||
// Please make this a little bit cleaner when the fix has been merged
|
||||
// into the main Alpine repo.
|
||||
this.loading = true;
|
||||
const productTypes = this.$refs.productTypes
|
||||
.childNodes as NodeListOf<HTMLLIElement>;
|
||||
const getId = (elem: HTMLLIElement) =>
|
||||
Number.parseInt(elem.getAttribute("x-sort:item"));
|
||||
const query =
|
||||
newPosition === 0
|
||||
? { above: getId(productTypes.item(1)) }
|
||||
: { below: getId(productTypes.item(newPosition - 1)) };
|
||||
const response = await producttypeReorder({
|
||||
// biome-ignore lint/style/useNamingConvention: api is snake_case
|
||||
path: { type_id: itemId },
|
||||
query: query,
|
||||
});
|
||||
this.openAlertMessage(response.response);
|
||||
this.loading = false;
|
||||
},
|
||||
|
||||
openAlertMessage(response: Response) {
|
||||
if (response.ok) {
|
||||
this.alertMessage.success = true;
|
||||
this.alertMessage.content = gettext("Products types successfully reordered");
|
||||
} else {
|
||||
this.alertMessage.success = false;
|
||||
this.alertMessage.content = interpolate(
|
||||
gettext("Product type reorganisation failed with status code : %d"),
|
||||
[response.status],
|
||||
);
|
||||
}
|
||||
this.alertMessage.open = true;
|
||||
if (this.alertMessage.timeout !== null) {
|
||||
clearTimeout(this.alertMessage.timeout);
|
||||
}
|
||||
this.alertMessage.timeout = setTimeout(() => {
|
||||
this.alertMessage.open = false;
|
||||
}, 2000);
|
||||
this.loading = false;
|
||||
},
|
||||
}));
|
||||
});
|
15
counter/static/counter/css/product_type.scss
Normal file
15
counter/static/counter/css/product_type.scss
Normal file
@ -0,0 +1,15 @@
|
||||
.product-type-list {
|
||||
li {
|
||||
list-style: none;
|
||||
margin-bottom: 10px;
|
||||
|
||||
i {
|
||||
cursor: grab;
|
||||
visibility: hidden;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
body:not(.sorting) .product-type-list li:hover i {
|
||||
visibility: visible;
|
||||
}
|
64
counter/templates/counter/product_type_list.jinja
Normal file
64
counter/templates/counter/product_type_list.jinja
Normal file
@ -0,0 +1,64 @@
|
||||
{% extends "core/base.jinja" %}
|
||||
|
||||
{% block title %}
|
||||
{% trans %}Product type list{% endtrans %}
|
||||
{% endblock %}
|
||||
|
||||
{% block additional_css %}
|
||||
<link rel="stylesheet" href="{{ static("counter/css/product_type.scss") }}">
|
||||
{% endblock %}
|
||||
|
||||
{% block additional_js %}
|
||||
<script type="module" src="{{ static("bundled/counter/product-type-index.ts") }}"></script>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<p>
|
||||
<a href="{{ url('counter:new_product_type') }}" class="btn btn-blue">
|
||||
{% trans %}New product type{% endtrans %}
|
||||
<i class="fa fa-plus"></i>
|
||||
</a>
|
||||
</p>
|
||||
{% if product_types %}
|
||||
<aside>
|
||||
<p>
|
||||
{% trans %}Product types are in the same order on this page and on the eboutic.{% endtrans %}
|
||||
</p>
|
||||
<p>
|
||||
{% trans trimmed %}
|
||||
You can reorder them here by drag-and-drop.
|
||||
The changes will then be applied globally immediately.
|
||||
{% endtrans %}
|
||||
</p>
|
||||
</aside>
|
||||
<div x-data="productTypesList">
|
||||
<p
|
||||
class="alert snackbar"
|
||||
:class="alertMessage.success ? 'alert-green' : 'alert-red'"
|
||||
x-show="alertMessage.open"
|
||||
x-transition.duration.500ms
|
||||
x-text="alertMessage.content"
|
||||
></p>
|
||||
<h3>{% trans %}Product type list{% endtrans %}</h3>
|
||||
<ul
|
||||
x-sort="($item, $position) => reorder($item, $position)"
|
||||
x-ref="productTypes"
|
||||
class="product-type-list"
|
||||
:aria-busy="loading"
|
||||
>
|
||||
{%- for product_type in product_types -%}
|
||||
<li x-sort:item="{{ product_type.id }}">
|
||||
<i class="fa fa-grip-vertical"></i>
|
||||
<a href="{{ url('counter:product_type_edit', type_id=product_type.id) }}">
|
||||
{{ product_type.name }}
|
||||
</a>
|
||||
</li>
|
||||
{%- endfor -%}
|
||||
</ul>
|
||||
</div>
|
||||
{% else %}
|
||||
<p>
|
||||
{% trans %}There are no product types in this website.{% endtrans %}
|
||||
</p>
|
||||
{% endif %}
|
||||
{% endblock %}
|
@ -1,24 +0,0 @@
|
||||
{% extends "core/base.jinja" %}
|
||||
|
||||
{% block title %}
|
||||
{% trans %}Product type list{% endtrans %}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<p><a href="{{ url('counter:new_producttype') }}">{% trans %}New product type{% endtrans %}</a></p>
|
||||
{% if producttype_list %}
|
||||
<h3>{% trans %}Product type list{% endtrans %}</h3>
|
||||
<ul>
|
||||
{% for t in producttype_list %}
|
||||
<li><a href="{{ url('counter:producttype_edit', type_id=t.id) }}">{{ t }}</a></li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% else %}
|
||||
{% trans %}There is no product types in this website.{% endtrans %}
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
|
||||
|
||||
|
||||
|
@ -1,11 +1,19 @@
|
||||
from io import BytesIO
|
||||
from typing import Callable
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from django.conf import settings
|
||||
from django.core.cache import cache
|
||||
from django.core.files.uploadedfile import SimpleUploadedFile
|
||||
from django.test import Client
|
||||
from django.urls import reverse
|
||||
from model_bakery import baker
|
||||
from PIL import Image
|
||||
from pytest_django.asserts import assertNumQueries
|
||||
|
||||
from core.baker_recipes import board_user, subscriber_user
|
||||
from core.models import RealGroup, User
|
||||
from counter.models import Product, ProductType
|
||||
|
||||
|
||||
@ -31,3 +39,50 @@ def test_resize_product_icon(model):
|
||||
assert product.icon.height == 70
|
||||
assert product.icon.name == f"products/{name}.webp"
|
||||
assert Image.open(product.icon).format == "WEBP"
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@pytest.mark.parametrize(
|
||||
("user_factory", "status_code"),
|
||||
[
|
||||
(lambda: baker.make(User, is_superuser=True), 200),
|
||||
(board_user.make, 403),
|
||||
(subscriber_user.make, 403),
|
||||
(
|
||||
lambda: baker.make(
|
||||
User,
|
||||
groups=[RealGroup.objects.get(pk=settings.SITH_GROUP_COUNTER_ADMIN_ID)],
|
||||
),
|
||||
200,
|
||||
),
|
||||
(
|
||||
lambda: baker.make(
|
||||
User,
|
||||
groups=[
|
||||
RealGroup.objects.get(pk=settings.SITH_GROUP_ACCOUNTING_ADMIN_ID)
|
||||
],
|
||||
),
|
||||
200,
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_fetch_product_access(
|
||||
client: Client, user_factory: Callable[[], User], status_code: int
|
||||
):
|
||||
"""Test that only authorized users can use the `GET /product` route."""
|
||||
client.force_login(user_factory())
|
||||
assert (
|
||||
client.get(reverse("api:search_products_detailed")).status_code == status_code
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_fetch_product_nb_queries(client: Client):
|
||||
client.force_login(baker.make(User, is_superuser=True))
|
||||
cache.clear()
|
||||
with assertNumQueries(5):
|
||||
# - 2 for authentication
|
||||
# - 1 for pagination
|
||||
# - 1 for the actual request
|
||||
# - 1 to prefetch the related buying_groups
|
||||
client.get(reverse("api:search_products_detailed"))
|
||||
|
91
counter/tests/test_product_type.py
Normal file
91
counter/tests/test_product_type.py
Normal file
@ -0,0 +1,91 @@
|
||||
import pytest
|
||||
from django.conf import settings
|
||||
from django.test import Client
|
||||
from django.urls import reverse
|
||||
from model_bakery import baker, seq
|
||||
from ninja_extra.testing import TestClient
|
||||
|
||||
from core.baker_recipes import board_user, subscriber_user
|
||||
from core.models import RealGroup, User
|
||||
from counter.api import ProductTypeController
|
||||
from counter.models import ProductType
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def product_types(db) -> list[ProductType]:
|
||||
"""All existing product types, ordered by their `order` field"""
|
||||
# delete product types that have been created in the `populate` command
|
||||
ProductType.objects.all().delete()
|
||||
return baker.make(ProductType, _quantity=5, order=seq(0))
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_fetch_product_types(product_types: list[ProductType]):
|
||||
"""Test that the API returns the right products in the right order"""
|
||||
client = TestClient(ProductTypeController)
|
||||
response = client.get("")
|
||||
assert response.status_code == 200
|
||||
assert [i["id"] for i in response.json()] == [t.id for t in product_types]
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_move_below_product_type(product_types: list[ProductType]):
|
||||
"""Test that moving a product below another works"""
|
||||
client = TestClient(ProductTypeController)
|
||||
response = client.patch(
|
||||
f"/{product_types[-1].id}/move", query={"below": product_types[0].id}
|
||||
)
|
||||
assert response.status_code == 200
|
||||
new_order = [i["id"] for i in client.get("").json()]
|
||||
assert new_order == [
|
||||
product_types[0].id,
|
||||
product_types[-1].id,
|
||||
*[t.id for t in product_types[1:-1]],
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_move_above_product_type(product_types: list[ProductType]):
|
||||
"""Test that moving a product above another works"""
|
||||
client = TestClient(ProductTypeController)
|
||||
response = client.patch(
|
||||
f"/{product_types[1].id}/move", query={"above": product_types[0].id}
|
||||
)
|
||||
assert response.status_code == 200
|
||||
new_order = [i["id"] for i in client.get("").json()]
|
||||
assert new_order == [
|
||||
product_types[1].id,
|
||||
product_types[0].id,
|
||||
*[t.id for t in product_types[2:]],
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@pytest.mark.parametrize(
|
||||
("user_factory", "status_code"),
|
||||
[
|
||||
(lambda: baker.make(User, is_superuser=True), 200),
|
||||
(subscriber_user.make, 403),
|
||||
(board_user.make, 403),
|
||||
(
|
||||
lambda: baker.make(
|
||||
User,
|
||||
groups=[RealGroup.objects.get(pk=settings.SITH_GROUP_COUNTER_ADMIN_ID)],
|
||||
),
|
||||
200,
|
||||
),
|
||||
(
|
||||
lambda: baker.make(
|
||||
User,
|
||||
groups=[
|
||||
RealGroup.objects.get(pk=settings.SITH_GROUP_ACCOUNTING_ADMIN_ID)
|
||||
],
|
||||
),
|
||||
200,
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_controller_permissions(client: Client, user_factory, status_code):
|
||||
client.force_login(user_factory())
|
||||
response = client.get(reverse("api:fetch_product_types"))
|
||||
assert response.status_code == status_code
|
@ -121,19 +121,19 @@ urlpatterns = [
|
||||
name="product_edit",
|
||||
),
|
||||
path(
|
||||
"admin/producttype/list/",
|
||||
"admin/product-type/list/",
|
||||
ProductTypeListView.as_view(),
|
||||
name="producttype_list",
|
||||
name="product_type_list",
|
||||
),
|
||||
path(
|
||||
"admin/producttype/create/",
|
||||
"admin/product-type/create/",
|
||||
ProductTypeCreateView.as_view(),
|
||||
name="new_producttype",
|
||||
name="new_product_type",
|
||||
),
|
||||
path(
|
||||
"admin/producttype/<int:type_id>/",
|
||||
"admin/product-type/<int:type_id>/",
|
||||
ProductTypeEditView.as_view(),
|
||||
name="producttype_edit",
|
||||
name="product_type_edit",
|
||||
),
|
||||
path("admin/eticket/list/", EticketListView.as_view(), name="eticket_list"),
|
||||
path("admin/eticket/new/", EticketCreateView.as_view(), name="new_eticket"),
|
||||
|
@ -101,15 +101,16 @@ class ProductTypeListView(CounterAdminTabsMixin, CounterAdminMixin, ListView):
|
||||
"""A list view for the admins."""
|
||||
|
||||
model = ProductType
|
||||
template_name = "counter/producttype_list.jinja"
|
||||
template_name = "counter/product_type_list.jinja"
|
||||
current_tab = "product_types"
|
||||
context_object_name = "product_types"
|
||||
|
||||
|
||||
class ProductTypeCreateView(CounterAdminTabsMixin, CounterAdminMixin, CreateView):
|
||||
"""A create view for the admins."""
|
||||
|
||||
model = ProductType
|
||||
fields = ["name", "description", "comment", "icon", "priority"]
|
||||
fields = ["name", "description", "comment", "icon"]
|
||||
template_name = "core/create.jinja"
|
||||
current_tab = "products"
|
||||
|
||||
@ -119,7 +120,7 @@ class ProductTypeEditView(CounterAdminTabsMixin, CounterAdminMixin, UpdateView):
|
||||
|
||||
model = ProductType
|
||||
template_name = "core/edit.jinja"
|
||||
fields = ["name", "description", "comment", "icon", "priority"]
|
||||
fields = ["name", "description", "comment", "icon"]
|
||||
pk_url_kwarg = "type_id"
|
||||
current_tab = "products"
|
||||
|
||||
@ -129,7 +130,7 @@ class ProductListView(CounterAdminTabsMixin, CounterAdminMixin, ListView):
|
||||
queryset = Product.objects.values("id", "name", "code", "product_type__name")
|
||||
template_name = "counter/product_list.jinja"
|
||||
ordering = [
|
||||
F("product_type__priority").desc(nulls_last=True),
|
||||
F("product_type__order").asc(nulls_last=True),
|
||||
"product_type",
|
||||
"name",
|
||||
]
|
||||
|
@ -99,7 +99,7 @@ class CounterAdminTabsMixin(TabedViewMixin):
|
||||
"name": _("Archived products"),
|
||||
},
|
||||
{
|
||||
"url": reverse_lazy("counter:producttype_list"),
|
||||
"url": reverse_lazy("counter:product_type_list"),
|
||||
"slug": "product_types",
|
||||
"name": _("Product types"),
|
||||
},
|
||||
|
@ -2,7 +2,7 @@ from pydantic import TypeAdapter
|
||||
|
||||
from core.views.widgets.select import AutoCompleteSelect, AutoCompleteSelectMultiple
|
||||
from counter.models import Counter, Product
|
||||
from counter.schemas import ProductSchema, SimplifiedCounterSchema
|
||||
from counter.schemas import SimpleProductSchema, SimplifiedCounterSchema
|
||||
|
||||
_js = ["bundled/counter/components/ajax-select-index.ts"]
|
||||
|
||||
@ -24,12 +24,12 @@ class AutoCompleteSelectMultipleCounter(AutoCompleteSelectMultiple):
|
||||
class AutoCompleteSelectProduct(AutoCompleteSelect):
|
||||
component_name = "product-ajax-select"
|
||||
model = Product
|
||||
adapter = TypeAdapter(list[ProductSchema])
|
||||
adapter = TypeAdapter(list[SimpleProductSchema])
|
||||
js = _js
|
||||
|
||||
|
||||
class AutoCompleteSelectMultipleProduct(AutoCompleteSelectMultiple):
|
||||
component_name = "product-ajax-select"
|
||||
model = Product
|
||||
adapter = TypeAdapter(list[ProductSchema])
|
||||
adapter = TypeAdapter(list[SimpleProductSchema])
|
||||
js = _js
|
||||
|
@ -37,8 +37,11 @@ Il faut d'abord générer un fichier de traductions,
|
||||
l'éditer et enfin le compiler au format binaire pour qu'il soit lu par le serveur.
|
||||
|
||||
```bash
|
||||
./manage.py makemessages --locale=fr -e py,jinja --ignore=node_modules # Pour le backend
|
||||
./manage.py makemessages --locale=fr -d djangojs -e js,ts --ignore=node_modules # Pour le frontend
|
||||
# Pour le backend
|
||||
./manage.py makemessages --locale=fr -e py,jinja --ignore=node_modules
|
||||
|
||||
# Pour le frontend
|
||||
./manage.py makemessages --locale=fr -d djangojs -e js,ts --ignore=node_modules --ignore=staticfiles/generated
|
||||
```
|
||||
|
||||
## Éditer le fichier django.po
|
||||
|
@ -36,7 +36,7 @@ def get_eboutic_products(user: User) -> list[Product]:
|
||||
.products.filter(product_type__isnull=False)
|
||||
.filter(archived=False)
|
||||
.filter(limit_age__lte=user.age)
|
||||
.annotate(priority=F("product_type__priority"))
|
||||
.annotate(order=F("product_type__order"))
|
||||
.annotate(category=F("product_type__name"))
|
||||
.annotate(category_comment=F("product_type__comment"))
|
||||
.prefetch_related("buying_groups") # <-- used in `Product.can_be_sold_to`
|
||||
|
@ -88,7 +88,7 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% for priority_groups in products|groupby('priority')|reverse %}
|
||||
{% for priority_groups in products|groupby('order') %}
|
||||
{% for category, items in priority_groups.list|groupby('category') %}
|
||||
{% if items|count > 0 %}
|
||||
<section>
|
||||
|
@ -6,7 +6,7 @@
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2024-12-17 10:53+0100\n"
|
||||
"POT-Creation-Date: 2024-12-17 17:04+0100\n"
|
||||
"PO-Revision-Date: 2016-07-18\n"
|
||||
"Last-Translator: Maréchal <thomas.girod@utbm.fr\n"
|
||||
"Language-Team: AE info <ae.info@utbm.fr>\n"
|
||||
@ -18,8 +18,8 @@ msgstr ""
|
||||
|
||||
#: accounting/models.py:62 accounting/models.py:101 accounting/models.py:132
|
||||
#: accounting/models.py:190 club/models.py:55 com/models.py:274
|
||||
#: com/models.py:293 counter/models.py:298 counter/models.py:329
|
||||
#: counter/models.py:480 forum/models.py:60 launderette/models.py:29
|
||||
#: com/models.py:293 counter/models.py:299 counter/models.py:330
|
||||
#: counter/models.py:481 forum/models.py:60 launderette/models.py:29
|
||||
#: launderette/models.py:80 launderette/models.py:116
|
||||
msgid "name"
|
||||
msgstr "nom"
|
||||
@ -65,8 +65,8 @@ msgid "account number"
|
||||
msgstr "numéro de compte"
|
||||
|
||||
#: accounting/models.py:107 accounting/models.py:136 club/models.py:345
|
||||
#: com/models.py:74 com/models.py:259 com/models.py:299 counter/models.py:358
|
||||
#: counter/models.py:482 trombi/models.py:209
|
||||
#: com/models.py:74 com/models.py:259 com/models.py:299 counter/models.py:359
|
||||
#: counter/models.py:483 trombi/models.py:209
|
||||
msgid "club"
|
||||
msgstr "club"
|
||||
|
||||
@ -87,12 +87,12 @@ msgstr "Compte club"
|
||||
msgid "%(club_account)s on %(bank_account)s"
|
||||
msgstr "%(club_account)s sur %(bank_account)s"
|
||||
|
||||
#: accounting/models.py:188 club/models.py:351 counter/models.py:965
|
||||
#: accounting/models.py:188 club/models.py:351 counter/models.py:966
|
||||
#: election/models.py:16 launderette/models.py:165
|
||||
msgid "start date"
|
||||
msgstr "date de début"
|
||||
|
||||
#: accounting/models.py:189 club/models.py:352 counter/models.py:966
|
||||
#: accounting/models.py:189 club/models.py:352 counter/models.py:967
|
||||
#: election/models.py:17
|
||||
msgid "end date"
|
||||
msgstr "date de fin"
|
||||
@ -105,8 +105,8 @@ msgstr "est fermé"
|
||||
msgid "club account"
|
||||
msgstr "compte club"
|
||||
|
||||
#: accounting/models.py:199 accounting/models.py:255 counter/models.py:92
|
||||
#: counter/models.py:683
|
||||
#: accounting/models.py:199 accounting/models.py:255 counter/models.py:93
|
||||
#: counter/models.py:684
|
||||
msgid "amount"
|
||||
msgstr "montant"
|
||||
|
||||
@ -128,18 +128,18 @@ msgstr "classeur"
|
||||
|
||||
#: accounting/models.py:256 core/models.py:956 core/models.py:1467
|
||||
#: core/models.py:1512 core/models.py:1541 core/models.py:1565
|
||||
#: counter/models.py:693 counter/models.py:797 counter/models.py:1001
|
||||
#: counter/models.py:694 counter/models.py:798 counter/models.py:1002
|
||||
#: eboutic/models.py:57 eboutic/models.py:193 forum/models.py:312
|
||||
#: forum/models.py:413
|
||||
msgid "date"
|
||||
msgstr "date"
|
||||
|
||||
#: accounting/models.py:257 counter/models.py:300 counter/models.py:1002
|
||||
#: accounting/models.py:257 counter/models.py:302 counter/models.py:1003
|
||||
#: pedagogy/models.py:208
|
||||
msgid "comment"
|
||||
msgstr "commentaire"
|
||||
|
||||
#: accounting/models.py:259 counter/models.py:695 counter/models.py:799
|
||||
#: accounting/models.py:259 counter/models.py:696 counter/models.py:800
|
||||
#: subscription/models.py:56
|
||||
msgid "payment method"
|
||||
msgstr "méthode de paiement"
|
||||
@ -166,7 +166,7 @@ msgstr "type comptable"
|
||||
|
||||
#: accounting/models.py:294 accounting/models.py:429 accounting/models.py:460
|
||||
#: accounting/models.py:492 core/models.py:1540 core/models.py:1566
|
||||
#: counter/models.py:763
|
||||
#: counter/models.py:764
|
||||
msgid "label"
|
||||
msgstr "étiquette"
|
||||
|
||||
@ -264,7 +264,7 @@ msgstr ""
|
||||
"Vous devez fournir soit un type comptable simplifié ou un type comptable "
|
||||
"standard"
|
||||
|
||||
#: accounting/models.py:421 counter/models.py:339 pedagogy/models.py:41
|
||||
#: accounting/models.py:421 counter/models.py:340 pedagogy/models.py:41
|
||||
msgid "code"
|
||||
msgstr "code"
|
||||
|
||||
@ -1041,7 +1041,7 @@ msgstr "Vous ne pouvez pas faire de boucles dans les clubs"
|
||||
msgid "A club with that unix_name already exists"
|
||||
msgstr "Un club avec ce nom UNIX existe déjà."
|
||||
|
||||
#: club/models.py:337 counter/models.py:956 counter/models.py:992
|
||||
#: club/models.py:337 counter/models.py:957 counter/models.py:993
|
||||
#: eboutic/models.py:53 eboutic/models.py:189 election/models.py:183
|
||||
#: launderette/models.py:130 launderette/models.py:184 sas/models.py:273
|
||||
#: trombi/models.py:205
|
||||
@ -1053,8 +1053,8 @@ msgstr "nom d'utilisateur"
|
||||
msgid "role"
|
||||
msgstr "rôle"
|
||||
|
||||
#: club/models.py:359 core/models.py:90 counter/models.py:299
|
||||
#: counter/models.py:330 election/models.py:13 election/models.py:115
|
||||
#: club/models.py:359 core/models.py:90 counter/models.py:300
|
||||
#: counter/models.py:331 election/models.py:13 election/models.py:115
|
||||
#: election/models.py:188 forum/models.py:61 forum/models.py:245
|
||||
msgid "description"
|
||||
msgstr "description"
|
||||
@ -2501,7 +2501,7 @@ msgstr "Forum"
|
||||
msgid "Gallery"
|
||||
msgstr "Photos"
|
||||
|
||||
#: core/templates/core/base/navbar.jinja:22 counter/models.py:490
|
||||
#: core/templates/core/base/navbar.jinja:22 counter/models.py:491
|
||||
#: counter/templates/counter/counter_list.jinja:11
|
||||
#: eboutic/templates/eboutic/eboutic_main.jinja:4
|
||||
#: eboutic/templates/eboutic/eboutic_main.jinja:22
|
||||
@ -3607,13 +3607,13 @@ msgstr "Chèque"
|
||||
msgid "Cash"
|
||||
msgstr "Espèces"
|
||||
|
||||
#: counter/apps.py:30 counter/models.py:801 sith/settings.py:415
|
||||
#: counter/apps.py:30 counter/models.py:802 sith/settings.py:415
|
||||
#: sith/settings.py:420
|
||||
msgid "Credit card"
|
||||
msgstr "Carte bancaire"
|
||||
|
||||
#: counter/apps.py:36 counter/models.py:506 counter/models.py:962
|
||||
#: counter/models.py:998 launderette/models.py:32
|
||||
#: counter/apps.py:36 counter/models.py:507 counter/models.py:963
|
||||
#: counter/models.py:999 launderette/models.py:32
|
||||
msgid "counter"
|
||||
msgstr "comptoir"
|
||||
|
||||
@ -3637,180 +3637,184 @@ msgstr "Vidange de votre compte AE"
|
||||
msgid "Ecocup regularization"
|
||||
msgstr "Régularization des ecocups"
|
||||
|
||||
#: counter/models.py:91
|
||||
#: counter/models.py:92
|
||||
msgid "account id"
|
||||
msgstr "numéro de compte"
|
||||
|
||||
#: counter/models.py:93
|
||||
#: counter/models.py:94
|
||||
msgid "recorded product"
|
||||
msgstr "produits consignés"
|
||||
|
||||
#: counter/models.py:98
|
||||
#: counter/models.py:99
|
||||
msgid "customer"
|
||||
msgstr "client"
|
||||
|
||||
#: counter/models.py:99
|
||||
#: counter/models.py:100
|
||||
msgid "customers"
|
||||
msgstr "clients"
|
||||
|
||||
#: counter/models.py:111 counter/views/click.py:68
|
||||
#: counter/models.py:112 counter/views/click.py:68
|
||||
msgid "Not enough money"
|
||||
msgstr "Solde insuffisant"
|
||||
|
||||
#: counter/models.py:197
|
||||
#: counter/models.py:198
|
||||
msgid "First name"
|
||||
msgstr "Prénom"
|
||||
|
||||
#: counter/models.py:198
|
||||
#: counter/models.py:199
|
||||
msgid "Last name"
|
||||
msgstr "Nom de famille"
|
||||
|
||||
#: counter/models.py:199
|
||||
#: counter/models.py:200
|
||||
msgid "Address 1"
|
||||
msgstr "Adresse 1"
|
||||
|
||||
#: counter/models.py:200
|
||||
#: counter/models.py:201
|
||||
msgid "Address 2"
|
||||
msgstr "Adresse 2"
|
||||
|
||||
#: counter/models.py:201
|
||||
#: counter/models.py:202
|
||||
msgid "Zip code"
|
||||
msgstr "Code postal"
|
||||
|
||||
#: counter/models.py:202
|
||||
#: counter/models.py:203
|
||||
msgid "City"
|
||||
msgstr "Ville"
|
||||
|
||||
#: counter/models.py:203
|
||||
#: counter/models.py:204
|
||||
msgid "Country"
|
||||
msgstr "Pays"
|
||||
|
||||
#: counter/models.py:211
|
||||
#: counter/models.py:212
|
||||
msgid "Phone number"
|
||||
msgstr "Numéro de téléphone"
|
||||
|
||||
#: counter/models.py:253
|
||||
#: counter/models.py:254
|
||||
msgid "When the mail warning that the account was about to be dumped was sent."
|
||||
msgstr "Quand le mail d'avertissement de la vidange du compte a été envoyé."
|
||||
|
||||
#: counter/models.py:258
|
||||
#: counter/models.py:259
|
||||
msgid "Set this to True if the warning mail received an error"
|
||||
msgstr "Mettre à True si le mail a reçu une erreur"
|
||||
|
||||
#: counter/models.py:265
|
||||
#: counter/models.py:266
|
||||
msgid "The operation that emptied the account."
|
||||
msgstr "L'opération qui a vidé le compte."
|
||||
|
||||
#: counter/models.py:310 counter/models.py:334
|
||||
#: counter/models.py:304
|
||||
msgid "A text that will be shown on the eboutic."
|
||||
msgstr "Un texte qui sera affiché sur l'eboutic."
|
||||
|
||||
#: counter/models.py:311 counter/models.py:335
|
||||
msgid "product type"
|
||||
msgstr "type du produit"
|
||||
|
||||
#: counter/models.py:341
|
||||
#: counter/models.py:342
|
||||
msgid "purchase price"
|
||||
msgstr "prix d'achat"
|
||||
|
||||
#: counter/models.py:342
|
||||
#: counter/models.py:343
|
||||
msgid "Initial cost of purchasing the product"
|
||||
msgstr "Coût initial d'achat du produit"
|
||||
|
||||
#: counter/models.py:344
|
||||
#: counter/models.py:345
|
||||
msgid "selling price"
|
||||
msgstr "prix de vente"
|
||||
|
||||
#: counter/models.py:346
|
||||
#: counter/models.py:347
|
||||
msgid "special selling price"
|
||||
msgstr "prix de vente spécial"
|
||||
|
||||
#: counter/models.py:347
|
||||
#: counter/models.py:348
|
||||
msgid "Price for barmen during their permanence"
|
||||
msgstr "Prix pour les barmen durant leur permanence"
|
||||
|
||||
#: counter/models.py:355
|
||||
#: counter/models.py:356
|
||||
msgid "icon"
|
||||
msgstr "icône"
|
||||
|
||||
#: counter/models.py:360
|
||||
#: counter/models.py:361
|
||||
msgid "limit age"
|
||||
msgstr "âge limite"
|
||||
|
||||
#: counter/models.py:361
|
||||
#: counter/models.py:362
|
||||
msgid "tray price"
|
||||
msgstr "prix plateau"
|
||||
|
||||
#: counter/models.py:363
|
||||
#: counter/models.py:364
|
||||
msgid "buying groups"
|
||||
msgstr "groupe d'achat"
|
||||
|
||||
#: counter/models.py:365 election/models.py:50
|
||||
#: counter/models.py:366 election/models.py:50
|
||||
msgid "archived"
|
||||
msgstr "archivé"
|
||||
|
||||
#: counter/models.py:368 counter/models.py:1096
|
||||
#: counter/models.py:369 counter/models.py:1097
|
||||
msgid "product"
|
||||
msgstr "produit"
|
||||
|
||||
#: counter/models.py:485
|
||||
#: counter/models.py:486
|
||||
msgid "products"
|
||||
msgstr "produits"
|
||||
|
||||
#: counter/models.py:488
|
||||
#: counter/models.py:489
|
||||
msgid "counter type"
|
||||
msgstr "type de comptoir"
|
||||
|
||||
#: counter/models.py:490
|
||||
#: counter/models.py:491
|
||||
msgid "Bar"
|
||||
msgstr "Bar"
|
||||
|
||||
#: counter/models.py:490
|
||||
#: counter/models.py:491
|
||||
msgid "Office"
|
||||
msgstr "Bureau"
|
||||
|
||||
#: counter/models.py:493
|
||||
#: counter/models.py:494
|
||||
msgid "sellers"
|
||||
msgstr "vendeurs"
|
||||
|
||||
#: counter/models.py:501 launderette/models.py:178
|
||||
#: counter/models.py:502 launderette/models.py:178
|
||||
msgid "token"
|
||||
msgstr "jeton"
|
||||
|
||||
#: counter/models.py:701
|
||||
#: counter/models.py:702
|
||||
msgid "bank"
|
||||
msgstr "banque"
|
||||
|
||||
#: counter/models.py:703 counter/models.py:804
|
||||
#: counter/models.py:704 counter/models.py:805
|
||||
msgid "is validated"
|
||||
msgstr "est validé"
|
||||
|
||||
#: counter/models.py:708
|
||||
#: counter/models.py:709
|
||||
msgid "refilling"
|
||||
msgstr "rechargement"
|
||||
|
||||
#: counter/models.py:781 eboutic/models.py:249
|
||||
#: counter/models.py:782 eboutic/models.py:249
|
||||
msgid "unit price"
|
||||
msgstr "prix unitaire"
|
||||
|
||||
#: counter/models.py:782 counter/models.py:1076 eboutic/models.py:250
|
||||
#: counter/models.py:783 counter/models.py:1077 eboutic/models.py:250
|
||||
msgid "quantity"
|
||||
msgstr "quantité"
|
||||
|
||||
#: counter/models.py:801
|
||||
#: counter/models.py:802
|
||||
msgid "Sith account"
|
||||
msgstr "Compte utilisateur"
|
||||
|
||||
#: counter/models.py:809
|
||||
#: counter/models.py:810
|
||||
msgid "selling"
|
||||
msgstr "vente"
|
||||
|
||||
#: counter/models.py:913
|
||||
#: counter/models.py:914
|
||||
msgid "Unknown event"
|
||||
msgstr "Événement inconnu"
|
||||
|
||||
#: counter/models.py:914
|
||||
#: counter/models.py:915
|
||||
#, python-format
|
||||
msgid "Eticket bought for the event %(event)s"
|
||||
msgstr "Eticket acheté pour l'événement %(event)s"
|
||||
|
||||
#: counter/models.py:916 counter/models.py:929
|
||||
#: counter/models.py:917 counter/models.py:930
|
||||
#, python-format
|
||||
msgid ""
|
||||
"You bought an eticket for the event %(event)s.\n"
|
||||
@ -3822,67 +3826,67 @@ msgstr ""
|
||||
"Vous pouvez également retrouver tous vos e-tickets sur votre page de compte "
|
||||
"%(url)s."
|
||||
|
||||
#: counter/models.py:967
|
||||
#: counter/models.py:968
|
||||
msgid "last activity date"
|
||||
msgstr "dernière activité"
|
||||
|
||||
#: counter/models.py:970
|
||||
#: counter/models.py:971
|
||||
msgid "permanency"
|
||||
msgstr "permanence"
|
||||
|
||||
#: counter/models.py:1003
|
||||
#: counter/models.py:1004
|
||||
msgid "emptied"
|
||||
msgstr "coffre vidée"
|
||||
|
||||
#: counter/models.py:1006
|
||||
#: counter/models.py:1007
|
||||
msgid "cash register summary"
|
||||
msgstr "relevé de caisse"
|
||||
|
||||
#: counter/models.py:1072
|
||||
#: counter/models.py:1073
|
||||
msgid "cash summary"
|
||||
msgstr "relevé"
|
||||
|
||||
#: counter/models.py:1075
|
||||
#: counter/models.py:1076
|
||||
msgid "value"
|
||||
msgstr "valeur"
|
||||
|
||||
#: counter/models.py:1078
|
||||
#: counter/models.py:1079
|
||||
msgid "check"
|
||||
msgstr "chèque"
|
||||
|
||||
#: counter/models.py:1080
|
||||
#: counter/models.py:1081
|
||||
msgid "True if this is a bank check, else False"
|
||||
msgstr "Vrai si c'est un chèque, sinon Faux."
|
||||
|
||||
#: counter/models.py:1084
|
||||
#: counter/models.py:1085
|
||||
msgid "cash register summary item"
|
||||
msgstr "élément de relevé de caisse"
|
||||
|
||||
#: counter/models.py:1100
|
||||
#: counter/models.py:1101
|
||||
msgid "banner"
|
||||
msgstr "bannière"
|
||||
|
||||
#: counter/models.py:1102
|
||||
#: counter/models.py:1103
|
||||
msgid "event date"
|
||||
msgstr "date de l'événement"
|
||||
|
||||
#: counter/models.py:1104
|
||||
#: counter/models.py:1105
|
||||
msgid "event title"
|
||||
msgstr "titre de l'événement"
|
||||
|
||||
#: counter/models.py:1106
|
||||
#: counter/models.py:1107
|
||||
msgid "secret"
|
||||
msgstr "secret"
|
||||
|
||||
#: counter/models.py:1145
|
||||
#: counter/models.py:1146
|
||||
msgid "uid"
|
||||
msgstr "uid"
|
||||
|
||||
#: counter/models.py:1150 counter/models.py:1155
|
||||
#: counter/models.py:1151 counter/models.py:1156
|
||||
msgid "student card"
|
||||
msgstr "carte étudiante"
|
||||
|
||||
#: counter/models.py:1156
|
||||
#: counter/models.py:1157
|
||||
msgid "student cards"
|
||||
msgstr "cartes étudiantes"
|
||||
|
||||
@ -4193,17 +4197,29 @@ msgstr "Sans catégorie"
|
||||
msgid "There is no products in this website."
|
||||
msgstr "Il n'y a pas de produits dans ce site web."
|
||||
|
||||
#: counter/templates/counter/producttype_list.jinja:4
|
||||
#: counter/templates/counter/producttype_list.jinja:10
|
||||
#: counter/templates/counter/product_type_list.jinja:4
|
||||
#: counter/templates/counter/product_type_list.jinja:42
|
||||
msgid "Product type list"
|
||||
msgstr "Liste des types de produit"
|
||||
|
||||
#: counter/templates/counter/producttype_list.jinja:8
|
||||
#: counter/templates/counter/product_type_list.jinja:18
|
||||
msgid "New product type"
|
||||
msgstr "Nouveau type de produit"
|
||||
|
||||
#: counter/templates/counter/producttype_list.jinja:17
|
||||
msgid "There is no product types in this website."
|
||||
#: counter/templates/counter/product_type_list.jinja:25
|
||||
msgid "Product types are in the same order on this page and on the eboutic."
|
||||
msgstr "Les types de produit sont dans le même ordre sur cette page et sur l'eboutic."
|
||||
|
||||
#: counter/templates/counter/product_type_list.jinja:28
|
||||
msgid ""
|
||||
"You can reorder them here by drag-and-drop. The changes will then be applied "
|
||||
"globally immediately."
|
||||
msgstr ""
|
||||
"Vous pouvez les réorganiser ici. Les changements seront alors immédiatement "
|
||||
"appliqués globalement."
|
||||
|
||||
#: counter/templates/counter/product_type_list.jinja:58
|
||||
msgid "There are no product types in this website."
|
||||
msgstr "Il n'y a pas de types de produit dans ce site web."
|
||||
|
||||
#: counter/templates/counter/refilling_list.jinja:15
|
||||
|
@ -7,7 +7,7 @@
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2024-11-14 10:24+0100\n"
|
||||
"POT-Creation-Date: 2024-12-17 00:46+0100\n"
|
||||
"PO-Revision-Date: 2024-09-17 11:54+0200\n"
|
||||
"Last-Translator: Sli <antoine@bartuccio.fr>\n"
|
||||
"Language-Team: AE info <ae.info@utbm.fr>\n"
|
||||
@ -17,119 +17,128 @@ msgstr ""
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"Plural-Forms: nplurals=2; plural=(n > 1);\n"
|
||||
|
||||
#: core/static/bundled/core/components/ajax-select-base.ts:68
|
||||
msgid "Remove"
|
||||
msgstr "Retirer"
|
||||
|
||||
#: core/static/bundled/core/components/ajax-select-base.ts:90
|
||||
msgid "You need to type %(number)s more characters"
|
||||
msgstr "Vous devez taper %(number)s caractères de plus"
|
||||
|
||||
#: core/static/bundled/core/components/ajax-select-base.ts:94
|
||||
msgid "No results found"
|
||||
msgstr "Aucun résultat trouvé"
|
||||
|
||||
#: core/static/bundled/core/components/easymde-index.ts:38
|
||||
msgid "Heading"
|
||||
msgstr "Titre"
|
||||
|
||||
#: core/static/bundled/core/components/easymde-index.ts:44
|
||||
msgid "Italic"
|
||||
msgstr "Italique"
|
||||
|
||||
#: core/static/bundled/core/components/easymde-index.ts:50
|
||||
msgid "Bold"
|
||||
msgstr "Gras"
|
||||
|
||||
#: core/static/bundled/core/components/easymde-index.ts:56
|
||||
msgid "Strikethrough"
|
||||
msgstr "Barré"
|
||||
|
||||
#: core/static/bundled/core/components/easymde-index.ts:65
|
||||
msgid "Underline"
|
||||
msgstr "Souligné"
|
||||
|
||||
#: core/static/bundled/core/components/easymde-index.ts:74
|
||||
msgid "Superscript"
|
||||
msgstr "Exposant"
|
||||
|
||||
#: core/static/bundled/core/components/easymde-index.ts:83
|
||||
msgid "Subscript"
|
||||
msgstr "Indice"
|
||||
|
||||
#: core/static/bundled/core/components/easymde-index.ts:89
|
||||
msgid "Code"
|
||||
msgstr "Code"
|
||||
|
||||
#: core/static/bundled/core/components/easymde-index.ts:96
|
||||
msgid "Quote"
|
||||
msgstr "Citation"
|
||||
|
||||
#: core/static/bundled/core/components/easymde-index.ts:102
|
||||
msgid "Unordered list"
|
||||
msgstr "Liste non ordonnée"
|
||||
|
||||
#: core/static/bundled/core/components/easymde-index.ts:108
|
||||
msgid "Ordered list"
|
||||
msgstr "Liste ordonnée"
|
||||
|
||||
#: core/static/bundled/core/components/easymde-index.ts:115
|
||||
msgid "Insert link"
|
||||
msgstr "Insérer lien"
|
||||
|
||||
#: core/static/bundled/core/components/easymde-index.ts:121
|
||||
msgid "Insert image"
|
||||
msgstr "Insérer image"
|
||||
|
||||
#: core/static/bundled/core/components/easymde-index.ts:127
|
||||
msgid "Insert table"
|
||||
msgstr "Insérer tableau"
|
||||
|
||||
#: core/static/bundled/core/components/easymde-index.ts:134
|
||||
msgid "Clean block"
|
||||
msgstr "Nettoyer bloc"
|
||||
|
||||
#: core/static/bundled/core/components/easymde-index.ts:141
|
||||
msgid "Toggle preview"
|
||||
msgstr "Activer la prévisualisation"
|
||||
|
||||
#: core/static/bundled/core/components/easymde-index.ts:147
|
||||
msgid "Toggle side by side"
|
||||
msgstr "Activer la vue côte à côte"
|
||||
|
||||
#: core/static/bundled/core/components/easymde-index.ts:153
|
||||
msgid "Toggle fullscreen"
|
||||
msgstr "Activer le plein écran"
|
||||
|
||||
#: core/static/bundled/core/components/easymde-index.ts:160
|
||||
msgid "Markdown guide"
|
||||
msgstr "Guide markdown"
|
||||
|
||||
#: core/static/bundled/core/components/nfc-input-index.ts:26
|
||||
msgid "Unsupported NFC card"
|
||||
msgstr "Carte NFC non supportée"
|
||||
|
||||
#: core/static/bundled/user/family-graph-index.js:233
|
||||
msgid "family_tree.%(extension)s"
|
||||
msgstr "arbre_genealogique.%(extension)s"
|
||||
|
||||
#: core/static/bundled/user/pictures-index.js:76
|
||||
msgid "pictures.%(extension)s"
|
||||
msgstr "photos.%(extension)s"
|
||||
|
||||
#: core/static/user/js/user_edit.js:91
|
||||
#, javascript-format
|
||||
msgid "captured.%s"
|
||||
msgstr "capture.%s"
|
||||
|
||||
#: core/static/webpack/core/components/ajax-select-base.ts:68
|
||||
msgid "Remove"
|
||||
msgstr "Retirer"
|
||||
#: counter/static/bundled/counter/product-type-index.ts:36
|
||||
msgid "Products types reordered!"
|
||||
msgstr "Types de produits réordonnés !"
|
||||
|
||||
#: core/static/webpack/core/components/ajax-select-base.ts:88
|
||||
msgid "You need to type %(number)s more characters"
|
||||
msgstr "Vous devez taper %(number)s caractères de plus"
|
||||
|
||||
#: core/static/webpack/core/components/ajax-select-base.ts:92
|
||||
msgid "No results found"
|
||||
msgstr "Aucun résultat trouvé"
|
||||
|
||||
#: core/static/webpack/core/components/easymde-index.ts:38
|
||||
msgid "Heading"
|
||||
msgstr "Titre"
|
||||
|
||||
#: core/static/webpack/core/components/easymde-index.ts:44
|
||||
msgid "Italic"
|
||||
msgstr "Italique"
|
||||
|
||||
#: core/static/webpack/core/components/easymde-index.ts:50
|
||||
msgid "Bold"
|
||||
msgstr "Gras"
|
||||
|
||||
#: core/static/webpack/core/components/easymde-index.ts:56
|
||||
msgid "Strikethrough"
|
||||
msgstr "Barré"
|
||||
|
||||
#: core/static/webpack/core/components/easymde-index.ts:65
|
||||
msgid "Underline"
|
||||
msgstr "Souligné"
|
||||
|
||||
#: core/static/webpack/core/components/easymde-index.ts:74
|
||||
msgid "Superscript"
|
||||
msgstr "Exposant"
|
||||
|
||||
#: core/static/webpack/core/components/easymde-index.ts:83
|
||||
msgid "Subscript"
|
||||
msgstr "Indice"
|
||||
|
||||
#: core/static/webpack/core/components/easymde-index.ts:89
|
||||
msgid "Code"
|
||||
msgstr "Code"
|
||||
|
||||
#: core/static/webpack/core/components/easymde-index.ts:96
|
||||
msgid "Quote"
|
||||
msgstr "Citation"
|
||||
|
||||
#: core/static/webpack/core/components/easymde-index.ts:102
|
||||
msgid "Unordered list"
|
||||
msgstr "Liste non ordonnée"
|
||||
|
||||
#: core/static/webpack/core/components/easymde-index.ts:108
|
||||
msgid "Ordered list"
|
||||
msgstr "Liste ordonnée"
|
||||
|
||||
#: core/static/webpack/core/components/easymde-index.ts:115
|
||||
msgid "Insert link"
|
||||
msgstr "Insérer lien"
|
||||
|
||||
#: core/static/webpack/core/components/easymde-index.ts:121
|
||||
msgid "Insert image"
|
||||
msgstr "Insérer image"
|
||||
|
||||
#: core/static/webpack/core/components/easymde-index.ts:127
|
||||
msgid "Insert table"
|
||||
msgstr "Insérer tableau"
|
||||
|
||||
#: core/static/webpack/core/components/easymde-index.ts:134
|
||||
msgid "Clean block"
|
||||
msgstr "Nettoyer bloc"
|
||||
|
||||
#: core/static/webpack/core/components/easymde-index.ts:141
|
||||
msgid "Toggle preview"
|
||||
msgstr "Activer la prévisualisation"
|
||||
|
||||
#: core/static/webpack/core/components/easymde-index.ts:147
|
||||
msgid "Toggle side by side"
|
||||
msgstr "Activer la vue côte à côte"
|
||||
|
||||
#: core/static/webpack/core/components/easymde-index.ts:153
|
||||
msgid "Toggle fullscreen"
|
||||
msgstr "Activer le plein écran"
|
||||
|
||||
#: core/static/webpack/core/components/easymde-index.ts:160
|
||||
msgid "Markdown guide"
|
||||
msgstr "Guide markdown"
|
||||
|
||||
#: core/static/webpack/core/components/nfc-input-index.ts:24
|
||||
msgid "Unsupported NFC card"
|
||||
msgstr "Carte NFC non supportée"
|
||||
|
||||
#: core/static/webpack/user/family-graph-index.js:233
|
||||
msgid "family_tree.%(extension)s"
|
||||
msgstr "arbre_genealogique.%(extension)s"
|
||||
|
||||
#: core/static/webpack/user/pictures-index.js:76
|
||||
msgid "pictures.%(extension)s"
|
||||
msgstr "photos.%(extension)s"
|
||||
#: counter/static/bundled/counter/product-type-index.ts:40
|
||||
#, javascript-format
|
||||
msgid "Product type reorganisation failed with status code : %d"
|
||||
msgstr "La réorganisation des types de produit a échoué avec le code : %d"
|
||||
|
||||
#: eboutic/static/eboutic/js/makecommand.js:56
|
||||
msgid "Incorrect value"
|
||||
msgstr "Valeur incorrecte"
|
||||
|
||||
#: sas/static/webpack/sas/viewer-index.ts:271
|
||||
#: sas/static/bundled/sas/viewer-index.ts:271
|
||||
msgid "Couldn't moderate picture"
|
||||
msgstr "Il n'a pas été possible de modérer l'image"
|
||||
|
||||
#: sas/static/webpack/sas/viewer-index.ts:284
|
||||
#: sas/static/bundled/sas/viewer-index.ts:284
|
||||
msgid "Couldn't delete picture"
|
||||
msgstr "Il n'a pas été possible de supprimer l'image"
|
||||
|
16
package-lock.json
generated
16
package-lock.json
generated
@ -9,12 +9,13 @@
|
||||
"version": "3",
|
||||
"license": "GPL-3.0-only",
|
||||
"dependencies": {
|
||||
"@alpinejs/sort": "^3.14.7",
|
||||
"@fortawesome/fontawesome-free": "^6.6.0",
|
||||
"@hey-api/client-fetch": "^0.4.0",
|
||||
"@sentry/browser": "^8.34.0",
|
||||
"@zip.js/zip.js": "^2.7.52",
|
||||
"3d-force-graph": "^1.73.4",
|
||||
"alpinejs": "^3.14.1",
|
||||
"alpinejs": "^3.14.7",
|
||||
"chart.js": "^4.4.4",
|
||||
"cytoscape": "^3.30.2",
|
||||
"cytoscape-cxtmenu": "^3.5.0",
|
||||
@ -44,6 +45,12 @@
|
||||
"vite-plugin-static-copy": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@alpinejs/sort": {
|
||||
"version": "3.14.7",
|
||||
"resolved": "https://registry.npmjs.org/@alpinejs/sort/-/sort-3.14.7.tgz",
|
||||
"integrity": "sha512-EJzxTBSoKvOxKHAUFeTSgxJR4rJQQPm10b4dB38kGcsxjUtOeNkbBF3xV4nlc0ZyTv7DarTWdppdoR/iP8jfdQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@ampproject/remapping": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz",
|
||||
@ -3064,9 +3071,10 @@
|
||||
}
|
||||
},
|
||||
"node_modules/alpinejs": {
|
||||
"version": "3.14.1",
|
||||
"resolved": "https://registry.npmjs.org/alpinejs/-/alpinejs-3.14.1.tgz",
|
||||
"integrity": "sha512-ICar8UsnRZAYvv/fCNfNeKMXNoXGUfwHrjx7LqXd08zIP95G2d9bAOuaL97re+1mgt/HojqHsfdOLo/A5LuWgQ==",
|
||||
"version": "3.14.7",
|
||||
"resolved": "https://registry.npmjs.org/alpinejs/-/alpinejs-3.14.7.tgz",
|
||||
"integrity": "sha512-ScnbydNBcWVnCiVupD3wWUvoMPm8244xkvDNMxVCspgmap9m4QuJ7pjc+77UtByU+1+Ejg0wzYkP4mQaOMcvng==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vue/reactivity": "~3.1.1"
|
||||
}
|
||||
|
@ -33,12 +33,13 @@
|
||||
"vite-plugin-static-copy": "^2.1.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@alpinejs/sort": "^3.14.7",
|
||||
"@fortawesome/fontawesome-free": "^6.6.0",
|
||||
"@hey-api/client-fetch": "^0.4.0",
|
||||
"@sentry/browser": "^8.34.0",
|
||||
"@zip.js/zip.js": "^2.7.52",
|
||||
"3d-force-graph": "^1.73.4",
|
||||
"alpinejs": "^3.14.1",
|
||||
"alpinejs": "^3.14.7",
|
||||
"chart.js": "^4.4.4",
|
||||
"cytoscape": "^3.30.2",
|
||||
"cytoscape-cxtmenu": "^3.5.0",
|
||||
|
Loading…
Reference in New Issue
Block a user