Skip to content

Commit

Permalink
Merge pull request #952 from ae-utbm/sort-producttypes
Browse files Browse the repository at this point in the history
Sort product types
  • Loading branch information
imperosol authored Dec 18, 2024
2 parents c5646b1 + 5da27bb commit fad470b
Show file tree
Hide file tree
Showing 28 changed files with 731 additions and 198 deletions.
2 changes: 2 additions & 0 deletions core/static/bundled/alpine-index.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import sort from "@alpinejs/sort";
import Alpine from "alpinejs";

Alpine.plugin(sort);
window.Alpine = Alpine;

window.addEventListener("DOMContentLoaded", () => {
Expand Down
2 changes: 2 additions & 0 deletions core/static/bundled/core/components/ajax-select-base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
18 changes: 18 additions & 0 deletions core/static/core/forms.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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%;
}
}
11 changes: 11 additions & 0 deletions core/static/core/style.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
2 changes: 1 addition & 1 deletion core/templates/core/user_tools.jinja
Original file line number Diff line number Diff line change
Expand Up @@ -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>
Expand Down
2 changes: 1 addition & 1 deletion counter/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
92 changes: 79 additions & 13 deletions counter/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -64,15 +73,72 @@ def search_counter(self, filters: Query[CounterFilterSchema]):
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)
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",
)
.filter(archived=False)
.values()
)


@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)
62 changes: 62 additions & 0 deletions counter/migrations/0028_alter_producttype_comment_and_more.py
Original file line number Diff line number Diff line change
@@ -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"),
]
19 changes: 10 additions & 9 deletions counter/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
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
Expand Down Expand Up @@ -289,32 +290,32 @@ def amount(self):
)


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."""
Expand Down
78 changes: 73 additions & 5 deletions counter/schemas.py
Original file line number Diff line number Diff line change
@@ -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):
Expand All @@ -26,7 +29,72 @@ class Meta:
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")
Loading

0 comments on commit fad470b

Please sign in to comment.