add pages to manage returnable products

This commit is contained in:
imperosol 2025-03-07 02:03:22 +01:00
parent 9148cbf206
commit 500d82f166
10 changed files with 251 additions and 19 deletions

View File

@ -55,6 +55,14 @@
width: 80%; width: 80%;
} }
.card-top-left {
position: absolute;
top: 10px;
right: 10px;
padding: 10px;
text-align: center;
}
.card-content { .card-content {
color: black; color: black;
display: flex; display: flex;

View File

@ -10,10 +10,17 @@
{% block nav %} {% block nav %}
{% endblock %} {% endblock %}
{# if the template context has the `object_name` variable,
then this one will be used,
instead of the result of `str(object)` #}
{% if object and not object_name %}
{% set object_name=object %}
{% endif %}
{% block content %} {% block content %}
<h2>{% trans %}Delete confirmation{% endtrans %}</h2> <h2>{% trans %}Delete confirmation{% endtrans %}</h2>
<form action="" method="post">{% csrf_token %} <form action="" method="post">{% csrf_token %}
<p>{% trans obj=object %}Are you sure you want to delete "{{ obj }}"?{% endtrans %}</p> <p>{% trans name=object_name %}Are you sure you want to delete "{{ name }}"?{% endtrans %}</p>
<input type="submit" value="{% trans %}Confirm{% endtrans %}" /> <input type="submit" value="{% trans %}Confirm{% endtrans %}" />
</form> </form>
<form method="GET" action="javascript:history.back();"> <form method="GET" action="javascript:history.back();">

View File

@ -62,6 +62,11 @@
{% trans %}Product types management{% endtrans %} {% trans %}Product types management{% endtrans %}
</a> </a>
</li> </li>
<li>
<a href="{{ url("counter:returnable_list") }}">
{% trans %}Returnable products management{% endtrans %}
</a>
</li>
<li> <li>
<a href="{{ url('counter:cash_summary_list') }}"> <a href="{{ url('counter:cash_summary_list') }}">
{% trans %}Cash register summaries{% endtrans %} {% trans %}Cash register summaries{% endtrans %}

View File

@ -1,3 +1,5 @@
from typing import ClassVar
from django.conf import settings from django.conf import settings
from django.core.exceptions import ImproperlyConfigured from django.core.exceptions import ImproperlyConfigured
from django.views import View from django.views import View
@ -6,20 +8,24 @@ from django.views import View
class TabedViewMixin(View): class TabedViewMixin(View):
"""Basic functions for displaying tabs in the template.""" """Basic functions for displaying tabs in the template."""
current_tab: ClassVar[str | None] = None
list_of_tabs: ClassVar[list | None] = None
tabs_title: ClassVar[str | None] = None
def get_tabs_title(self): def get_tabs_title(self):
if hasattr(self, "tabs_title"): if not self.tabs_title:
return self.tabs_title
raise ImproperlyConfigured("tabs_title is required") raise ImproperlyConfigured("tabs_title is required")
return self.tabs_title
def get_current_tab(self): def get_current_tab(self):
if hasattr(self, "current_tab"): if not self.current_tab:
return self.current_tab
raise ImproperlyConfigured("current_tab is required") raise ImproperlyConfigured("current_tab is required")
return self.current_tab
def get_list_of_tabs(self): def get_list_of_tabs(self):
if hasattr(self, "list_of_tabs"): if not self.list_of_tabs:
return self.list_of_tabs
raise ImproperlyConfigured("list_of_tabs is required") raise ImproperlyConfigured("list_of_tabs is required")
return self.list_of_tabs
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
kwargs = super().get_context_data(**kwargs) kwargs = super().get_context_data(**kwargs)

View File

@ -17,6 +17,7 @@ from counter.models import (
Eticket, Eticket,
Product, Product,
Refilling, Refilling,
ReturnableProduct,
StudentCard, StudentCard,
) )
from counter.widgets.select import ( from counter.widgets.select import (
@ -213,6 +214,25 @@ class ProductEditForm(forms.ModelForm):
return ret return ret
class ReturnableProductForm(forms.ModelForm):
class Meta:
model = ReturnableProduct
fields = ["product", "returned_product", "max_return"]
widgets = {
"product": AutoCompleteSelectProduct(),
"returned_product": AutoCompleteSelectProduct(),
}
def save(self, commit: bool = True) -> ReturnableProduct: # noqa FBT
instance: ReturnableProduct = super().save(commit=commit)
if commit:
# This is expensive, but we don't have a task queue to offload it.
# Hopefully, creations and updates of returnable products
# occur very rarely
instance.update_balances()
return instance
class CashSummaryFormBase(forms.Form): class CashSummaryFormBase(forms.Form):
begin_date = forms.DateTimeField( begin_date = forms.DateTimeField(
label=_("Begin date"), widget=SelectDateTime, required=False label=_("Begin date"), widget=SelectDateTime, required=False

View File

@ -0,0 +1,67 @@
{% extends "core/base.jinja" %}
{% block title %}
{% trans %}Returnable products{% endtrans %}
{% endblock %}
{% block additional_js %}
{% endblock %}
{% block additional_css %}
<link rel="stylesheet" href="{{ static("core/components/card.scss") }}">
<link rel="stylesheet" href="{{ static("counter/css/admin.scss") }}">
<link rel="stylesheet" href="{{ static("bundled/core/components/ajax-select-index.css") }}">
<link rel="stylesheet" href="{{ static("core/components/ajax-select.scss") }}">
{% endblock %}
{% block content %}
<h3 class="margin-bottom">{% trans %}Returnable products{% endtrans %}</h3>
{% if user.has_perm("counter.add_returnableproduct") %}
<a href="{{ url('counter:create_returnable') }}" class="btn btn-blue margin-bottom">
{% trans %}New returnable product{% endtrans %} <i class="fa fa-plus"></i>
</a>
{% endif %}
<div class="product-group">
{% for returnable in object_list %}
{% if user.has_perm("counter.change_returnableproduct") %}
<a
class="card card-row shadow clickable"
href="{{ url("counter:edit_returnable", returnable_id=returnable.id) }}"
>
{% else %}
<div class="card card-row shadow">
{% endif %}
{% if returnable.product.icon %}
<img
class="card-image"
src="{{ returnable.product.icon.url }}"
alt="{{ returnable.product.name }}"
>
{% else %}
<i class="fa-regular fa-image fa-2x card-image"></i>
{% endif %}
<div class="card-content">
<strong class="card-title">{{ returnable.product }}</strong>
<p>{% trans %}Returned product{% endtrans %} : {{ returnable.returned_product }}</p>
</div>
{% if user.has_perm("counter.delete_returnableproduct") %}
<button
x-data
class="btn btn-red btn-no-text card-top-left"
@click.prevent="document.location.href = '{{ url("counter:delete_returnable", returnable_id=returnable.id) }}'"
>
{# The delete link is a button with a JS event listener
instead of a proper <a> element,
because the enclosing card is already a <a>,
and HTML forbids nested <a> #}
<i class="fa fa-trash"></i>
</button>
{% endif %}
{% if user.has_perm("counter.change_returnableproduct") %}
</a>
{% else %}
</div>
{% endif %}
{% endfor %}
</div>
{% endblock content %}

View File

@ -30,6 +30,10 @@ from counter.views.admin import (
ProductTypeEditView, ProductTypeEditView,
ProductTypeListView, ProductTypeListView,
RefillingDeleteView, RefillingDeleteView,
ReturnableProductCreateView,
ReturnableProductDeleteView,
ReturnableProductListView,
ReturnableProductUpdateView,
SellingDeleteView, SellingDeleteView,
) )
from counter.views.auth import counter_login, counter_logout from counter.views.auth import counter_login, counter_logout
@ -51,10 +55,7 @@ from counter.views.home import (
CounterMain, CounterMain,
) )
from counter.views.invoice import InvoiceCallView from counter.views.invoice import InvoiceCallView
from counter.views.student_card import ( from counter.views.student_card import StudentCardDeleteView, StudentCardFormView
StudentCardDeleteView,
StudentCardFormView,
)
urlpatterns = [ urlpatterns = [
path("<int:counter_id>/", CounterMain.as_view(), name="details"), path("<int:counter_id>/", CounterMain.as_view(), name="details"),
@ -129,6 +130,24 @@ urlpatterns = [
ProductTypeEditView.as_view(), ProductTypeEditView.as_view(),
name="product_type_edit", name="product_type_edit",
), ),
path(
"admin/returnable/", ReturnableProductListView.as_view(), name="returnable_list"
),
path(
"admin/returnable/create/",
ReturnableProductCreateView.as_view(),
name="create_returnable",
),
path(
"admin/returnable/<int:returnable_id>/",
ReturnableProductUpdateView.as_view(),
name="edit_returnable",
),
path(
"admin/returnable/delete/<int:returnable_id>/",
ReturnableProductDeleteView.as_view(),
name="delete_returnable",
),
path("admin/eticket/list/", EticketListView.as_view(), name="eticket_list"), path("admin/eticket/list/", EticketListView.as_view(), name="eticket_list"),
path("admin/eticket/new/", EticketCreateView.as_view(), name="new_eticket"), path("admin/eticket/new/", EticketCreateView.as_view(), name="new_eticket"),
path( path(

View File

@ -15,19 +15,28 @@
from datetime import timedelta from datetime import timedelta
from django.conf import settings from django.conf import settings
from django.contrib.auth.mixins import PermissionRequiredMixin
from django.core.exceptions import PermissionDenied from django.core.exceptions import PermissionDenied
from django.forms import CheckboxSelectMultiple from django.forms import CheckboxSelectMultiple
from django.forms.models import modelform_factory from django.forms.models import modelform_factory
from django.shortcuts import get_object_or_404 from django.shortcuts import get_object_or_404
from django.urls import reverse, reverse_lazy from django.urls import reverse, reverse_lazy
from django.utils import timezone from django.utils import timezone
from django.utils.translation import gettext as _
from django.views.generic import DetailView, ListView, TemplateView from django.views.generic import DetailView, ListView, TemplateView
from django.views.generic.edit import CreateView, DeleteView, UpdateView from django.views.generic.edit import CreateView, DeleteView, UpdateView
from core.auth.mixins import CanEditMixin, CanViewMixin from core.auth.mixins import CanEditMixin, CanViewMixin
from core.utils import get_semester_code, get_start_of_semester from core.utils import get_semester_code, get_start_of_semester
from counter.forms import CounterEditForm, ProductEditForm from counter.forms import CounterEditForm, ProductEditForm, ReturnableProductForm
from counter.models import Counter, Product, ProductType, Refilling, Selling from counter.models import (
Counter,
Product,
ProductType,
Refilling,
ReturnableProduct,
Selling,
)
from counter.utils import is_logged_in_counter from counter.utils import is_logged_in_counter
from counter.views.mixins import CounterAdminMixin, CounterAdminTabsMixin from counter.views.mixins import CounterAdminMixin, CounterAdminTabsMixin
@ -146,6 +155,69 @@ class ProductEditView(CounterAdminTabsMixin, CounterAdminMixin, UpdateView):
current_tab = "products" current_tab = "products"
class ReturnableProductListView(
CounterAdminTabsMixin, PermissionRequiredMixin, ListView
):
model = ReturnableProduct
queryset = model.objects.select_related("product", "returned_product")
template_name = "counter/returnable_list.jinja"
current_tab = "returnable_products"
permission_required = "counter.view_returnableproduct"
class ReturnableProductCreateView(
CounterAdminTabsMixin, PermissionRequiredMixin, CreateView
):
form_class = ReturnableProductForm
template_name = "core/create.jinja"
current_tab = "returnable_products"
success_url = reverse_lazy("counter:returnable_list")
permission_required = "counter.add_returnableproduct"
class ReturnableProductUpdateView(
CounterAdminTabsMixin, PermissionRequiredMixin, UpdateView
):
model = ReturnableProduct
pk_url_kwarg = "returnable_id"
queryset = model.objects.select_related("product", "returned_product")
form_class = ReturnableProductForm
template_name = "core/edit.jinja"
current_tab = "returnable_products"
success_url = reverse_lazy("counter:returnable_list")
permission_required = "counter.change_returnableproduct"
def get_context_data(self, **kwargs):
return super().get_context_data(**kwargs) | {
"object_name": _("returnable product : %(returnable)s -> %(returned)s")
% {
"returnable": self.object.product.name,
"returned": self.object.returned_product.name,
}
}
class ReturnableProductDeleteView(
CounterAdminTabsMixin, PermissionRequiredMixin, DeleteView
):
model = ReturnableProduct
pk_url_kwarg = "returnable_id"
queryset = model.objects.select_related("product", "returned_product")
template_name = "core/delete_confirm.jinja"
current_tab = "returnable_products"
success_url = reverse_lazy("counter:returnable_list")
permission_required = "counter.delete_returnableproduct"
def get_context_data(self, **kwargs):
return super().get_context_data(**kwargs) | {
"object_name": _("returnable product : %(returnable)s -> %(returned)s")
% {
"returnable": self.object.product.name,
"returned": self.object.returned_product.name,
}
}
class RefillingDeleteView(DeleteView): class RefillingDeleteView(DeleteView):
"""Delete a refilling (for the admins).""" """Delete a refilling (for the admins)."""

View File

@ -98,6 +98,11 @@ class CounterAdminTabsMixin(TabedViewMixin):
"slug": "product_types", "slug": "product_types",
"name": _("Product types"), "name": _("Product types"),
}, },
{
"url": reverse_lazy("counter:returnable_list"),
"slug": "returnable_products",
"name": _("Returnable products"),
},
{ {
"url": reverse_lazy("counter:cash_summary_list"), "url": reverse_lazy("counter:cash_summary_list"),
"slug": "cash_summary", "slug": "cash_summary",

View File

@ -6,7 +6,7 @@
msgid "" msgid ""
msgstr "" msgstr ""
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-03-06 16:50+0100\n" "POT-Creation-Date: 2025-03-06 22:40+0100\n"
"PO-Revision-Date: 2016-07-18\n" "PO-Revision-Date: 2016-07-18\n"
"Last-Translator: Maréchal <thomas.girod@utbm.fr\n" "Last-Translator: Maréchal <thomas.girod@utbm.fr\n"
"Language-Team: AE info <ae.info@utbm.fr>\n" "Language-Team: AE info <ae.info@utbm.fr>\n"
@ -3199,6 +3199,10 @@ msgstr "Gestion des produits"
msgid "Product types management" msgid "Product types management"
msgstr "Gestion des types de produit" msgstr "Gestion des types de produit"
#: core/templates/core/user_tools.jinja
msgid "Returnable products management"
msgstr "Gestion des consignes"
#: core/templates/core/user_tools.jinja #: core/templates/core/user_tools.jinja
#: counter/templates/counter/cash_summary_list.jinja counter/views/mixins.py #: counter/templates/counter/cash_summary_list.jinja counter/views/mixins.py
msgid "Cash register summaries" msgid "Cash register summaries"
@ -3712,7 +3716,7 @@ msgstr "produit consigné"
#: counter/models.py #: counter/models.py
msgid "returned product" msgid "returned product"
msgstr "produits déconsignés" msgstr "produit déconsigné"
#: counter/models.py #: counter/models.py
msgid "maximum returns" msgid "maximum returns"
@ -3724,12 +3728,18 @@ msgid ""
"bought them." "bought them."
msgstr "" msgstr ""
"Le nombre maximum d'articles qu'un client peut déconsigner sans les avoir " "Le nombre maximum d'articles qu'un client peut déconsigner sans les avoir "
"acheté." "achetés."
#: counter/models.py #: counter/models.py
msgid "returnable products" msgid "returnable products"
msgstr "produits consignés" msgstr "produits consignés"
#: counter/models.py
msgid "The returnable product cannot be the same as the returned one"
msgstr ""
"Le produit consigné ne peut pas être le même "
"que le produit de déconsigne"
#: counter/templates/counter/activity.jinja #: counter/templates/counter/activity.jinja
#, python-format #, python-format
msgid "%(counter_name)s activity" msgid "%(counter_name)s activity"
@ -4097,6 +4107,14 @@ msgstr "Il n'y a pas de types de produit dans ce site web."
msgid "Seller" msgid "Seller"
msgstr "Vendeur" msgstr "Vendeur"
#: counter/templates/counter/returnable_list.jinja counter/views/mixins.py
msgid "Returnable products"
msgstr "Produits consignés"
#: counter/templates/counter/returnable_list.jinja
msgid "Returned product"
msgstr "Produit déconsigné"
#: counter/templates/counter/stats.jinja #: counter/templates/counter/stats.jinja
#, python-format #, python-format
msgid "%(counter_name)s stats" msgid "%(counter_name)s stats"
@ -4129,6 +4147,11 @@ msgstr "Temps"
msgid "Top 100 barman %(counter_name)s (all semesters)" msgid "Top 100 barman %(counter_name)s (all semesters)"
msgstr "Top 100 barman %(counter_name)s (tous les semestres)" msgstr "Top 100 barman %(counter_name)s (tous les semestres)"
#: counter/views/admin.py
#, python-format
msgid "returnable product : %(returnable)s -> %(returned)s"
msgstr "produit consigné : %(returnable)s -> %(returned)s"
#: counter/views/cash.py #: counter/views/cash.py
msgid "10 cents" msgid "10 cents"
msgstr "10 centimes" msgstr "10 centimes"