mirror of
https://github.com/ae-utbm/sith.git
synced 2026-03-13 15:15:03 +00:00
Compare commits
13 Commits
discord-au
...
price
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
59d7fadf4f | ||
|
|
e188acc78b | ||
|
|
654ba383b4 | ||
|
|
2ee0c091be | ||
|
|
0f1660ad79 | ||
|
|
680dc44486 | ||
|
|
5f7a9fc600 | ||
|
|
5126dc2a82 | ||
|
|
7322a0c1cb | ||
|
|
c2dfbc8bec | ||
|
|
a019707d4a | ||
|
|
85f1a0b9cb | ||
|
|
0a3f8b8e6f |
@@ -17,15 +17,6 @@ class ApiClientAdmin(admin.ModelAdmin):
|
||||
"owner__nick_name",
|
||||
)
|
||||
autocomplete_fields = ("owner", "groups", "client_permissions")
|
||||
readonly_fields = ("hmac_key",)
|
||||
actions = ("reset_hmac_key",)
|
||||
|
||||
@admin.action(permissions=["change"], description=_("Reset HMAC key"))
|
||||
def reset_hmac_key(self, _request: HttpRequest, queryset: QuerySet[ApiClient]):
|
||||
objs = list(queryset)
|
||||
for obj in objs:
|
||||
obj.reset_hmac(commit=False)
|
||||
ApiClient.objects.bulk_update(objs, fields=["hmac_key"])
|
||||
|
||||
|
||||
@admin.register(ApiKey)
|
||||
|
||||
16
api/api.py
16
api/api.py
@@ -1,16 +0,0 @@
|
||||
from ninja_extra import ControllerBase, api_controller, route
|
||||
|
||||
from api.auth import ApiKeyAuth
|
||||
from api.schemas import ApiClientSchema
|
||||
|
||||
|
||||
@api_controller("/client")
|
||||
class ApiClientController(ControllerBase):
|
||||
@route.get(
|
||||
"/me",
|
||||
auth=[ApiKeyAuth()],
|
||||
response=ApiClientSchema,
|
||||
url_name="api-client-infos",
|
||||
)
|
||||
def get_client_info(self):
|
||||
return self.context.request.auth
|
||||
35
api/forms.py
35
api/forms.py
@@ -1,35 +0,0 @@
|
||||
from django import forms
|
||||
from django.forms import HiddenInput
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
|
||||
class ThirdPartyAuthForm(forms.Form):
|
||||
"""Form to complete to authenticate on the sith from a third-party app.
|
||||
|
||||
For the form to be valid, the user approve the EULA (french: CGU)
|
||||
and give its username from the third-party app.
|
||||
"""
|
||||
|
||||
cgu_accepted = forms.BooleanField(
|
||||
required=True,
|
||||
label=_("I have read and I accept the terms and conditions of use"),
|
||||
error_messages={
|
||||
"required": _("You must approve the terms and conditions of use.")
|
||||
},
|
||||
)
|
||||
is_username_valid = forms.BooleanField(
|
||||
required=True,
|
||||
error_messages={"required": _("You must confirm that this is your username.")},
|
||||
)
|
||||
client_id = forms.IntegerField(widget=HiddenInput())
|
||||
third_party_app = forms.CharField(widget=HiddenInput())
|
||||
privacy_link = forms.URLField(widget=HiddenInput())
|
||||
username = forms.CharField(widget=HiddenInput())
|
||||
callback_url = forms.URLField(widget=HiddenInput())
|
||||
signature = forms.CharField(widget=HiddenInput())
|
||||
|
||||
def __init__(self, *args, label_suffix: str = "", initial, **kwargs):
|
||||
super().__init__(*args, label_suffix=label_suffix, initial=initial, **kwargs)
|
||||
self.fields["is_username_valid"].label = _(
|
||||
"I confirm that %(username)s is my username on %(app)s"
|
||||
) % {"username": initial.get("username"), "app": initial.get("third_party_app")}
|
||||
@@ -1,19 +0,0 @@
|
||||
# Generated by Django 5.2.3 on 2025-10-26 10:15
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
import api.models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [("api", "0001_initial")]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="apiclient",
|
||||
name="hmac_key",
|
||||
field=models.CharField(
|
||||
default=api.models.get_hmac_key, max_length=128, verbose_name="HMAC Key"
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -1,20 +1,13 @@
|
||||
import secrets
|
||||
from typing import Iterable
|
||||
|
||||
from django.contrib.auth.models import Permission
|
||||
from django.db import models
|
||||
from django.db.models import Q
|
||||
from django.utils.functional import cached_property
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.utils.translation import pgettext_lazy
|
||||
|
||||
from core.models import Group, User
|
||||
|
||||
|
||||
def get_hmac_key():
|
||||
return secrets.token_hex(64)
|
||||
|
||||
|
||||
class ApiClient(models.Model):
|
||||
name = models.CharField(_("name"), max_length=64)
|
||||
owner = models.ForeignKey(
|
||||
@@ -33,10 +26,11 @@ class ApiClient(models.Model):
|
||||
help_text=_("Specific permissions for this api client."),
|
||||
related_name="clients",
|
||||
)
|
||||
hmac_key = models.CharField(_("HMAC Key"), max_length=128, default=get_hmac_key)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
_perm_cache: set[str] | None = None
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("api client")
|
||||
verbose_name_plural = _("api clients")
|
||||
@@ -44,38 +38,33 @@ class ApiClient(models.Model):
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
@cached_property
|
||||
def all_permissions(self) -> set[str]:
|
||||
permissions = (
|
||||
Permission.objects.filter(
|
||||
Q(group__group__in=self.groups.all()) | Q(clients=self)
|
||||
)
|
||||
.values_list("content_type__app_label", "codename")
|
||||
.order_by()
|
||||
)
|
||||
return {f"{content_type}.{name}" for content_type, name in permissions}
|
||||
|
||||
def has_perm(self, perm: str):
|
||||
"""Return True if the client has the specified permission."""
|
||||
return perm in self.all_permissions
|
||||
|
||||
def has_perms(self, perm_list: Iterable[str]) -> bool:
|
||||
"""Return True if the client has each of the specified permissions."""
|
||||
if self._perm_cache is None:
|
||||
group_permissions = (
|
||||
Permission.objects.filter(group__group__in=self.groups.all())
|
||||
.values_list("content_type__app_label", "codename")
|
||||
.order_by()
|
||||
)
|
||||
client_permissions = self.client_permissions.values_list(
|
||||
"content_type__app_label", "codename"
|
||||
).order_by()
|
||||
self._perm_cache = {
|
||||
f"{content_type}.{name}"
|
||||
for content_type, name in (*group_permissions, *client_permissions)
|
||||
}
|
||||
return perm in self._perm_cache
|
||||
|
||||
def has_perms(self, perm_list):
|
||||
"""
|
||||
Return True if the client has each of the specified permissions. If
|
||||
object is passed, check if the client has all required perms for it.
|
||||
"""
|
||||
if not isinstance(perm_list, Iterable) or isinstance(perm_list, str):
|
||||
raise ValueError("perm_list must be an iterable of permissions.")
|
||||
return all(self.has_perm(perm) for perm in perm_list)
|
||||
|
||||
def reset_hmac(self, *, commit: bool = True) -> str:
|
||||
"""Reset and return the HMAC key for this client.
|
||||
|
||||
Args:
|
||||
commit: if True (the default), persist the new hmac in db.
|
||||
"""
|
||||
self.hmac_key = get_hmac_key()
|
||||
if commit:
|
||||
self.save()
|
||||
return self.hmac_key
|
||||
|
||||
|
||||
class ApiKey(models.Model):
|
||||
PREFIX_LENGTH = 5
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
from ninja import ModelSchema, Schema
|
||||
from pydantic import Field, HttpUrl
|
||||
|
||||
from api.models import ApiClient
|
||||
from core.schemas import SimpleUserSchema
|
||||
|
||||
|
||||
class ApiClientSchema(ModelSchema):
|
||||
class Meta:
|
||||
model = ApiClient
|
||||
fields = ["id", "name"]
|
||||
|
||||
owner: SimpleUserSchema
|
||||
permissions: list[str] = Field(alias="all_permissions")
|
||||
|
||||
|
||||
class ThirdPartyAuthParamsSchema(Schema):
|
||||
client_id: int
|
||||
third_party_app: str
|
||||
privacy_link: HttpUrl
|
||||
username: str
|
||||
callback_url: HttpUrl
|
||||
signature: str
|
||||
32
api/templates/api/third_party/auth.jinja
vendored
32
api/templates/api/third_party/auth.jinja
vendored
@@ -1,32 +0,0 @@
|
||||
{% extends "core/base.jinja" %}
|
||||
|
||||
{% block content %}
|
||||
<form method="post">
|
||||
{% csrf_token %}
|
||||
<h3>{% trans %}Confidentiality{% endtrans %}</h3>
|
||||
<p>
|
||||
{% trans trimmed app=third_party_app %}
|
||||
By ticking this box and clicking on the send button, you
|
||||
acknowledge and agree to provide {{ app }} with your
|
||||
first name, last name, nickname and any other information
|
||||
that was the third party app was explicitly authorized to fetch
|
||||
and that it must have acknowledged to you, in a complete and accurate manner.
|
||||
{% endtrans %}
|
||||
</p>
|
||||
<p class="margin-bottom">
|
||||
{% trans trimmed app=third_party_app, privacy_link=third_party_cgu, sith_cgu_link=sith_cgu %}
|
||||
The privacy policies of <a href="{{ privacy_link }}">{{ app }}</a>
|
||||
and of <a href="{{ sith_cgu_link }}">the Students' Association</a>
|
||||
applies as soon as the form is submitted.
|
||||
{% endtrans %}
|
||||
</p>
|
||||
<div class="row">{{ form.cgu_accepted }} {{ form.cgu_accepted.label_tag() }}</div>
|
||||
<br>
|
||||
<h3 class="margin-bottom">{% trans %}Confirmation of identity{% endtrans %}</h3>
|
||||
<div class="row margin-bottom">
|
||||
{{ form.is_username_valid }} {{ form.is_username_valid.label_tag() }}
|
||||
</div>
|
||||
{% for field in form.hidden_fields() %}{{ field }}{% endfor %}
|
||||
<input type="submit" class="btn btn-blue">
|
||||
</form>
|
||||
{% endblock %}
|
||||
@@ -1,24 +0,0 @@
|
||||
import pytest
|
||||
from django.contrib.admin import AdminSite
|
||||
from django.http import HttpRequest
|
||||
from model_bakery import baker
|
||||
from pytest_django.asserts import assertNumQueries
|
||||
|
||||
from api.admin import ApiClientAdmin
|
||||
from api.models import ApiClient
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_reset_hmac_action():
|
||||
client_admin = ApiClientAdmin(ApiClient, AdminSite())
|
||||
api_clients = baker.make(ApiClient, _quantity=4, _bulk_create=True)
|
||||
old_hmac_keys = [c.hmac_key for c in api_clients]
|
||||
with assertNumQueries(2):
|
||||
qs = ApiClient.objects.filter(id__in=[c.id for c in api_clients[2:4]])
|
||||
client_admin.reset_hmac_key(HttpRequest(), qs)
|
||||
for c in api_clients:
|
||||
c.refresh_from_db()
|
||||
assert api_clients[0].hmac_key == old_hmac_keys[0]
|
||||
assert api_clients[1].hmac_key == old_hmac_keys[1]
|
||||
assert api_clients[2].hmac_key != old_hmac_keys[2]
|
||||
assert api_clients[3].hmac_key != old_hmac_keys[3]
|
||||
@@ -1,18 +0,0 @@
|
||||
import pytest
|
||||
from django.test import Client
|
||||
from django.urls import reverse
|
||||
from model_bakery import baker
|
||||
|
||||
from api.hashers import generate_key
|
||||
from api.models import ApiClient, ApiKey
|
||||
from api.schemas import ApiClientSchema
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_api_client_controller(client: Client):
|
||||
key, hashed = generate_key()
|
||||
api_client = baker.make(ApiClient)
|
||||
baker.make(ApiKey, client=api_client, hashed_key=hashed)
|
||||
res = client.get(reverse("api:api-client-infos"), headers={"X-APIKey": key})
|
||||
assert res.status_code == 200
|
||||
assert res.json() == ApiClientSchema.from_orm(api_client).model_dump()
|
||||
@@ -1,59 +0,0 @@
|
||||
import pytest
|
||||
from django.contrib.auth.models import Permission
|
||||
from django.test import TestCase
|
||||
from model_bakery import baker
|
||||
|
||||
from api.models import ApiClient
|
||||
from core.models import Group
|
||||
|
||||
|
||||
class TestClientPermissions(TestCase):
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
cls.api_client = baker.make(ApiClient)
|
||||
cls.perms = baker.make(Permission, _quantity=10, _bulk_create=True)
|
||||
cls.api_client.groups.set(
|
||||
[
|
||||
baker.make(Group, permissions=cls.perms[0:3]),
|
||||
baker.make(Group, permissions=cls.perms[3:5]),
|
||||
]
|
||||
)
|
||||
cls.api_client.client_permissions.set(
|
||||
[cls.perms[3], cls.perms[5], cls.perms[6], cls.perms[7]]
|
||||
)
|
||||
|
||||
def test_all_permissions(self):
|
||||
assert self.api_client.all_permissions == {
|
||||
f"{p.content_type.app_label}.{p.codename}" for p in self.perms[0:8]
|
||||
}
|
||||
|
||||
def test_has_perm(self):
|
||||
assert self.api_client.has_perm(
|
||||
f"{self.perms[1].content_type.app_label}.{self.perms[1].codename}"
|
||||
)
|
||||
assert not self.api_client.has_perm(
|
||||
f"{self.perms[9].content_type.app_label}.{self.perms[9].codename}"
|
||||
)
|
||||
|
||||
def test_has_perms(self):
|
||||
assert self.api_client.has_perms(
|
||||
[
|
||||
f"{self.perms[1].content_type.app_label}.{self.perms[1].codename}",
|
||||
f"{self.perms[2].content_type.app_label}.{self.perms[2].codename}",
|
||||
]
|
||||
)
|
||||
assert not self.api_client.has_perms(
|
||||
[
|
||||
f"{self.perms[1].content_type.app_label}.{self.perms[1].codename}",
|
||||
f"{self.perms[9].content_type.app_label}.{self.perms[9].codename}",
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_reset_hmac_key():
|
||||
client = baker.make(ApiClient)
|
||||
original_key = client.hmac_key
|
||||
client.reset_hmac(commit=True)
|
||||
assert len(client.hmac_key) == len(original_key)
|
||||
assert client.hmac_key != original_key
|
||||
@@ -1,114 +0,0 @@
|
||||
from unittest import mock
|
||||
from unittest.mock import Mock
|
||||
|
||||
from django.db.models import Max
|
||||
from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
from model_bakery import baker
|
||||
from pytest_django.asserts import assertRedirects
|
||||
|
||||
from api.models import ApiClient, get_hmac_key
|
||||
from core.baker_recipes import subscriber_user
|
||||
from core.schemas import UserProfileSchema
|
||||
from core.utils import hmac_hexdigest
|
||||
|
||||
|
||||
def mocked_post(*, ok: bool):
|
||||
class MockedResponse(Mock):
|
||||
@property
|
||||
def ok(self):
|
||||
return ok
|
||||
|
||||
def mocked():
|
||||
return MockedResponse()
|
||||
|
||||
return mocked
|
||||
|
||||
|
||||
class TestThirdPartyAuth(TestCase):
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
cls.user = subscriber_user.make()
|
||||
cls.api_client = baker.make(ApiClient)
|
||||
|
||||
def setUp(self):
|
||||
self.query = {
|
||||
"client_id": self.api_client.id,
|
||||
"third_party_app": "app",
|
||||
"privacy_link": "https://foobar.fr/",
|
||||
"username": "bibou",
|
||||
"callback_url": "https://callback.fr/",
|
||||
}
|
||||
self.query["signature"] = hmac_hexdigest(self.api_client.hmac_key, self.query)
|
||||
self.callback_data = {
|
||||
"user": UserProfileSchema.from_orm(self.user).model_dump()
|
||||
}
|
||||
self.callback_data["signature"] = hmac_hexdigest(
|
||||
self.api_client.hmac_key, self.callback_data["user"]
|
||||
)
|
||||
|
||||
def test_auth_ok(self):
|
||||
self.client.force_login(self.user)
|
||||
res = self.client.get(reverse("api-link:third-party-auth", query=self.query))
|
||||
assert res.status_code == 200
|
||||
with mock.patch("requests.post", new_callable=mocked_post(ok=True)) as mocked:
|
||||
res = self.client.post(
|
||||
reverse("api-link:third-party-auth"),
|
||||
data={"cgu_accepted": True, "is_username_valid": True, **self.query},
|
||||
)
|
||||
mocked.assert_called_once_with(
|
||||
self.query["callback_url"], json=self.callback_data
|
||||
)
|
||||
assertRedirects(
|
||||
res,
|
||||
reverse("api-link:third-party-auth-result", kwargs={"result": "success"}),
|
||||
)
|
||||
|
||||
def test_callback_error(self):
|
||||
"""Test that the user see the failure page if the callback request failed."""
|
||||
self.client.force_login(self.user)
|
||||
with mock.patch("requests.post", new_callable=mocked_post(ok=False)) as mocked:
|
||||
res = self.client.post(
|
||||
reverse("api-link:third-party-auth"),
|
||||
data={"cgu_accepted": True, "is_username_valid": True, **self.query},
|
||||
)
|
||||
mocked.assert_called_once_with(
|
||||
self.query["callback_url"], json=self.callback_data
|
||||
)
|
||||
assertRedirects(
|
||||
res,
|
||||
reverse("api-link:third-party-auth-result", kwargs={"result": "failure"}),
|
||||
)
|
||||
|
||||
def test_wrong_signature(self):
|
||||
"""Test that a 403 is raised if the signature of the query is wrong."""
|
||||
self.client.force_login(subscriber_user.make())
|
||||
new_key = get_hmac_key()
|
||||
del self.query["signature"]
|
||||
self.query["signature"] = hmac_hexdigest(new_key, self.query)
|
||||
res = self.client.get(reverse("api-link:third-party-auth", query=self.query))
|
||||
assert res.status_code == 403
|
||||
|
||||
def test_cgu_not_accepted(self):
|
||||
self.client.force_login(self.user)
|
||||
res = self.client.get(reverse("api-link:third-party-auth", query=self.query))
|
||||
assert res.status_code == 200
|
||||
res = self.client.post(reverse("api-link:third-party-auth"), data=self.query)
|
||||
assert res.status_code == 200 # no redirect means invalid form
|
||||
res = self.client.post(
|
||||
reverse("api-link:third-party-auth"),
|
||||
data={"cgu_accepted": False, "is_username_valid": False, **self.query},
|
||||
)
|
||||
assert res.status_code == 200
|
||||
|
||||
def test_invalid_client(self):
|
||||
self.query["client_id"] = ApiClient.objects.aggregate(res=Max("id"))["res"] + 1
|
||||
res = self.client.get(reverse("api-link:third-party-auth", query=self.query))
|
||||
assert res.status_code == 403
|
||||
|
||||
def test_missing_parameter(self):
|
||||
"""Test that a 403 is raised if there is a missing parameter."""
|
||||
del self.query["username"]
|
||||
self.query["signature"] = hmac_hexdigest(self.api_client.hmac_key, self.query)
|
||||
res = self.client.get(reverse("api-link:third-party-auth", query=self.query))
|
||||
assert res.status_code == 403
|
||||
15
api/urls.py
15
api/urls.py
@@ -1,10 +1,6 @@
|
||||
from django.urls import path, register_converter
|
||||
from ninja.security import SessionAuth
|
||||
from ninja_extra import NinjaExtraAPI
|
||||
|
||||
from api.views import ThirdPartyAuthResultView, ThirdPartyAuthView
|
||||
from core.converters import ResultConverter
|
||||
|
||||
api = NinjaExtraAPI(
|
||||
title="PICON",
|
||||
description="Portail Interactif de Communication avec les Outils Numériques",
|
||||
@@ -13,14 +9,3 @@ api = NinjaExtraAPI(
|
||||
auth=[SessionAuth()],
|
||||
)
|
||||
api.auto_discover_controllers()
|
||||
|
||||
register_converter(ResultConverter, "res")
|
||||
|
||||
urlpatterns = [
|
||||
path("auth/", ThirdPartyAuthView.as_view(), name="third-party-auth"),
|
||||
path(
|
||||
"auth/<res:result>/",
|
||||
ThirdPartyAuthResultView.as_view(),
|
||||
name="third-party-auth-result",
|
||||
),
|
||||
]
|
||||
|
||||
119
api/views.py
119
api/views.py
@@ -1,119 +0,0 @@
|
||||
import hmac
|
||||
from urllib.parse import unquote
|
||||
|
||||
import pydantic
|
||||
import requests
|
||||
from django.conf import settings
|
||||
from django.contrib import messages
|
||||
from django.contrib.auth.mixins import LoginRequiredMixin
|
||||
from django.core.exceptions import PermissionDenied
|
||||
from django.urls import reverse, reverse_lazy
|
||||
from django.utils.translation import gettext as _
|
||||
from django.views.generic import FormView, TemplateView
|
||||
from ninja_extra.shortcuts import get_object_or_none
|
||||
|
||||
from api.forms import ThirdPartyAuthForm
|
||||
from api.models import ApiClient
|
||||
from api.schemas import ThirdPartyAuthParamsSchema
|
||||
from core.models import SithFile
|
||||
from core.schemas import UserProfileSchema
|
||||
from core.utils import hmac_hexdigest
|
||||
|
||||
|
||||
class ThirdPartyAuthView(LoginRequiredMixin, FormView):
|
||||
form_class = ThirdPartyAuthForm
|
||||
template_name = "api/third_party/auth.jinja"
|
||||
success_url = reverse_lazy("core:index")
|
||||
|
||||
def parse_params(self) -> ThirdPartyAuthParamsSchema:
|
||||
"""Parse and check the authentication parameters.
|
||||
|
||||
Raises:
|
||||
PermissionDenied: if the verification failed.
|
||||
"""
|
||||
# This is here rather than in ThirdPartyAuthForm because
|
||||
# the given parameters and their signature are checked during both
|
||||
# POST (for obvious reasons) and GET (in order not to make
|
||||
# the user fill a form just to get an error he won't understand)
|
||||
params = self.request.GET or self.request.POST
|
||||
params = {key: unquote(val) for key, val in params.items()}
|
||||
try:
|
||||
params = ThirdPartyAuthParamsSchema(**params)
|
||||
except pydantic.ValidationError as e:
|
||||
raise PermissionDenied("Wrong data format") from e
|
||||
client: ApiClient = get_object_or_none(ApiClient, id=params.client_id)
|
||||
if not client:
|
||||
raise PermissionDenied
|
||||
if not hmac.compare_digest(
|
||||
hmac_hexdigest(client.hmac_key, params.model_dump(exclude={"signature"})),
|
||||
params.signature,
|
||||
):
|
||||
raise PermissionDenied("Bad signature")
|
||||
return params
|
||||
|
||||
def dispatch(self, request, *args, **kwargs):
|
||||
self.params = self.parse_params()
|
||||
return super().dispatch(request, *args, **kwargs)
|
||||
|
||||
def get(self, *args, **kwargs):
|
||||
messages.warning(
|
||||
self.request,
|
||||
_(
|
||||
"You are going to link your AE account and your %(app)s account. "
|
||||
"Continue only if this page was opened from %(app)s."
|
||||
)
|
||||
% {"app": self.params.third_party_app},
|
||||
)
|
||||
return super().get(*args, **kwargs)
|
||||
|
||||
def get_initial(self):
|
||||
return self.params.model_dump()
|
||||
|
||||
def form_valid(self, form):
|
||||
client = ApiClient.objects.get(id=form.cleaned_data["client_id"])
|
||||
user = UserProfileSchema.from_orm(self.request.user).model_dump()
|
||||
data = {"user": user, "signature": hmac_hexdigest(client.hmac_key, user)}
|
||||
response = requests.post(form.cleaned_data["callback_url"], json=data)
|
||||
self.success_url = reverse(
|
||||
"api-link:third-party-auth-result",
|
||||
kwargs={"result": "success" if response.ok else "failure"},
|
||||
)
|
||||
return super().form_valid(form)
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
return super().get_context_data(**kwargs) | {
|
||||
"third_party_app": self.params.third_party_app,
|
||||
"third_party_cgu": self.params.privacy_link,
|
||||
"sith_cgu": SithFile.objects.get(id=settings.SITH_CGU_FILE_ID),
|
||||
}
|
||||
|
||||
|
||||
class ThirdPartyAuthResultView(LoginRequiredMixin, TemplateView):
|
||||
"""View that the user will see if its authentication on sith was successful.
|
||||
|
||||
This can show either a success or a failure message :
|
||||
- success : everything is good, the user is successfully authenticated
|
||||
and can close the page
|
||||
- failure : the authentication has been processed on the sith side,
|
||||
but the request to the callback url received an error.
|
||||
In such a case, there is nothing much we can do but to advice
|
||||
the user to contact the developers of the third-party app.
|
||||
"""
|
||||
|
||||
template_name = "core/base.jinja"
|
||||
success_message = _(
|
||||
"You have been successfully authenticated. You can now close this page."
|
||||
)
|
||||
error_message = _(
|
||||
"Your authentication on the AE website was successful, "
|
||||
"but an error happened during the interaction "
|
||||
"with the third-party application. "
|
||||
"Please contact the managers of the latter."
|
||||
)
|
||||
|
||||
def get(self, request, *args, **kwargs):
|
||||
if self.kwargs.get("result") == "success":
|
||||
messages.success(request, self.success_message)
|
||||
else:
|
||||
messages.error(request, self.error_message)
|
||||
return super().get(request, *args, **kwargs)
|
||||
@@ -123,7 +123,7 @@ class GroupController(ControllerBase):
|
||||
)
|
||||
@paginate(PageNumberPaginationExtra, page_size=50)
|
||||
def search_group(self, search: Annotated[str, MinLen(1)]):
|
||||
return Group.objects.filter(name__icontains=search).values()
|
||||
return Group.objects.filter(name__icontains=search).order_by("name").values()
|
||||
|
||||
|
||||
DepthValue = Annotated[int, Ge(0), Le(10)]
|
||||
|
||||
@@ -1,16 +1,19 @@
|
||||
from django.urls.converters import IntConverter, StringConverter
|
||||
|
||||
|
||||
class FourDigitYearConverter(IntConverter):
|
||||
class FourDigitYearConverter:
|
||||
regex = "[0-9]{4}"
|
||||
|
||||
def to_python(self, value):
|
||||
return int(value)
|
||||
|
||||
def to_url(self, value):
|
||||
return str(value).zfill(4)
|
||||
|
||||
|
||||
class TwoDigitMonthConverter(IntConverter):
|
||||
class TwoDigitMonthConverter:
|
||||
regex = "[0-9]{2}"
|
||||
|
||||
def to_python(self, value):
|
||||
return int(value)
|
||||
|
||||
def to_url(self, value):
|
||||
return str(value).zfill(2)
|
||||
|
||||
@@ -25,9 +28,3 @@ class BooleanStringConverter:
|
||||
|
||||
def to_url(self, value):
|
||||
return str(value)
|
||||
|
||||
|
||||
class ResultConverter(StringConverter):
|
||||
"""Converter whose regex match either "success" or "failure"."""
|
||||
|
||||
regex = "(success|failure)"
|
||||
|
||||
@@ -28,7 +28,6 @@ from typing import ClassVar, NamedTuple
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.models import Permission
|
||||
from django.contrib.sites.models import Site
|
||||
from django.core.files.base import ContentFile
|
||||
from django.core.management import call_command
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.db import connection
|
||||
@@ -42,7 +41,14 @@ from com.ics_calendar import IcsCalendar
|
||||
from com.models import News, NewsDate, Sith, Weekmail
|
||||
from core.models import BanGroup, Group, Page, PageRev, SithFile, User
|
||||
from core.utils import resize_image
|
||||
from counter.models import Counter, Product, ProductType, ReturnableProduct, StudentCard
|
||||
from counter.models import (
|
||||
Counter,
|
||||
Price,
|
||||
Product,
|
||||
ProductType,
|
||||
ReturnableProduct,
|
||||
StudentCard,
|
||||
)
|
||||
from election.models import Candidature, Election, ElectionList, Role
|
||||
from forum.models import Forum
|
||||
from pedagogy.models import UE
|
||||
@@ -105,21 +111,13 @@ class Command(BaseCommand):
|
||||
)
|
||||
self.profiles_root = SithFile.objects.create(name="profiles", owner=root)
|
||||
home_root = SithFile.objects.create(name="users", owner=root)
|
||||
club_root = SithFile.objects.create(name="clubs", owner=root)
|
||||
sas = SithFile.objects.create(name="SAS", owner=root)
|
||||
SithFile.objects.create(
|
||||
name="CGU",
|
||||
is_folder=False,
|
||||
file=ContentFile(
|
||||
content="Conditions générales d'utilisation", name="cgu.txt"
|
||||
),
|
||||
owner=root,
|
||||
)
|
||||
|
||||
# Page needed for club creation
|
||||
p = Page(name=settings.SITH_CLUB_ROOT_PAGE)
|
||||
p.save(force_lock=True)
|
||||
|
||||
club_root = SithFile.objects.create(name="clubs", owner=root)
|
||||
sas = SithFile.objects.create(name="SAS", owner=root)
|
||||
main_club = Club.objects.create(
|
||||
id=1, name="AE", address="6 Boulevard Anatole France, 90000 Belfort"
|
||||
)
|
||||
@@ -377,125 +375,15 @@ class Command(BaseCommand):
|
||||
end_date=localdate() - timedelta(days=100),
|
||||
)
|
||||
|
||||
p = ProductType.objects.create(name="Bières bouteilles")
|
||||
c = ProductType.objects.create(name="Cotisations")
|
||||
r = ProductType.objects.create(name="Rechargements")
|
||||
verre = ProductType.objects.create(name="Verre")
|
||||
cotis = Product.objects.create(
|
||||
name="Cotis 1 semestre",
|
||||
code="1SCOTIZ",
|
||||
product_type=c,
|
||||
purchase_price="15",
|
||||
selling_price="15",
|
||||
special_selling_price="15",
|
||||
club=main_club,
|
||||
)
|
||||
cotis2 = Product.objects.create(
|
||||
name="Cotis 2 semestres",
|
||||
code="2SCOTIZ",
|
||||
product_type=c,
|
||||
purchase_price="28",
|
||||
selling_price="28",
|
||||
special_selling_price="28",
|
||||
club=main_club,
|
||||
)
|
||||
refill = Product.objects.create(
|
||||
name="Rechargement 15 €",
|
||||
code="15REFILL",
|
||||
product_type=r,
|
||||
purchase_price="15",
|
||||
selling_price="15",
|
||||
special_selling_price="15",
|
||||
club=main_club,
|
||||
)
|
||||
barb = Product.objects.create(
|
||||
name="Barbar",
|
||||
code="BARB",
|
||||
product_type=p,
|
||||
purchase_price="1.50",
|
||||
selling_price="1.7",
|
||||
special_selling_price="1.6",
|
||||
club=main_club,
|
||||
limit_age=18,
|
||||
)
|
||||
cble = Product.objects.create(
|
||||
name="Chimay Bleue",
|
||||
code="CBLE",
|
||||
product_type=p,
|
||||
purchase_price="1.50",
|
||||
selling_price="1.7",
|
||||
special_selling_price="1.6",
|
||||
club=main_club,
|
||||
limit_age=18,
|
||||
)
|
||||
cons = Product.objects.create(
|
||||
name="Consigne Eco-cup",
|
||||
code="CONS",
|
||||
product_type=verre,
|
||||
purchase_price="1",
|
||||
selling_price="1",
|
||||
special_selling_price="1",
|
||||
club=main_club,
|
||||
)
|
||||
dcons = Product.objects.create(
|
||||
name="Déconsigne Eco-cup",
|
||||
code="DECO",
|
||||
product_type=verre,
|
||||
purchase_price="-1",
|
||||
selling_price="-1",
|
||||
special_selling_price="-1",
|
||||
club=main_club,
|
||||
)
|
||||
cors = Product.objects.create(
|
||||
name="Corsendonk",
|
||||
code="CORS",
|
||||
product_type=p,
|
||||
purchase_price="1.50",
|
||||
selling_price="1.7",
|
||||
special_selling_price="1.6",
|
||||
club=main_club,
|
||||
limit_age=18,
|
||||
)
|
||||
carolus = Product.objects.create(
|
||||
name="Carolus",
|
||||
code="CARO",
|
||||
product_type=p,
|
||||
purchase_price="1.50",
|
||||
selling_price="1.7",
|
||||
special_selling_price="1.6",
|
||||
club=main_club,
|
||||
limit_age=18,
|
||||
)
|
||||
Product.objects.create(
|
||||
name="remboursement",
|
||||
code="REMBOURS",
|
||||
purchase_price="0",
|
||||
selling_price="0",
|
||||
special_selling_price="0",
|
||||
club=refound,
|
||||
)
|
||||
groups.subscribers.products.add(
|
||||
cotis, cotis2, refill, barb, cble, cors, carolus
|
||||
)
|
||||
groups.old_subscribers.products.add(cotis, cotis2)
|
||||
|
||||
mde = Counter.objects.get(name="MDE")
|
||||
mde.products.add(barb, cble, cons, dcons)
|
||||
|
||||
eboutic = Counter.objects.get(name="Eboutic")
|
||||
eboutic.products.add(barb, cotis, cotis2, refill)
|
||||
self._create_products(groups, main_club, refound)
|
||||
|
||||
Counter.objects.create(name="Carte AE", club=refound, type="OFFICE")
|
||||
|
||||
ReturnableProduct.objects.create(
|
||||
product=cons, returned_product=dcons, max_return=3
|
||||
)
|
||||
|
||||
# Add barman to counter
|
||||
Counter.sellers.through.objects.bulk_create(
|
||||
[
|
||||
Counter.sellers.through(counter_id=2, user=krophil),
|
||||
Counter.sellers.through(counter=mde, user=skia),
|
||||
Counter.sellers.through(counter_id=1, user=skia), # MDE
|
||||
Counter.sellers.through(counter_id=2, user=krophil), # Foyer
|
||||
]
|
||||
)
|
||||
|
||||
@@ -751,6 +639,131 @@ class Command(BaseCommand):
|
||||
]
|
||||
)
|
||||
|
||||
def _create_products(
|
||||
self, groups: PopulatedGroups, main_club: Club, refound_club: Club
|
||||
):
|
||||
beers_type, cotis_type, refill_type, verre_type = (
|
||||
ProductType.objects.bulk_create(
|
||||
[
|
||||
ProductType(name="Bières bouteilles"),
|
||||
ProductType(name="Cotisations"),
|
||||
ProductType(name="Rechargements"),
|
||||
ProductType(name="Verre"),
|
||||
]
|
||||
)
|
||||
)
|
||||
cotis = Product.objects.create(
|
||||
name="Cotis 1 semestre",
|
||||
code="1SCOTIZ",
|
||||
product_type=cotis_type,
|
||||
purchase_price=15,
|
||||
club=main_club,
|
||||
)
|
||||
cotis2 = Product.objects.create(
|
||||
name="Cotis 2 semestres",
|
||||
code="2SCOTIZ",
|
||||
product_type=cotis_type,
|
||||
purchase_price="28",
|
||||
club=main_club,
|
||||
)
|
||||
refill = Product.objects.create(
|
||||
name="Rechargement 15 €",
|
||||
code="15REFILL",
|
||||
product_type=refill_type,
|
||||
purchase_price=15,
|
||||
club=main_club,
|
||||
)
|
||||
barb = Product.objects.create(
|
||||
name="Barbar",
|
||||
code="BARB",
|
||||
product_type=beers_type,
|
||||
purchase_price="1.50",
|
||||
club=main_club,
|
||||
limit_age=18,
|
||||
)
|
||||
cble = Product.objects.create(
|
||||
name="Chimay Bleue",
|
||||
code="CBLE",
|
||||
product_type=beers_type,
|
||||
purchase_price="1.50",
|
||||
club=main_club,
|
||||
limit_age=18,
|
||||
)
|
||||
cons = Product.objects.create(
|
||||
name="Consigne Eco-cup",
|
||||
code="CONS",
|
||||
product_type=verre_type,
|
||||
purchase_price="1",
|
||||
club=main_club,
|
||||
)
|
||||
dcons = Product.objects.create(
|
||||
name="Déconsigne Eco-cup",
|
||||
code="DECO",
|
||||
product_type=verre_type,
|
||||
purchase_price="-1",
|
||||
club=main_club,
|
||||
)
|
||||
cors = Product.objects.create(
|
||||
name="Corsendonk",
|
||||
code="CORS",
|
||||
product_type=beers_type,
|
||||
purchase_price="1.50",
|
||||
club=main_club,
|
||||
limit_age=18,
|
||||
)
|
||||
carolus = Product.objects.create(
|
||||
name="Carolus",
|
||||
code="CARO",
|
||||
product_type=beers_type,
|
||||
purchase_price="1.50",
|
||||
club=main_club,
|
||||
limit_age=18,
|
||||
)
|
||||
Product.objects.create(
|
||||
name="remboursement",
|
||||
code="REMBOURS",
|
||||
purchase_price=0,
|
||||
club=refound_club,
|
||||
)
|
||||
ReturnableProduct.objects.create(
|
||||
product=cons, returned_product=dcons, max_return=3
|
||||
)
|
||||
mde = Counter.objects.get(name="MDE")
|
||||
mde.products.add(barb, cble, cons, dcons)
|
||||
eboutic = Counter.objects.get(name="Eboutic")
|
||||
eboutic.products.add(barb, cotis, cotis2, refill)
|
||||
|
||||
cotis, cotis2, refill, barb, cble, cors, carolus, cons, dcons = (
|
||||
Price.objects.bulk_create(
|
||||
[
|
||||
Price(product=cotis, amount=15),
|
||||
Price(product=cotis2, amount=28),
|
||||
Price(product=refill, amount=15),
|
||||
Price(product=barb, amount=1.7),
|
||||
Price(product=cble, amount=1.7),
|
||||
Price(product=cors, amount=1.7),
|
||||
Price(product=carolus, amount=1.7),
|
||||
Price(product=cons, amount=1),
|
||||
Price(product=dcons, amount=-1),
|
||||
]
|
||||
)
|
||||
)
|
||||
Price.groups.through.objects.bulk_create(
|
||||
[
|
||||
Price.groups.through(price=cotis, group=groups.subscribers),
|
||||
Price.groups.through(price=cotis2, group=groups.subscribers),
|
||||
Price.groups.through(price=refill, group=groups.subscribers),
|
||||
Price.groups.through(price=barb, group=groups.subscribers),
|
||||
Price.groups.through(price=cble, group=groups.subscribers),
|
||||
Price.groups.through(price=cors, group=groups.subscribers),
|
||||
Price.groups.through(price=carolus, group=groups.subscribers),
|
||||
Price.groups.through(price=cotis, group=groups.old_subscribers),
|
||||
Price.groups.through(price=cotis2, group=groups.old_subscribers),
|
||||
Price.groups.through(price=cons, group=groups.old_subscribers),
|
||||
Price.groups.through(price=dcons, group=groups.old_subscribers),
|
||||
]
|
||||
)
|
||||
|
||||
def _create_profile_pict(self, user: User):
|
||||
path = self.SAS_FIXTURE_PATH / "Family" / f"{user.username}.jpg"
|
||||
file = resize_image(Image.open(path), 400, "WEBP")
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import math
|
||||
import random
|
||||
from datetime import date, timedelta
|
||||
from datetime import timezone as tz
|
||||
@@ -18,6 +17,7 @@ from counter.models import (
|
||||
Counter,
|
||||
Customer,
|
||||
Permanency,
|
||||
Price,
|
||||
Product,
|
||||
ProductType,
|
||||
Refilling,
|
||||
@@ -35,17 +35,12 @@ class Command(BaseCommand):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.faker = Faker("fr_FR")
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument(
|
||||
"-n", "--nb-users", help="Number of users to create", type=int, default=600
|
||||
)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
if not settings.DEBUG:
|
||||
raise Exception("Never call this command in prod. Never.")
|
||||
|
||||
self.stdout.write("Creating users...")
|
||||
users = self.create_users(options["nb_users"])
|
||||
users = self.create_users()
|
||||
self.create_bans(random.sample(users, k=len(users) // 200)) # 0.5% of users
|
||||
subscribers = random.sample(users, k=int(0.8 * len(users)))
|
||||
self.stdout.write("Creating subscriptions...")
|
||||
@@ -85,7 +80,7 @@ class Command(BaseCommand):
|
||||
self.stdout.write("Creating products...")
|
||||
self.create_products()
|
||||
self.stdout.write("Creating sales and refills...")
|
||||
sellers = random.sample(users, len(users) // 10)
|
||||
sellers = random.sample(list(User.objects.all()), 100)
|
||||
self.create_sales(sellers)
|
||||
self.stdout.write("Creating permanences...")
|
||||
self.create_permanences(sellers)
|
||||
@@ -94,7 +89,7 @@ class Command(BaseCommand):
|
||||
|
||||
self.stdout.write("Done")
|
||||
|
||||
def create_users(self, nb_users: int = 600) -> list[User]:
|
||||
def create_users(self) -> list[User]:
|
||||
# Create a single password hash for all users to make it faster.
|
||||
# It's insecure as hell, but it's ok since it's only for dev purposes.
|
||||
password = make_password("plop")
|
||||
@@ -113,7 +108,7 @@ class Command(BaseCommand):
|
||||
address=self.faker.address(),
|
||||
password=password,
|
||||
)
|
||||
for _ in range(nb_users)
|
||||
for _ in range(600)
|
||||
]
|
||||
# there may a duplicate or two
|
||||
# Not a problem, we will just have 599 users instead of 600
|
||||
@@ -284,6 +279,7 @@ class Command(BaseCommand):
|
||||
# 2/3 of the products are owned by AE
|
||||
clubs = [ae, ae, ae, ae, ae, ae, *other_clubs]
|
||||
products = []
|
||||
prices = []
|
||||
buying_groups = []
|
||||
selling_places = []
|
||||
for _ in range(200):
|
||||
@@ -294,25 +290,28 @@ class Command(BaseCommand):
|
||||
product_type=random.choice(categories),
|
||||
code="".join(self.faker.random_letters(length=random.randint(4, 8))),
|
||||
purchase_price=price,
|
||||
selling_price=price,
|
||||
special_selling_price=price - min(0.5, price),
|
||||
club=random.choice(clubs),
|
||||
limit_age=0 if random.random() > 0.2 else 18,
|
||||
archived=bool(random.random() > 0.7),
|
||||
archived=self.faker.boolean(60),
|
||||
)
|
||||
products.append(product)
|
||||
# there will be products without buying groups
|
||||
# but there are also such products in the real database
|
||||
buying_groups.extend(
|
||||
Product.buying_groups.through(product=product, group=group)
|
||||
for group in random.sample(groups, k=random.randint(0, 3))
|
||||
)
|
||||
for i in range(random.randint(0, 3)):
|
||||
product_price = Price(
|
||||
amount=price, product=product, is_always_shown=self.faker.boolean()
|
||||
)
|
||||
# prices for non-subscribers will be higher than for subscribers
|
||||
price *= 1.2
|
||||
prices.append(product_price)
|
||||
buying_groups.append(
|
||||
Price.groups.through(price=product_price, group=groups[i])
|
||||
)
|
||||
selling_places.extend(
|
||||
Counter.products.through(counter=counter, product=product)
|
||||
for counter in random.sample(counters, random.randint(0, 4))
|
||||
)
|
||||
Product.objects.bulk_create(products)
|
||||
Product.buying_groups.through.objects.bulk_create(buying_groups)
|
||||
Price.objects.bulk_create(prices)
|
||||
Price.groups.through.objects.bulk_create(buying_groups)
|
||||
Counter.products.through.objects.bulk_create(selling_places)
|
||||
|
||||
def create_sales(self, sellers: list[User]):
|
||||
@@ -326,7 +325,7 @@ class Command(BaseCommand):
|
||||
)
|
||||
)
|
||||
)
|
||||
products = list(Product.objects.all())
|
||||
prices = list(Price.objects.select_related("product").all())
|
||||
counters = list(
|
||||
Counter.objects.filter(name__in=["Foyer", "MDE", "La Gommette"])
|
||||
)
|
||||
@@ -336,14 +335,14 @@ class Command(BaseCommand):
|
||||
# the longer the customer has existed, the higher the mean of nb_products
|
||||
mu = 5 + (now().year - customer.since.year) * 2
|
||||
nb_sales = max(0, int(random.normalvariate(mu=mu, sigma=mu * 5)))
|
||||
favoured_products = random.sample(products, k=(random.randint(1, 5)))
|
||||
favoured_prices = random.sample(prices, k=(random.randint(1, 5)))
|
||||
favoured_counter = random.choice(counters)
|
||||
this_customer_sales = []
|
||||
for _ in range(nb_sales):
|
||||
product = (
|
||||
random.choice(favoured_products)
|
||||
price = (
|
||||
random.choice(favoured_prices)
|
||||
if random.random() > 0.7
|
||||
else random.choice(products)
|
||||
else random.choice(prices)
|
||||
)
|
||||
counter = (
|
||||
favoured_counter
|
||||
@@ -352,11 +351,11 @@ class Command(BaseCommand):
|
||||
)
|
||||
this_customer_sales.append(
|
||||
Selling(
|
||||
product=product,
|
||||
product=price.product,
|
||||
counter=counter,
|
||||
club_id=product.club_id,
|
||||
club_id=price.product.club_id,
|
||||
quantity=random.randint(1, 5),
|
||||
unit_price=product.selling_price,
|
||||
unit_price=price.amount,
|
||||
seller=random.choice(sellers),
|
||||
customer=customer,
|
||||
date=make_aware(
|
||||
@@ -416,9 +415,8 @@ class Command(BaseCommand):
|
||||
Permanency.objects.bulk_create(perms)
|
||||
|
||||
def create_forums(self):
|
||||
users = list(User.objects.all())
|
||||
forumers = random.sample(users, math.ceil(len(users) / 10))
|
||||
most_actives = random.sample(forumers, math.ceil(len(forumers) / 6))
|
||||
forumers = random.sample(list(User.objects.all()), 100)
|
||||
most_actives = random.sample(forumers, 10)
|
||||
categories = list(Forum.objects.filter(is_category=True))
|
||||
new_forums = [
|
||||
Forum(name=self.faker.text(20), parent=random.choice(categories))
|
||||
|
||||
@@ -141,6 +141,7 @@ form {
|
||||
display: block;
|
||||
margin: calc(var(--nf-input-size) * 1.5) auto 10px;
|
||||
line-height: 1;
|
||||
white-space: nowrap;
|
||||
|
||||
.fields-centered {
|
||||
padding: 10px 10px 0;
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
<div id="quick-notifications"
|
||||
x-data="{
|
||||
messages: [
|
||||
{%- if messages -%}
|
||||
{%- for message in messages -%}
|
||||
{ tag: '{{ message.tags }}', text: '{{ message }}' },
|
||||
{%- endfor -%}
|
||||
{%- endif -%}
|
||||
{% if messages %}
|
||||
{% for message in messages %}
|
||||
{
|
||||
tag: '{{ message.tags }}',
|
||||
text: '{{ message }}',
|
||||
},
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
]
|
||||
}"
|
||||
@quick-notification-add="(e) => messages.push(e?.detail)"
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
import contextlib
|
||||
import os
|
||||
|
||||
import pytest
|
||||
from django.core.management import call_command
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_populate_more(settings):
|
||||
"""Just check that populate more doesn't crash"""
|
||||
settings.DEBUG = True
|
||||
with open(os.devnull, "w") as devnull, contextlib.redirect_stdout(devnull):
|
||||
call_command("populate_more", "--nb-users", "50")
|
||||
@@ -213,9 +213,9 @@ def test_user_invoice_with_multiple_items():
|
||||
"""Test that annotate_total() works when invoices contain multiple items."""
|
||||
user: User = subscriber_user.make()
|
||||
item_recipe = Recipe(InvoiceItem, invoice=foreign_key(Recipe(Invoice, user=user)))
|
||||
item_recipe.make(_quantity=3, quantity=1, product_unit_price=5)
|
||||
item_recipe.make(_quantity=1, quantity=1, product_unit_price=5)
|
||||
item_recipe.make(_quantity=2, quantity=1, product_unit_price=iter([5, 8]))
|
||||
item_recipe.make(_quantity=3, quantity=1, unit_price=5)
|
||||
item_recipe.make(_quantity=1, quantity=1, unit_price=5)
|
||||
item_recipe.make(_quantity=2, quantity=1, unit_price=iter([5, 8]))
|
||||
res = list(
|
||||
Invoice.objects.filter(user=user)
|
||||
.annotate_total()
|
||||
|
||||
@@ -12,32 +12,22 @@
|
||||
# OR WITHIN THE LOCAL FILE "LICENSE"
|
||||
#
|
||||
#
|
||||
from __future__ import annotations
|
||||
|
||||
import hmac
|
||||
from datetime import date, timedelta
|
||||
|
||||
# Image utils
|
||||
from io import BytesIO
|
||||
from typing import TYPE_CHECKING
|
||||
from urllib.parse import urlencode
|
||||
from typing import Final
|
||||
|
||||
import PIL
|
||||
from django.conf import settings
|
||||
from django.core.files.base import ContentFile
|
||||
from django.core.files.uploadedfile import UploadedFile
|
||||
from django.http import HttpRequest
|
||||
from django.utils.timezone import localdate
|
||||
from PIL import ExifTags
|
||||
from PIL.Image import Image, Resampling
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from _hashlib import HASH
|
||||
from collections.abc import Buffer, Mapping, Sequence
|
||||
from typing import Any, Callable, Final
|
||||
|
||||
from django.core.files.uploadedfile import UploadedFile
|
||||
from django.http import HttpRequest
|
||||
|
||||
|
||||
RED_PIXEL_PNG: Final[bytes] = (
|
||||
b"\x89\x50\x4e\x47\x0d\x0a\x1a\x0a\x00\x00\x00\x0d\x49\x48\x44\x52"
|
||||
b"\x00\x00\x00\x01\x00\x00\x00\x01\x08\x02\x00\x00\x00\x90\x77\x53"
|
||||
@@ -215,30 +205,3 @@ def get_client_ip(request: HttpRequest) -> str | None:
|
||||
return ip
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def hmac_hexdigest(
|
||||
key: str | bytes,
|
||||
data: Mapping[str, Any] | Sequence[tuple[str, Any]],
|
||||
digest: str | Callable[[Buffer], HASH] = "sha512",
|
||||
) -> str:
|
||||
"""Return the hexdigest of the signature of the given data.
|
||||
|
||||
Args:
|
||||
key: the HMAC key used for the signature
|
||||
data: the data to sign
|
||||
digest: a PEP247 hashing algorithm (by default, sha512)
|
||||
|
||||
Examples:
|
||||
```python
|
||||
data = {
|
||||
"foo": 5,
|
||||
"bar": "somevalue",
|
||||
}
|
||||
hmac_key = secrets.token_hex(64)
|
||||
signature = hmac_hexdigest(hmac_key, data, "sha256")
|
||||
```
|
||||
"""
|
||||
if isinstance(key, str):
|
||||
key = key.encode()
|
||||
return hmac.digest(key, urlencode(data).encode(), digest).hex()
|
||||
|
||||
@@ -24,6 +24,7 @@ from counter.models import (
|
||||
Eticket,
|
||||
InvoiceCall,
|
||||
Permanency,
|
||||
Price,
|
||||
Product,
|
||||
ProductType,
|
||||
Refilling,
|
||||
@@ -32,19 +33,24 @@ from counter.models import (
|
||||
)
|
||||
|
||||
|
||||
class PriceInline(admin.TabularInline):
|
||||
model = Price
|
||||
autocomplete_fields = ("groups",)
|
||||
|
||||
|
||||
@admin.register(Product)
|
||||
class ProductAdmin(SearchModelAdmin):
|
||||
list_display = (
|
||||
"name",
|
||||
"code",
|
||||
"product_type",
|
||||
"selling_price",
|
||||
"archived",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
)
|
||||
list_select_related = ("product_type",)
|
||||
search_fields = ("name", "code")
|
||||
inlines = [PriceInline]
|
||||
|
||||
|
||||
@admin.register(ReturnableProduct)
|
||||
|
||||
@@ -101,13 +101,9 @@ class ProductController(ControllerBase):
|
||||
"""Get the detailed information about the products."""
|
||||
return filters.filter(
|
||||
Product.objects.select_related("club")
|
||||
.prefetch_related("buying_groups")
|
||||
.prefetch_related("prices", "prices__groups")
|
||||
.select_related("product_type")
|
||||
.order_by(
|
||||
F("product_type__order").asc(nulls_last=True),
|
||||
"product_type",
|
||||
"name",
|
||||
)
|
||||
.order_by(F("product_type__order").asc(nulls_last=True), "name")
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -2,10 +2,11 @@ from model_bakery.recipe import Recipe, foreign_key
|
||||
|
||||
from club.models import Club
|
||||
from core.models import User
|
||||
from counter.models import Counter, Product, Refilling, Selling
|
||||
from counter.models import Counter, Price, Product, Refilling, Selling
|
||||
|
||||
counter_recipe = Recipe(Counter)
|
||||
product_recipe = Recipe(Product, club=foreign_key(Recipe(Club)))
|
||||
price_recipe = Recipe(Price, product=foreign_key(product_recipe))
|
||||
sale_recipe = Recipe(
|
||||
Selling,
|
||||
product=foreign_key(product_recipe),
|
||||
|
||||
120
counter/forms.py
120
counter/forms.py
@@ -1,11 +1,11 @@
|
||||
import json
|
||||
import math
|
||||
import uuid
|
||||
from collections import defaultdict
|
||||
from datetime import date, datetime, timezone
|
||||
|
||||
from dateutil.relativedelta import relativedelta
|
||||
from django import forms
|
||||
from django.core.validators import MaxValueValidator
|
||||
from django.db.models import Exists, OuterRef, Q
|
||||
from django.forms import BaseModelFormSet
|
||||
from django.utils.timezone import now
|
||||
@@ -35,6 +35,7 @@ from counter.models import (
|
||||
Customer,
|
||||
Eticket,
|
||||
InvoiceCall,
|
||||
Price,
|
||||
Product,
|
||||
ProductFormula,
|
||||
Refilling,
|
||||
@@ -292,7 +293,21 @@ ScheduledProductActionFormSet = forms.modelformset_factory(
|
||||
can_delete=True,
|
||||
can_delete_extra=False,
|
||||
extra=0,
|
||||
)
|
||||
|
||||
|
||||
ProductPriceFormSet = forms.inlineformset_factory(
|
||||
parent_model=Product,
|
||||
model=Price,
|
||||
fields=["amount", "label", "groups", "is_always_shown"],
|
||||
widgets={
|
||||
"groups": AutoCompleteSelectMultipleGroup,
|
||||
"is_always_shown": forms.CheckboxInput(attrs={"class": "switch"}),
|
||||
},
|
||||
absolute_max=None,
|
||||
can_delete_extra=False,
|
||||
min_num=1,
|
||||
extra=0,
|
||||
)
|
||||
|
||||
|
||||
@@ -307,10 +322,7 @@ class ProductForm(forms.ModelForm):
|
||||
"description",
|
||||
"product_type",
|
||||
"code",
|
||||
"buying_groups",
|
||||
"purchase_price",
|
||||
"selling_price",
|
||||
"special_selling_price",
|
||||
"icon",
|
||||
"club",
|
||||
"limit_age",
|
||||
@@ -325,8 +337,8 @@ class ProductForm(forms.ModelForm):
|
||||
}
|
||||
widgets = {
|
||||
"product_type": AutoCompleteSelect,
|
||||
"buying_groups": AutoCompleteSelectMultipleGroup,
|
||||
"club": AutoCompleteSelectClub,
|
||||
"tray": forms.CheckboxInput(attrs={"class": "switch"}),
|
||||
}
|
||||
|
||||
counters = forms.ModelMultipleChoiceField(
|
||||
@@ -336,50 +348,40 @@ class ProductForm(forms.ModelForm):
|
||||
queryset=Counter.objects.all(),
|
||||
)
|
||||
|
||||
def __init__(self, *args, instance=None, **kwargs):
|
||||
super().__init__(*args, instance=instance, **kwargs)
|
||||
def __init__(self, *args, prefix: str | None = None, instance=None, **kwargs):
|
||||
super().__init__(*args, prefix=prefix, instance=instance, **kwargs)
|
||||
self.fields["name"].widget.attrs["autofocus"] = "autofocus"
|
||||
if self.instance.id:
|
||||
self.fields["counters"].initial = self.instance.counters.all()
|
||||
if hasattr(self.instance, "formula"):
|
||||
self.formula_init(self.instance.formula)
|
||||
self.price_formset = ProductPriceFormSet(
|
||||
*args, instance=self.instance, prefix="price", **kwargs
|
||||
)
|
||||
self.action_formset = ScheduledProductActionFormSet(
|
||||
*args, product=self.instance, **kwargs
|
||||
*args, product=self.instance, prefix="action", **kwargs
|
||||
)
|
||||
|
||||
def formula_init(self, formula: ProductFormula):
|
||||
"""Part of the form initialisation specific to formula products."""
|
||||
self.fields["selling_price"].help_text = _(
|
||||
"This product is a formula. "
|
||||
"Its price cannot be greater than the price "
|
||||
"of the products constituting it, which is %(price)s €"
|
||||
) % {"price": formula.max_selling_price}
|
||||
self.fields["special_selling_price"].help_text = _(
|
||||
"This product is a formula. "
|
||||
"Its special price cannot be greater than the price "
|
||||
"of the products constituting it, which is %(price)s €"
|
||||
) % {"price": formula.max_special_selling_price}
|
||||
for key, price in (
|
||||
("selling_price", formula.max_selling_price),
|
||||
("special_selling_price", formula.max_special_selling_price),
|
||||
):
|
||||
self.fields[key].widget.attrs["max"] = price
|
||||
self.fields[key].validators.append(MaxValueValidator(price))
|
||||
|
||||
def is_valid(self):
|
||||
return super().is_valid() and self.action_formset.is_valid()
|
||||
return (
|
||||
super().is_valid()
|
||||
and self.price_formset.is_valid()
|
||||
and self.action_formset.is_valid()
|
||||
)
|
||||
|
||||
def save(self, *args, **kwargs) -> Product:
|
||||
product = super().save(*args, **kwargs)
|
||||
product.counters.set(self.cleaned_data["counters"])
|
||||
# if it's a creation, the product given in the formset
|
||||
# wasn't a persisted instance.
|
||||
# So if we tried to persist the related objects in the current state,
|
||||
# they would be linked to no product, thus be completely useless
|
||||
# To make it work, we have to replace
|
||||
# the initial product with a persisted one
|
||||
for form in self.action_formset:
|
||||
# if it's a creation, the product given in the formset
|
||||
# wasn't a persisted instance.
|
||||
# So if we tried to persist the scheduled actions in the current state,
|
||||
# they would be linked to no product, thus be completely useless
|
||||
# To make it work, we have to replace
|
||||
# the initial product with a persisted one
|
||||
form.set_product(product)
|
||||
self.action_formset.save()
|
||||
self.price_formset.save()
|
||||
return product
|
||||
|
||||
|
||||
@@ -402,18 +404,6 @@ class ProductFormulaForm(forms.ModelForm):
|
||||
"the result and a part of the formula."
|
||||
),
|
||||
)
|
||||
prices = [p.selling_price for p in cleaned_data["products"]]
|
||||
special_prices = [p.special_selling_price for p in cleaned_data["products"]]
|
||||
selling_price = cleaned_data["result"].selling_price
|
||||
special_selling_price = cleaned_data["result"].special_selling_price
|
||||
if selling_price > sum(prices) or special_selling_price > sum(special_prices):
|
||||
self.add_error(
|
||||
"result",
|
||||
_(
|
||||
"The result cannot be more expensive "
|
||||
"than the total of the other products."
|
||||
),
|
||||
)
|
||||
return cleaned_data
|
||||
|
||||
|
||||
@@ -464,48 +454,47 @@ class CloseCustomerAccountForm(forms.Form):
|
||||
)
|
||||
|
||||
|
||||
class BasketProductForm(forms.Form):
|
||||
class BasketItemForm(forms.Form):
|
||||
quantity = forms.IntegerField(min_value=1, required=True)
|
||||
id = forms.IntegerField(min_value=0, required=True)
|
||||
price_id = forms.IntegerField(min_value=0, required=True)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
customer: Customer,
|
||||
counter: Counter,
|
||||
allowed_products: dict[int, Product],
|
||||
allowed_prices: dict[int, Price],
|
||||
*args,
|
||||
**kwargs,
|
||||
):
|
||||
self.customer = customer # Used by formset
|
||||
self.counter = counter # Used by formset
|
||||
self.allowed_products = allowed_products
|
||||
self.allowed_prices = allowed_prices
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
def clean_id(self):
|
||||
data = self.cleaned_data["id"]
|
||||
def clean_price_id(self):
|
||||
data = self.cleaned_data["price_id"]
|
||||
|
||||
# We store self.product so we can use it later on the formset validation
|
||||
# We store self.price so we can use it later on the formset validation
|
||||
# And also in the global clean
|
||||
self.product = self.allowed_products.get(data, None)
|
||||
if self.product is None:
|
||||
self.price = self.allowed_prices.get(data, None)
|
||||
if self.price is None:
|
||||
raise forms.ValidationError(
|
||||
_("The selected product isn't available for this user")
|
||||
)
|
||||
|
||||
return data
|
||||
|
||||
def clean(self):
|
||||
cleaned_data = super().clean()
|
||||
if len(self.errors) > 0:
|
||||
return
|
||||
return cleaned_data
|
||||
|
||||
# Compute prices
|
||||
cleaned_data["bonus_quantity"] = 0
|
||||
if self.product.tray:
|
||||
if self.price.product.tray:
|
||||
cleaned_data["bonus_quantity"] = math.floor(
|
||||
cleaned_data["quantity"] / Product.QUANTITY_FOR_TRAY_PRICE
|
||||
)
|
||||
cleaned_data["total_price"] = self.product.price * (
|
||||
cleaned_data["total_price"] = self.price.amount * (
|
||||
cleaned_data["quantity"] - cleaned_data["bonus_quantity"]
|
||||
)
|
||||
|
||||
@@ -529,8 +518,8 @@ class BaseBasketForm(forms.BaseFormSet):
|
||||
raise forms.ValidationError(_("Submitted basket is invalid"))
|
||||
|
||||
def _check_product_are_unique(self):
|
||||
product_ids = {form.cleaned_data["id"] for form in self.forms}
|
||||
if len(product_ids) != len(self.forms):
|
||||
price_ids = {form.cleaned_data["price_id"] for form in self.forms}
|
||||
if len(price_ids) != len(self.forms):
|
||||
raise forms.ValidationError(_("Duplicated product entries."))
|
||||
|
||||
def _check_enough_money(self, counter: Counter, customer: Customer):
|
||||
@@ -540,10 +529,9 @@ class BaseBasketForm(forms.BaseFormSet):
|
||||
|
||||
def _check_recorded_products(self, customer: Customer):
|
||||
"""Check for, among other things, ecocups and pitchers"""
|
||||
items = {
|
||||
form.cleaned_data["id"]: form.cleaned_data["quantity"]
|
||||
for form in self.forms
|
||||
}
|
||||
items = defaultdict(int)
|
||||
for form in self.forms:
|
||||
items[form.price.product_id] += form.cleaned_data["quantity"]
|
||||
ids = list(items.keys())
|
||||
returnables = list(
|
||||
ReturnableProduct.objects.filter(
|
||||
@@ -569,7 +557,7 @@ class BaseBasketForm(forms.BaseFormSet):
|
||||
|
||||
|
||||
BasketForm = forms.formset_factory(
|
||||
BasketProductForm, formset=BaseBasketForm, absolute_max=None, min_num=1
|
||||
BasketItemForm, formset=BaseBasketForm, absolute_max=None, min_num=1
|
||||
)
|
||||
|
||||
|
||||
|
||||
149
counter/migrations/0038_price.py
Normal file
149
counter/migrations/0038_price.py
Normal file
@@ -0,0 +1,149 @@
|
||||
# Generated by Django 5.2.11 on 2026-02-18 13:30
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
from django.db.migrations.state import StateApps
|
||||
|
||||
import counter.fields
|
||||
|
||||
|
||||
def migrate_prices(apps: StateApps, schema_editor):
|
||||
Product = apps.get_model("counter", "Product")
|
||||
Price = apps.get_model("counter", "Price")
|
||||
prices = [
|
||||
Price(
|
||||
amount=p.selling_price,
|
||||
product=p,
|
||||
created_at=p.created_at,
|
||||
updated_at=p.updated_at,
|
||||
)
|
||||
for p in Product.objects.all()
|
||||
]
|
||||
Price.objects.bulk_create(prices)
|
||||
groups = [
|
||||
Price.groups.through(price=price, group=group)
|
||||
for price in Price.objects.select_related("product").prefetch_related(
|
||||
"product__buying_groups"
|
||||
)
|
||||
for group in price.product.buying_groups.all()
|
||||
]
|
||||
Price.groups.through.objects.bulk_create(groups)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("core", "0048_alter_user_options"),
|
||||
("counter", "0037_productformula"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="Price",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.AutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
(
|
||||
"amount",
|
||||
counter.fields.CurrencyField(
|
||||
decimal_places=2, max_digits=12, verbose_name="amount"
|
||||
),
|
||||
),
|
||||
(
|
||||
"is_always_shown",
|
||||
models.BooleanField(
|
||||
default=False,
|
||||
help_text=(
|
||||
"If this option is enabled, "
|
||||
"people will see this price and be able to pay it, "
|
||||
"even if another cheaper price exists. "
|
||||
"Else it will visible only if it is the cheapest available price."
|
||||
),
|
||||
verbose_name="always show",
|
||||
),
|
||||
),
|
||||
(
|
||||
"label",
|
||||
models.CharField(
|
||||
default="",
|
||||
help_text=(
|
||||
"A short label for easier differentiation "
|
||||
"if a user can see multiple prices."
|
||||
),
|
||||
max_length=32,
|
||||
verbose_name="label",
|
||||
blank=True,
|
||||
),
|
||||
),
|
||||
(
|
||||
"created_at",
|
||||
models.DateTimeField(auto_now_add=True, verbose_name="created at"),
|
||||
),
|
||||
(
|
||||
"updated_at",
|
||||
models.DateTimeField(auto_now=True, verbose_name="updated at"),
|
||||
),
|
||||
(
|
||||
"groups",
|
||||
models.ManyToManyField(
|
||||
related_name="prices", to="core.group", verbose_name="groups"
|
||||
),
|
||||
),
|
||||
(
|
||||
"product",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="prices",
|
||||
to="counter.product",
|
||||
verbose_name="product",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={"verbose_name": "price"},
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="product",
|
||||
name="tray",
|
||||
field=models.BooleanField(
|
||||
default=False,
|
||||
help_text="Buy five, get the sixth free",
|
||||
verbose_name="tray price",
|
||||
),
|
||||
),
|
||||
migrations.RunPython(migrate_prices, reverse_code=migrations.RunPython.noop),
|
||||
migrations.RemoveField(model_name="product", name="selling_price"),
|
||||
migrations.RemoveField(model_name="product", name="special_selling_price"),
|
||||
migrations.AlterField(
|
||||
model_name="product",
|
||||
name="description",
|
||||
field=models.TextField(blank=True, default="", verbose_name="description"),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="product",
|
||||
name="product_type",
|
||||
field=models.ForeignKey(
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="products",
|
||||
to="counter.producttype",
|
||||
verbose_name="product type",
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="productformula",
|
||||
name="result",
|
||||
field=models.OneToOneField(
|
||||
help_text="The product got with the formula.",
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="formula",
|
||||
to="counter.product",
|
||||
verbose_name="result product",
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -22,7 +22,7 @@ import string
|
||||
from datetime import date, datetime, timedelta
|
||||
from datetime import timezone as tz
|
||||
from decimal import Decimal
|
||||
from typing import Literal, Self
|
||||
from typing import TYPE_CHECKING, Literal, Self
|
||||
|
||||
from dict2xml import dict2xml
|
||||
from django.conf import settings
|
||||
@@ -47,6 +47,9 @@ from core.utils import get_start_of_semester
|
||||
from counter.fields import CurrencyField
|
||||
from subscription.models import Subscription
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Sequence
|
||||
|
||||
|
||||
def get_eboutic() -> Counter:
|
||||
return Counter.objects.filter(type="EBOUTIC").order_by("id").first()
|
||||
@@ -157,14 +160,7 @@ class Customer(models.Model):
|
||||
|
||||
@property
|
||||
def can_buy(self) -> bool:
|
||||
"""Check if whether this customer has the right to purchase any item.
|
||||
|
||||
This must be not confused with the Product.can_be_sold_to(user)
|
||||
method as the present method returns an information
|
||||
about a customer whereas the other tells something
|
||||
about the relation between a User (not a Customer,
|
||||
don't mix them) and a Product.
|
||||
"""
|
||||
"""Check if whether this customer has the right to purchase any item."""
|
||||
subscription = self.user.subscriptions.order_by("subscription_end").last()
|
||||
if subscription is None:
|
||||
return False
|
||||
@@ -363,13 +359,13 @@ class Product(models.Model):
|
||||
QUANTITY_FOR_TRAY_PRICE = 6
|
||||
|
||||
name = models.CharField(_("name"), max_length=64)
|
||||
description = models.TextField(_("description"), default="")
|
||||
description = models.TextField(_("description"), blank=True, default="")
|
||||
product_type = models.ForeignKey(
|
||||
ProductType,
|
||||
related_name="products",
|
||||
verbose_name=_("product type"),
|
||||
null=True,
|
||||
blank=True,
|
||||
blank=False,
|
||||
on_delete=models.SET_NULL,
|
||||
)
|
||||
code = models.CharField(_("code"), max_length=16, blank=True)
|
||||
@@ -377,11 +373,6 @@ class Product(models.Model):
|
||||
_("purchase price"),
|
||||
help_text=_("Initial cost of purchasing the product"),
|
||||
)
|
||||
selling_price = CurrencyField(_("selling price"))
|
||||
special_selling_price = CurrencyField(
|
||||
_("special selling price"),
|
||||
help_text=_("Price for barmen during their permanence"),
|
||||
)
|
||||
icon = ResizedImageField(
|
||||
height=70,
|
||||
force_format="WEBP",
|
||||
@@ -394,7 +385,9 @@ class Product(models.Model):
|
||||
Club, related_name="products", verbose_name=_("club"), on_delete=models.CASCADE
|
||||
)
|
||||
limit_age = models.IntegerField(_("limit age"), default=0)
|
||||
tray = models.BooleanField(_("tray price"), default=False)
|
||||
tray = models.BooleanField(
|
||||
_("tray price"), help_text=_("Buy five, get the sixth free"), default=False
|
||||
)
|
||||
buying_groups = models.ManyToManyField(
|
||||
Group, related_name="products", verbose_name=_("buying groups"), blank=True
|
||||
)
|
||||
@@ -419,41 +412,77 @@ class Product(models.Model):
|
||||
pk=settings.SITH_GROUP_ACCOUNTING_ADMIN_ID
|
||||
) or user.is_in_group(pk=settings.SITH_GROUP_COUNTER_ADMIN_ID)
|
||||
|
||||
def can_be_sold_to(self, user: User) -> bool:
|
||||
"""Check if whether the user given in parameter has the right to buy
|
||||
this product or not.
|
||||
|
||||
This must be not confused with the Customer.can_buy()
|
||||
method as the present method returns an information
|
||||
about the relation between a User and a Product,
|
||||
whereas the other tells something about a Customer
|
||||
(and not a user, they are not the same model).
|
||||
class PriceQuerySet(models.QuerySet):
|
||||
def for_user(self, user: User) -> Self:
|
||||
age = user.age
|
||||
if user.is_banned_alcohol:
|
||||
age = min(age, 17)
|
||||
return self.filter(
|
||||
Q(is_always_shown=True, groups__in=user.all_groups)
|
||||
| Q(
|
||||
id=Subquery(
|
||||
Price.objects.filter(
|
||||
product_id=OuterRef("product_id"), groups__in=user.all_groups
|
||||
)
|
||||
.order_by("amount")
|
||||
.values("id")[:1]
|
||||
)
|
||||
),
|
||||
product__archived=False,
|
||||
product__limit_age__lte=age,
|
||||
)
|
||||
|
||||
Returns:
|
||||
True if the user can buy this product else False
|
||||
|
||||
Warning:
|
||||
This performs a db query, thus you can quickly have
|
||||
a N+1 queries problem if you call it in a loop.
|
||||
Hopefully, you can avoid that if you prefetch the buying_groups :
|
||||
class Price(models.Model):
|
||||
amount = CurrencyField(_("amount"))
|
||||
product = models.ForeignKey(
|
||||
Product,
|
||||
verbose_name=_("product"),
|
||||
related_name="prices",
|
||||
on_delete=models.CASCADE,
|
||||
)
|
||||
groups = models.ManyToManyField(
|
||||
Group, verbose_name=_("groups"), related_name="prices"
|
||||
)
|
||||
is_always_shown = models.BooleanField(
|
||||
_("always show"),
|
||||
help_text=_(
|
||||
"If this option is enabled, "
|
||||
"people will see this price and be able to pay it, "
|
||||
"even if another cheaper price exists. "
|
||||
"Else it will visible only if it is the cheapest available price."
|
||||
),
|
||||
default=False,
|
||||
)
|
||||
label = models.CharField(
|
||||
_("label"),
|
||||
help_text=_(
|
||||
"A short label for easier differentiation "
|
||||
"if a user can see multiple prices."
|
||||
),
|
||||
max_length=32,
|
||||
default="",
|
||||
blank=True,
|
||||
)
|
||||
created_at = models.DateTimeField(_("created at"), auto_now_add=True)
|
||||
updated_at = models.DateTimeField(_("updated at"), auto_now=True)
|
||||
|
||||
```python
|
||||
user = User.objects.get(username="foobar")
|
||||
products = [
|
||||
p
|
||||
for p in Product.objects.prefetch_related("buying_groups")
|
||||
if p.can_be_sold_to(user)
|
||||
]
|
||||
```
|
||||
"""
|
||||
buying_groups = list(self.buying_groups.all())
|
||||
if not buying_groups:
|
||||
return True
|
||||
return any(user.is_in_group(pk=group.id) for group in buying_groups)
|
||||
objects = PriceQuerySet.as_manager()
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("price")
|
||||
|
||||
def __str__(self):
|
||||
if not self.label:
|
||||
return f"{self.product.name} ({self.amount}€)"
|
||||
return f"{self.product.name} {self.label} ({self.amount}€)"
|
||||
|
||||
@property
|
||||
def profit(self):
|
||||
return self.selling_price - self.purchase_price
|
||||
def full_label(self):
|
||||
if not self.label:
|
||||
return self.product.name
|
||||
return f"{self.product.name} \u2013 {self.label}"
|
||||
|
||||
|
||||
class ProductFormula(models.Model):
|
||||
@@ -474,18 +503,6 @@ class ProductFormula(models.Model):
|
||||
def __str__(self):
|
||||
return self.result.name
|
||||
|
||||
@cached_property
|
||||
def max_selling_price(self) -> float:
|
||||
# iterating over all products is less efficient than doing
|
||||
# a simple aggregation, but this method is likely to be used in
|
||||
# coordination with `max_special_selling_price`,
|
||||
# and Django caches the result of the `all` queryset.
|
||||
return sum(p.selling_price for p in self.products.all())
|
||||
|
||||
@cached_property
|
||||
def max_special_selling_price(self) -> float:
|
||||
return sum(p.special_selling_price for p in self.products.all())
|
||||
|
||||
|
||||
class CounterQuerySet(models.QuerySet):
|
||||
def annotate_has_barman(self, user: User) -> Self:
|
||||
@@ -712,35 +729,20 @@ class Counter(models.Model):
|
||||
# but they share the same primary key
|
||||
return self.type == "BAR" and any(b.pk == customer.pk for b in self.barmen_list)
|
||||
|
||||
def get_products_for(self, customer: Customer) -> list[Product]:
|
||||
"""
|
||||
Get all allowed products for the provided customer on this counter
|
||||
Prices will be annotated
|
||||
"""
|
||||
|
||||
products = (
|
||||
self.products.filter(archived=False)
|
||||
.select_related("product_type")
|
||||
.prefetch_related("buying_groups")
|
||||
def get_prices_for(
|
||||
self, customer: Customer, *, order_by: Sequence[str] | None = None
|
||||
) -> list[Price]:
|
||||
qs = (
|
||||
Price.objects.filter(
|
||||
product__counters=self, product__product_type__isnull=False
|
||||
)
|
||||
.for_user(customer.user)
|
||||
.select_related("product", "product__product_type")
|
||||
.prefetch_related("groups")
|
||||
)
|
||||
|
||||
# Only include age appropriate products
|
||||
age = customer.user.age
|
||||
if customer.user.is_banned_alcohol:
|
||||
age = min(age, 17)
|
||||
products = products.filter(limit_age__lte=age)
|
||||
|
||||
# Compute special price for customer if he is a barmen on that bar
|
||||
if self.customer_is_barman(customer):
|
||||
products = products.annotate(price=F("special_selling_price"))
|
||||
else:
|
||||
products = products.annotate(price=F("selling_price"))
|
||||
|
||||
return [
|
||||
product
|
||||
for product in products.all()
|
||||
if product.can_be_sold_to(customer.user)
|
||||
]
|
||||
if order_by:
|
||||
qs = qs.order_by(*order_by)
|
||||
return list(qs)
|
||||
|
||||
|
||||
class RefillingQuerySet(models.QuerySet):
|
||||
@@ -1001,7 +1003,9 @@ class Selling(models.Model):
|
||||
event = self.product.eticket.event_title or _("Unknown event")
|
||||
subject = _("Eticket bought for the event %(event)s") % {"event": event}
|
||||
message_html = _(
|
||||
"You bought an eticket for the event %(event)s.\nYou can download it directly from this link %(eticket)s.\nYou can also retrieve all your e-tickets on your account page %(url)s."
|
||||
"You bought an eticket for the event %(event)s.\n"
|
||||
"You can download it directly from this link %(eticket)s.\n"
|
||||
"You can also retrieve all your e-tickets on your account page %(url)s."
|
||||
) % {
|
||||
"event": event,
|
||||
"url": (
|
||||
|
||||
@@ -6,8 +6,8 @@ from ninja import FilterLookup, FilterSchema, ModelSchema, Schema
|
||||
from pydantic import model_validator
|
||||
|
||||
from club.schemas import SimpleClubSchema
|
||||
from core.schemas import GroupSchema, NonEmptyStr, SimpleUserSchema
|
||||
from counter.models import Counter, Product, ProductType
|
||||
from core.schemas import NonEmptyStr, SimpleUserSchema
|
||||
from counter.models import Counter, Price, Product, ProductType
|
||||
|
||||
|
||||
class CounterSchema(ModelSchema):
|
||||
@@ -66,6 +66,12 @@ class SimpleProductSchema(ModelSchema):
|
||||
fields = ["id", "name", "code"]
|
||||
|
||||
|
||||
class ProductPriceSchema(ModelSchema):
|
||||
class Meta:
|
||||
model = Price
|
||||
fields = ["amount", "groups"]
|
||||
|
||||
|
||||
class ProductSchema(ModelSchema):
|
||||
class Meta:
|
||||
model = Product
|
||||
@@ -75,13 +81,12 @@ class ProductSchema(ModelSchema):
|
||||
"code",
|
||||
"description",
|
||||
"purchase_price",
|
||||
"selling_price",
|
||||
"icon",
|
||||
"limit_age",
|
||||
"archived",
|
||||
]
|
||||
|
||||
buying_groups: list[GroupSchema]
|
||||
prices: list[ProductPriceSchema]
|
||||
club: SimpleClubSchema
|
||||
product_type: SimpleProductTypeSchema | None
|
||||
url: str
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import type { Product } from "#counter:counter/types.ts";
|
||||
import type { CounterItem } from "#counter:counter/types";
|
||||
|
||||
export class BasketItem {
|
||||
quantity: number;
|
||||
product: Product;
|
||||
quantityForTrayPrice: number;
|
||||
product: CounterItem;
|
||||
errors: string[];
|
||||
|
||||
constructor(product: Product, quantity: number) {
|
||||
constructor(product: CounterItem, quantity: number) {
|
||||
this.quantity = quantity;
|
||||
this.product = product;
|
||||
this.errors = [];
|
||||
@@ -20,6 +19,6 @@ export class BasketItem {
|
||||
}
|
||||
|
||||
sum(): number {
|
||||
return (this.quantity - this.getBonusQuantity()) * this.product.price;
|
||||
return (this.quantity - this.getBonusQuantity()) * this.product.price.amount;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { AlertMessage } from "#core:utils/alert-message.ts";
|
||||
import { BasketItem } from "#counter:counter/basket.ts";
|
||||
import { AlertMessage } from "#core:utils/alert-message";
|
||||
import { BasketItem } from "#counter:counter/basket";
|
||||
import type {
|
||||
CounterConfig,
|
||||
CounterItem,
|
||||
ErrorMessage,
|
||||
ProductFormula,
|
||||
} from "#counter:counter/types.ts";
|
||||
import type { CounterProductSelect } from "./components/counter-product-select-index.ts";
|
||||
} from "#counter:counter/types";
|
||||
import type { CounterProductSelect } from "./components/counter-product-select-index";
|
||||
|
||||
document.addEventListener("alpine:init", () => {
|
||||
Alpine.data("counter", (config: CounterConfig) => ({
|
||||
@@ -63,8 +64,10 @@ document.addEventListener("alpine:init", () => {
|
||||
},
|
||||
|
||||
checkFormulas() {
|
||||
// Try to find a formula.
|
||||
// A formula is found if all its elements are already in the basket
|
||||
const products = new Set(
|
||||
Object.keys(this.basket).map((i: string) => Number.parseInt(i)),
|
||||
Object.values(this.basket).map((item: BasketItem) => item.product.productId),
|
||||
);
|
||||
const formula: ProductFormula = config.formulas.find((f: ProductFormula) => {
|
||||
return f.products.every((p: number) => products.has(p));
|
||||
@@ -72,22 +75,29 @@ document.addEventListener("alpine:init", () => {
|
||||
if (formula === undefined) {
|
||||
return;
|
||||
}
|
||||
// Now that the formula is found, remove the items composing it from the basket
|
||||
for (const product of formula.products) {
|
||||
const key = product.toString();
|
||||
const key = Object.entries(this.basket).find(
|
||||
([_, i]: [string, BasketItem]) => i.product.productId === product,
|
||||
)[0];
|
||||
this.basket[key].quantity -= 1;
|
||||
if (this.basket[key].quantity <= 0) {
|
||||
this.removeFromBasket(key);
|
||||
}
|
||||
}
|
||||
// Then add the result product of the formula to the basket
|
||||
const result = Object.values(config.products)
|
||||
.filter((item: CounterItem) => item.productId === formula.result)
|
||||
.reduce((acc, curr) => (acc.price.amount < curr.price.amount ? acc : curr));
|
||||
this.addToBasket(result.price.id, 1);
|
||||
this.alertMessage.display(
|
||||
interpolate(
|
||||
gettext("Formula %(formula)s applied"),
|
||||
{ formula: config.products[formula.result.toString()].name },
|
||||
{ formula: result.name },
|
||||
true,
|
||||
),
|
||||
{ success: true },
|
||||
);
|
||||
this.addToBasket(formula.result.toString(), 1);
|
||||
},
|
||||
|
||||
getBasketSize() {
|
||||
|
||||
@@ -1,13 +1,9 @@
|
||||
import { showSaveFilePicker } from "native-file-system-adapter";
|
||||
import type TomSelect from "tom-select";
|
||||
import { paginated } from "#core:utils/api.ts";
|
||||
import { csv } from "#core:utils/csv.ts";
|
||||
import {
|
||||
getCurrentUrlParams,
|
||||
History,
|
||||
updateQueryString,
|
||||
} from "#core:utils/history.ts";
|
||||
import type { NestedKeyOf } from "#core:utils/types.ts";
|
||||
import { paginated } from "#core:utils/api";
|
||||
import { csv } from "#core:utils/csv";
|
||||
import { getCurrentUrlParams, History, updateQueryString } from "#core:utils/history";
|
||||
import type { NestedKeyOf } from "#core:utils/types";
|
||||
import {
|
||||
type ProductSchema,
|
||||
type ProductSearchProductsDetailedData,
|
||||
@@ -20,6 +16,9 @@ type GroupedProducts = Record<ProductType, ProductSchema[]>;
|
||||
const defaultPageSize = 100;
|
||||
const defaultPage = 1;
|
||||
|
||||
// biome-ignore lint/style/useNamingConvention: api is snake case
|
||||
type ProductWithPriceSchema = ProductSchema & { selling_price: string };
|
||||
|
||||
/**
|
||||
* Keys of the properties to include in the CSV.
|
||||
*/
|
||||
@@ -34,7 +33,7 @@ const csvColumns = [
|
||||
"purchase_price",
|
||||
"selling_price",
|
||||
"archived",
|
||||
] as NestedKeyOf<ProductSchema>[];
|
||||
] as NestedKeyOf<ProductWithPriceSchema>[];
|
||||
|
||||
/**
|
||||
* Title of the csv columns.
|
||||
@@ -175,7 +174,16 @@ document.addEventListener("alpine:init", () => {
|
||||
this.nbPages > 1
|
||||
? await paginated(productSearchProductsDetailed, this.getQueryParams())
|
||||
: Object.values<ProductSchema[]>(this.products).flat();
|
||||
const content = csv.stringify(products, {
|
||||
// CSV cannot represent nested data
|
||||
// so we create a row for each price of each product.
|
||||
const productsWithPrice: ProductWithPriceSchema[] = products.flatMap(
|
||||
(product: ProductSchema) =>
|
||||
product.prices.map((price) =>
|
||||
// biome-ignore lint/style/useNamingConvention: API is snake_case
|
||||
Object.assign(product, { selling_price: price.amount }),
|
||||
),
|
||||
);
|
||||
const content = csv.stringify(productsWithPrice, {
|
||||
columns: csvColumns,
|
||||
titleRow: csvColumnTitles,
|
||||
});
|
||||
|
||||
15
counter/static/bundled/counter/types.d.ts
vendored
15
counter/static/bundled/counter/types.d.ts
vendored
@@ -2,7 +2,7 @@ export type ErrorMessage = string;
|
||||
|
||||
export interface InitialFormData {
|
||||
/* Used to refill the form when the backend raises an error */
|
||||
id?: keyof Record<string, Product>;
|
||||
id?: keyof Record<string, CounterItem>;
|
||||
quantity?: number;
|
||||
errors?: string[];
|
||||
}
|
||||
@@ -15,17 +15,22 @@ export interface ProductFormula {
|
||||
export interface CounterConfig {
|
||||
customerBalance: number;
|
||||
customerId: number;
|
||||
products: Record<string, Product>;
|
||||
products: Record<string, CounterItem>;
|
||||
formulas: ProductFormula[];
|
||||
formInitial: InitialFormData[];
|
||||
cancelUrl: string;
|
||||
}
|
||||
|
||||
export interface Product {
|
||||
id: string;
|
||||
interface Price {
|
||||
id: number;
|
||||
amount: number;
|
||||
}
|
||||
|
||||
export interface CounterItem {
|
||||
productId: number;
|
||||
price: Price;
|
||||
code: string;
|
||||
name: string;
|
||||
price: number;
|
||||
hasTrayPrice: boolean;
|
||||
quantityForTrayPrice: number;
|
||||
}
|
||||
|
||||
@@ -6,10 +6,10 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block additional_css %}
|
||||
<link rel="stylesheet" type="text/css" href="{{ static('counter/css/counter-click.scss') }}" defer></link>
|
||||
<link rel="stylesheet" type="text/css" href="{{ static('bundled/core/components/ajax-select-index.css') }}" defer></link>
|
||||
<link rel="stylesheet" type="text/css" href="{{ static('core/components/ajax-select.scss') }}" defer></link>
|
||||
<link rel="stylesheet" type="text/css" href="{{ static('core/components/tabs.scss') }}" defer></link>
|
||||
<link rel="stylesheet" href="{{ static('counter/css/counter-click.scss') }}">
|
||||
<link rel="stylesheet" href="{{ static('bundled/core/components/ajax-select-index.css') }}">
|
||||
<link rel="stylesheet" href="{{ static('core/components/ajax-select.scss') }}">
|
||||
<link rel="stylesheet" href="{{ static('core/components/tabs.scss') }}">
|
||||
<link rel="stylesheet" href="{{ static("core/components/card.scss") }}">
|
||||
{% endblock %}
|
||||
|
||||
@@ -65,10 +65,10 @@
|
||||
<option value="FIN">{% trans %}Confirm (FIN){% endtrans %}</option>
|
||||
<option value="ANN">{% trans %}Cancel (ANN){% endtrans %}</option>
|
||||
</optgroup>
|
||||
{%- for category in categories.keys() -%}
|
||||
{%- for category, prices in categories.items() -%}
|
||||
<optgroup label="{{ category }}">
|
||||
{%- for product in categories[category] -%}
|
||||
<option value="{{ product.id }}">{{ product }}</option>
|
||||
{%- for price in prices -%}
|
||||
<option value="{{ price.id }}">{{ price.full_label }}</option>
|
||||
{%- endfor -%}
|
||||
</optgroup>
|
||||
{%- endfor -%}
|
||||
@@ -103,24 +103,25 @@
|
||||
</div>
|
||||
<ul>
|
||||
<li x-show="getBasketSize() === 0">{% trans %}This basket is empty{% endtrans %}</li>
|
||||
<template x-for="(item, index) in Object.values(basket)" :key="item.product.id">
|
||||
<template x-for="(item, index) in Object.values(basket)" :key="item.product.price.id">
|
||||
<li>
|
||||
<template x-for="error in item.errors">
|
||||
<div class="alert alert-red" x-text="error">
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<button @click.prevent="addToBasket(item.product.id, -1)">-</button>
|
||||
<button @click.prevent="addToBasket(item.product.price.id, -1)">-</button>
|
||||
<span class="quantity" x-text="item.quantity"></span>
|
||||
<button @click.prevent="addToBasket(item.product.id, 1)">+</button>
|
||||
<button @click.prevent="addToBasket(item.product.price.id, 1)">+</button>
|
||||
|
||||
<span x-text="item.product.name"></span> :
|
||||
<span x-text="item.sum().toLocaleString(undefined, { minimumFractionDigits: 2 })">€</span>
|
||||
<span x-show="item.getBonusQuantity() > 0" x-text="`${item.getBonusQuantity()} x P`"></span>
|
||||
<span x-show="item.getBonusQuantity() > 0"
|
||||
x-text="`${item.getBonusQuantity()} x P`"></span>
|
||||
|
||||
<button
|
||||
class="remove-item"
|
||||
@click.prevent="removeFromBasket(item.product.id)"
|
||||
@click.prevent="removeFromBasket(item.product.price.id)"
|
||||
><i class="fa fa-trash-can delete-action"></i></button>
|
||||
|
||||
<input
|
||||
@@ -133,9 +134,9 @@
|
||||
>
|
||||
<input
|
||||
type="hidden"
|
||||
:value="item.product.id"
|
||||
:id="`id_form-${index}-id`"
|
||||
:name="`form-${index}-id`"
|
||||
:value="item.product.price.id"
|
||||
:id="`id_form-${index}-price_id`"
|
||||
:name="`form-${index}-price_id`"
|
||||
required
|
||||
readonly
|
||||
>
|
||||
@@ -201,30 +202,30 @@
|
||||
</div>
|
||||
|
||||
<div id="products">
|
||||
{% if not products %}
|
||||
{% if not prices %}
|
||||
<div class="alert alert-red">
|
||||
{% trans %}No products available on this counter for this user{% endtrans %}
|
||||
</div>
|
||||
{% else %}
|
||||
<ui-tab-group>
|
||||
{% for category in categories.keys() -%}
|
||||
{% for category, prices in categories.items() -%}
|
||||
<ui-tab title="{{ category }}" {% if loop.index == 1 -%}active{%- endif -%}>
|
||||
<h5 class="margin-bottom">{{ category }}</h5>
|
||||
<div class="row gap-2x">
|
||||
{% for product in categories[category] -%}
|
||||
<button class="card shadow" @click="addToBasket('{{ product.id }}', 1)">
|
||||
{% for price in prices -%}
|
||||
<button class="card shadow" @click="addToBasket('{{ price.id }}', 1)">
|
||||
<img
|
||||
class="card-image"
|
||||
alt="image de {{ product.name }}"
|
||||
{% if product.icon %}
|
||||
src="{{ product.icon.url }}"
|
||||
alt="image de {{ price.full_label }}"
|
||||
{% if price.product.icon %}
|
||||
src="{{ price.product.icon.url }}"
|
||||
{% else %}
|
||||
src="{{ static('core/img/na.gif') }}"
|
||||
{% endif %}
|
||||
/>
|
||||
<span class="card-content">
|
||||
<strong class="card-title">{{ product.name }}</strong>
|
||||
<p>{{ product.price }} €<br>{{ product.code }}</p>
|
||||
<strong class="card-title">{{ price.full_label }}</strong>
|
||||
<p>{{ price.amount }} €<br>{{ price.product.code }}</p>
|
||||
</span>
|
||||
</button>
|
||||
{%- endfor %}
|
||||
@@ -241,13 +242,14 @@
|
||||
{{ super() }}
|
||||
<script>
|
||||
const products = {
|
||||
{%- for product in products -%}
|
||||
{{ product.id }}: {
|
||||
id: "{{ product.id }}",
|
||||
name: "{{ product.name }}",
|
||||
price: {{ product.price }},
|
||||
hasTrayPrice: {{ product.tray | tojson }},
|
||||
quantityForTrayPrice: {{ product.QUANTITY_FOR_TRAY_PRICE }},
|
||||
{%- for price in prices -%}
|
||||
{{ price.id }}: {
|
||||
productId: {{ price.product_id }},
|
||||
price: { id: "{{ price.id }}", amount: {{ price.amount }} },
|
||||
code: "{{ price.product.code }}",
|
||||
name: "{{ price.full_label }}",
|
||||
hasTrayPrice: {{ price.product.tray | tojson }},
|
||||
quantityForTrayPrice: {{ price.product.QUANTITY_FOR_TRAY_PRICE }},
|
||||
},
|
||||
{%- endfor -%}
|
||||
};
|
||||
|
||||
@@ -49,14 +49,10 @@
|
||||
<strong class="card-title">{{ formula.result.name }}</strong>
|
||||
<p>
|
||||
{% for p in formula.products.all() %}
|
||||
<i>{{ p.code }} ({{ p.selling_price }} €)</i>
|
||||
<i>{{ p.name }} ({{ p.code }})</i>
|
||||
{% if not loop.last %}+{% endif %}
|
||||
{% endfor %}
|
||||
</p>
|
||||
<p>
|
||||
{{ formula.result.selling_price }} €
|
||||
({% trans %}instead of{% endtrans %} {{ formula.max_selling_price}} €)
|
||||
</p>
|
||||
</div>
|
||||
{% if user.has_perm("counter.delete_productformula") %}
|
||||
<button
|
||||
|
||||
@@ -39,6 +39,49 @@
|
||||
{% endmacro %}
|
||||
|
||||
|
||||
{% macro price_form(form) %}
|
||||
<fieldset>
|
||||
{{ form.non_field_errors() }}
|
||||
<div class="form-group row gap-2x">
|
||||
<div>{{ form.amount.as_field_group() }}</div>
|
||||
<div>
|
||||
{{ form.label.errors }}
|
||||
<label for="{{ form.label.id_for_label }}">{{ form.label.label }}</label>
|
||||
{{ form.label }}
|
||||
<span class="helptext">{{ form.label.help_text }}</span>
|
||||
</div>
|
||||
<div class="grow">{{ form.groups.as_field_group() }}</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<div>
|
||||
{{ form.is_always_shown.errors }}
|
||||
<div class="row gap">
|
||||
{{ form.is_always_shown }}
|
||||
<label for="{{ form.is_always_shown.id_for_label }}">{{ form.is_always_shown.label }}</label>
|
||||
</div>
|
||||
<span class="helptext">{{ form.is_always_shown.help_text }}</span>
|
||||
</div>
|
||||
</div>
|
||||
{%- if form.DELETE -%}
|
||||
<div class="form-group row gap">
|
||||
{{ form.DELETE.as_field_group() }}
|
||||
</div>
|
||||
{%- else -%}
|
||||
<br>
|
||||
<button
|
||||
class="btn btn-grey"
|
||||
@click.prevent="removeForm($event.target.closest('fieldset').parentElement)"
|
||||
>
|
||||
<i class="fa fa-minus"></i> {% trans %}Remove price{% endtrans %}
|
||||
</button>
|
||||
{%- endif -%}
|
||||
{%- for field in form.hidden_fields() -%}
|
||||
{{ field }}
|
||||
{%- endfor -%}
|
||||
</fieldset>
|
||||
<hr class="margin-bottom">
|
||||
{% endmacro %}
|
||||
|
||||
{% block content %}
|
||||
{% if object %}
|
||||
<h2>{% trans name=object %}Edit product {{ name }}{% endtrans %}</h2>
|
||||
@@ -49,7 +92,54 @@
|
||||
{% endif %}
|
||||
<form method="post" enctype="multipart/form-data">
|
||||
{% csrf_token %}
|
||||
{{ form.as_p() }}
|
||||
{{ form.non_field_errors() }}
|
||||
<fieldset class="row gap">
|
||||
<div>{{ form.name.as_field_group() }}</div>
|
||||
<div>{{ form.code.as_field_group() }}</div>
|
||||
</fieldset>
|
||||
<fieldset>
|
||||
<div class="form-group">{{ form.description.as_field_group() }}</div>
|
||||
</fieldset>
|
||||
<fieldset class="row gap">
|
||||
<div>{{ form.club.as_field_group() }}</div>
|
||||
<div>{{ form.product_type.as_field_group() }}</div>
|
||||
</fieldset>
|
||||
<fieldset><div>{{ form.icon.as_field_group() }}</div></fieldset>
|
||||
<fieldset><div>{{ form.purchase_price.as_field_group() }}</div></fieldset>
|
||||
<fieldset>
|
||||
<div>{{ form.limit_age.as_field_group() }}</div>
|
||||
</fieldset>
|
||||
<fieldset>
|
||||
<div class="row gap">
|
||||
{{ form.tray }}
|
||||
<div>
|
||||
{{ form.tray.label_tag() }}
|
||||
<span class="helptext">{{ form.tray.help_text }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
<fieldset><div>{{ form.counters.as_field_group() }}</div></fieldset>
|
||||
|
||||
<h3 class="margin-bottom">{% trans %}Prices{% endtrans %}</h3>
|
||||
|
||||
<div x-data="dynamicFormSet({ prefix: '{{ form.price_formset.prefix }}' })">
|
||||
{{ form.price_formset.management_form }}
|
||||
<div x-ref="formContainer">
|
||||
{%- for form in form.price_formset.forms -%}
|
||||
<div>
|
||||
{{ price_form(form) }}
|
||||
</div>
|
||||
{%- endfor -%}
|
||||
</div>
|
||||
<template x-ref="formTemplate">
|
||||
<div>
|
||||
{{ price_form(form.price_formset.empty_form) }}
|
||||
</div>
|
||||
</template>
|
||||
<button class="btn btn-grey" @click.prevent="addForm()">
|
||||
<i class="fa fa-plus"></i> {% trans %}Add a price{% endtrans %}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<br />
|
||||
|
||||
@@ -64,7 +154,7 @@
|
||||
</em>
|
||||
</p>
|
||||
|
||||
<div x-data="dynamicFormSet" class="margin-bottom">
|
||||
<div x-data="dynamicFormSet({ prefix: '{{ form.action_formset.prefix }}' })" class="margin-bottom">
|
||||
{{ form.action_formset.management_form }}
|
||||
<div x-ref="formContainer">
|
||||
{%- for f in form.action_formset.forms -%}
|
||||
@@ -78,6 +168,7 @@
|
||||
<i class="fa fa-plus"></i>{% trans %}Add action{% endtrans %}
|
||||
</button>
|
||||
</div>
|
||||
<div class="row gap margin-bottom">{{ form.archived.as_field_group() }}</div>
|
||||
<p><input class="btn btn-blue" type="submit" value="{% trans %}Save{% endtrans %}" /></p>
|
||||
</form>
|
||||
{% endblock %}
|
||||
{% endblock %}
|
||||
|
||||
@@ -108,7 +108,7 @@
|
||||
</template>
|
||||
<span class="card-content">
|
||||
<strong class="card-title" x-text="`${p.name} (${p.code})`"></strong>
|
||||
<p x-text="`${p.selling_price} €`"></p>
|
||||
<p x-text="`${p.prices.map((p) => p.amount).join(' – ')} €`"></p>
|
||||
</span>
|
||||
</a>
|
||||
</template>
|
||||
|
||||
@@ -16,7 +16,7 @@ from counter.forms import (
|
||||
ScheduledProductActionForm,
|
||||
ScheduledProductActionFormSet,
|
||||
)
|
||||
from counter.models import Product, ScheduledProductAction
|
||||
from counter.models import Product, ProductType, ScheduledProductAction
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@@ -47,20 +47,22 @@ def test_create_actions_alongside_product():
|
||||
form = ProductForm(
|
||||
data={
|
||||
"name": "foo",
|
||||
"description": "bar",
|
||||
"product_type": product.product_type_id,
|
||||
"product_type": ProductType.objects.first(),
|
||||
"club": product.club_id,
|
||||
"code": "FOO",
|
||||
"purchase_price": 1.0,
|
||||
"selling_price": 1.0,
|
||||
"special_selling_price": 1.0,
|
||||
"limit_age": 0,
|
||||
"form-TOTAL_FORMS": "2",
|
||||
"form-INITIAL_FORMS": "0",
|
||||
"form-0-task": "counter.tasks.archive_product",
|
||||
"form-0-trigger_at": trigger_at,
|
||||
"price-TOTAL_FORMS": "0",
|
||||
"price-INITIAL_FORMS": "0",
|
||||
"action-TOTAL_FORMS": "1",
|
||||
"action-INITIAL_FORMS": "0",
|
||||
"action-0-task": "counter.tasks.archive_product",
|
||||
"action-0-trigger_at": trigger_at,
|
||||
},
|
||||
)
|
||||
form.is_valid()
|
||||
assert form.is_valid()
|
||||
product = form.save()
|
||||
action = ScheduledProductAction.objects.last()
|
||||
|
||||
@@ -20,7 +20,6 @@ import pytest
|
||||
from dateutil.relativedelta import relativedelta
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.models import Permission, make_password
|
||||
from django.core.cache import cache
|
||||
from django.http import HttpResponse
|
||||
from django.shortcuts import resolve_url
|
||||
from django.test import Client, TestCase
|
||||
@@ -34,13 +33,13 @@ from pytest_django.asserts import assertRedirects
|
||||
|
||||
from club.models import Membership
|
||||
from core.baker_recipes import board_user, subscriber_user, very_old_subscriber_user
|
||||
from core.models import BanGroup, User
|
||||
from counter.baker_recipes import product_recipe, sale_recipe
|
||||
from core.models import BanGroup, Group, User
|
||||
from counter.baker_recipes import price_recipe, product_recipe, sale_recipe
|
||||
from counter.models import (
|
||||
Counter,
|
||||
Customer,
|
||||
Permanency,
|
||||
Product,
|
||||
ProductType,
|
||||
Refilling,
|
||||
ReturnableProduct,
|
||||
Selling,
|
||||
@@ -204,7 +203,7 @@ class TestRefilling(TestFullClickBase):
|
||||
|
||||
@dataclass
|
||||
class BasketItem:
|
||||
id: int | None = None
|
||||
price_id: int | None = None
|
||||
quantity: int | None = None
|
||||
|
||||
def to_form(self, index: int) -> dict[str, str]:
|
||||
@@ -236,38 +235,59 @@ class TestCounterClick(TestFullClickBase):
|
||||
cls.banned_counter_customer.ban_groups.add(
|
||||
BanGroup.objects.get(pk=settings.SITH_GROUP_BANNED_COUNTER_ID)
|
||||
)
|
||||
subscriber_group = Group.objects.get(id=settings.SITH_GROUP_SUBSCRIBERS_ID)
|
||||
old_subscriber_group = Group.objects.get(
|
||||
id=settings.SITH_GROUP_OLD_SUBSCRIBERS_ID
|
||||
)
|
||||
_product_recipe = product_recipe.extend(product_type=baker.make(ProductType))
|
||||
|
||||
cls.gift = product_recipe.make(
|
||||
selling_price="-1.5",
|
||||
special_selling_price="-1.5",
|
||||
cls.gift = price_recipe.make(
|
||||
amount=-1.5, groups=[subscriber_group], product=_product_recipe.make()
|
||||
)
|
||||
cls.beer = product_recipe.make(
|
||||
limit_age=18, selling_price=1.5, special_selling_price=1
|
||||
cls.beer = price_recipe.make(
|
||||
groups=[subscriber_group],
|
||||
amount=1.5,
|
||||
product=_product_recipe.make(limit_age=18),
|
||||
)
|
||||
cls.beer_tap = product_recipe.make(
|
||||
limit_age=18, tray=True, selling_price=1.5, special_selling_price=1
|
||||
cls.beer_tap = price_recipe.make(
|
||||
groups=[subscriber_group],
|
||||
amount=1.5,
|
||||
product=_product_recipe.make(limit_age=18, tray=True),
|
||||
)
|
||||
cls.snack = product_recipe.make(
|
||||
limit_age=0, selling_price=1.5, special_selling_price=1
|
||||
cls.snack = price_recipe.make(
|
||||
groups=[subscriber_group, old_subscriber_group],
|
||||
amount=1.5,
|
||||
product=_product_recipe.make(limit_age=0),
|
||||
)
|
||||
cls.stamps = product_recipe.make(
|
||||
limit_age=0, selling_price=1.5, special_selling_price=1
|
||||
cls.stamps = price_recipe.make(
|
||||
groups=[subscriber_group],
|
||||
amount=1.5,
|
||||
product=_product_recipe.make(limit_age=0),
|
||||
)
|
||||
ReturnableProduct.objects.all().delete()
|
||||
cls.cons = baker.make(Product, selling_price=1)
|
||||
cls.dcons = baker.make(Product, selling_price=-1)
|
||||
cls.cons = price_recipe.make(
|
||||
amount=1, groups=[subscriber_group], product=_product_recipe.make()
|
||||
)
|
||||
cls.dcons = price_recipe.make(
|
||||
amount=-1, groups=[subscriber_group], product=_product_recipe.make()
|
||||
)
|
||||
baker.make(
|
||||
ReturnableProduct,
|
||||
product=cls.cons,
|
||||
returned_product=cls.dcons,
|
||||
product=cls.cons.product,
|
||||
returned_product=cls.dcons.product,
|
||||
max_return=3,
|
||||
)
|
||||
|
||||
cls.counter.products.add(
|
||||
cls.gift, cls.beer, cls.beer_tap, cls.snack, cls.cons, cls.dcons
|
||||
cls.gift.product,
|
||||
cls.beer.product,
|
||||
cls.beer_tap.product,
|
||||
cls.snack.product,
|
||||
cls.cons.product,
|
||||
cls.dcons.product,
|
||||
)
|
||||
cls.other_counter.products.add(cls.snack)
|
||||
cls.club_counter.products.add(cls.stamps)
|
||||
cls.other_counter.products.add(cls.snack.product)
|
||||
cls.club_counter.products.add(cls.stamps.product)
|
||||
|
||||
def login_in_bar(self, barmen: User | None = None):
|
||||
used_barman = barmen if barmen is not None else self.barmen
|
||||
@@ -285,10 +305,7 @@ class TestCounterClick(TestFullClickBase):
|
||||
) -> HttpResponse:
|
||||
used_counter = counter if counter is not None else self.counter
|
||||
used_client = client if client is not None else self.client
|
||||
data = {
|
||||
"form-TOTAL_FORMS": str(len(basket)),
|
||||
"form-INITIAL_FORMS": "0",
|
||||
}
|
||||
data = {"form-TOTAL_FORMS": str(len(basket)), "form-INITIAL_FORMS": "0"}
|
||||
for index, item in enumerate(basket):
|
||||
data.update(item.to_form(index))
|
||||
return used_client.post(
|
||||
@@ -331,32 +348,22 @@ class TestCounterClick(TestFullClickBase):
|
||||
res = self.submit_basket(
|
||||
self.customer, [BasketItem(self.beer.id, 2), BasketItem(self.snack.id, 1)]
|
||||
)
|
||||
assert res.status_code == 302
|
||||
self.assertRedirects(res, self.counter.get_absolute_url())
|
||||
|
||||
assert self.updated_amount(self.customer) == Decimal("5.5")
|
||||
|
||||
# Test barmen special price
|
||||
|
||||
force_refill_user(self.barmen, 10)
|
||||
|
||||
assert (
|
||||
self.submit_basket(self.barmen, [BasketItem(self.beer.id, 1)])
|
||||
).status_code == 302
|
||||
|
||||
assert self.updated_amount(self.barmen) == Decimal(9)
|
||||
|
||||
def test_click_tray_price(self):
|
||||
force_refill_user(self.customer, 20)
|
||||
self.login_in_bar(self.barmen)
|
||||
|
||||
# Not applying tray price
|
||||
res = self.submit_basket(self.customer, [BasketItem(self.beer_tap.id, 2)])
|
||||
assert res.status_code == 302
|
||||
self.assertRedirects(res, self.counter.get_absolute_url())
|
||||
assert self.updated_amount(self.customer) == Decimal(17)
|
||||
|
||||
# Applying tray price
|
||||
res = self.submit_basket(self.customer, [BasketItem(self.beer_tap.id, 7)])
|
||||
assert res.status_code == 302
|
||||
self.assertRedirects(res, self.counter.get_absolute_url())
|
||||
assert self.updated_amount(self.customer) == Decimal(8)
|
||||
|
||||
def test_click_alcool_unauthorized(self):
|
||||
@@ -477,7 +484,8 @@ class TestCounterClick(TestFullClickBase):
|
||||
BasketItem(None, 1),
|
||||
BasketItem(self.beer.id, None),
|
||||
]:
|
||||
assert self.submit_basket(self.customer, [item]).status_code == 200
|
||||
res = self.submit_basket(self.customer, [item])
|
||||
assert res.status_code == 200
|
||||
assert self.updated_amount(self.customer) == Decimal(10)
|
||||
|
||||
def test_click_not_enough_money(self):
|
||||
@@ -506,29 +514,30 @@ class TestCounterClick(TestFullClickBase):
|
||||
res = self.submit_basket(
|
||||
self.customer, [BasketItem(self.beer.id, 1), BasketItem(self.gift.id, 1)]
|
||||
)
|
||||
assert res.status_code == 302
|
||||
self.assertRedirects(res, self.counter.get_absolute_url())
|
||||
|
||||
assert self.updated_amount(self.customer) == 0
|
||||
|
||||
def test_recordings(self):
|
||||
force_refill_user(self.customer, self.cons.selling_price * 3)
|
||||
force_refill_user(self.customer, self.cons.amount * 3)
|
||||
self.login_in_bar(self.barmen)
|
||||
res = self.submit_basket(self.customer, [BasketItem(self.cons.id, 3)])
|
||||
assert res.status_code == 302
|
||||
assert self.updated_amount(self.customer) == 0
|
||||
assert list(
|
||||
self.customer.customer.return_balances.values("returnable", "balance")
|
||||
) == [{"returnable": self.cons.cons.id, "balance": 3}]
|
||||
) == [{"returnable": self.cons.product.cons.id, "balance": 3}]
|
||||
|
||||
res = self.submit_basket(self.customer, [BasketItem(self.dcons.id, 3)])
|
||||
assert res.status_code == 302
|
||||
assert self.updated_amount(self.customer) == self.dcons.selling_price * -3
|
||||
assert self.updated_amount(self.customer) == self.dcons.amount * -3
|
||||
|
||||
res = self.submit_basket(
|
||||
self.customer, [BasketItem(self.dcons.id, self.dcons.dcons.max_return)]
|
||||
self.customer,
|
||||
[BasketItem(self.dcons.id, self.dcons.product.dcons.max_return)],
|
||||
)
|
||||
# from now on, the user amount should not change
|
||||
expected_amount = self.dcons.selling_price * (-3 - self.dcons.dcons.max_return)
|
||||
expected_amount = self.dcons.amount * (-3 - self.dcons.product.dcons.max_return)
|
||||
assert res.status_code == 302
|
||||
assert self.updated_amount(self.customer) == expected_amount
|
||||
|
||||
@@ -545,48 +554,57 @@ class TestCounterClick(TestFullClickBase):
|
||||
def test_recordings_when_negative(self):
|
||||
sale_recipe.make(
|
||||
customer=self.customer.customer,
|
||||
product=self.dcons,
|
||||
unit_price=self.dcons.selling_price,
|
||||
product=self.dcons.product,
|
||||
unit_price=self.dcons.amount,
|
||||
quantity=10,
|
||||
)
|
||||
self.customer.customer.update_returnable_balance()
|
||||
self.login_in_bar(self.barmen)
|
||||
res = self.submit_basket(self.customer, [BasketItem(self.dcons.id, 1)])
|
||||
assert res.status_code == 200
|
||||
assert self.updated_amount(self.customer) == self.dcons.selling_price * -10
|
||||
assert self.updated_amount(self.customer) == self.dcons.amount * -10
|
||||
|
||||
res = self.submit_basket(self.customer, [BasketItem(self.cons.id, 3)])
|
||||
assert res.status_code == 302
|
||||
assert (
|
||||
self.updated_amount(self.customer)
|
||||
== self.dcons.selling_price * -10 - self.cons.selling_price * 3
|
||||
== self.dcons.amount * -10 - self.cons.amount * 3
|
||||
)
|
||||
|
||||
res = self.submit_basket(self.customer, [BasketItem(self.beer.id, 1)])
|
||||
assert res.status_code == 302
|
||||
assert (
|
||||
self.updated_amount(self.customer)
|
||||
== self.dcons.selling_price * -10
|
||||
- self.cons.selling_price * 3
|
||||
- self.beer.selling_price
|
||||
== self.dcons.amount * -10 - self.cons.amount * 3 - self.beer.amount
|
||||
)
|
||||
|
||||
def test_no_fetch_archived_product(self):
|
||||
counter = baker.make(Counter)
|
||||
group = baker.make(Group)
|
||||
customer = baker.make(Customer)
|
||||
product_recipe.make(archived=True, counters=[counter])
|
||||
unarchived_products = product_recipe.make(
|
||||
archived=False, counters=[counter], _quantity=3
|
||||
group.users.add(customer.user)
|
||||
_product_recipe = product_recipe.extend(
|
||||
counters=[counter], product_type=baker.make(ProductType)
|
||||
)
|
||||
customer_products = counter.get_products_for(customer)
|
||||
assert unarchived_products == customer_products
|
||||
price_recipe.make(
|
||||
_quantity=2,
|
||||
product=iter(_product_recipe.make(archived=True, _quantity=2)),
|
||||
groups=[group],
|
||||
)
|
||||
unarchived_prices = price_recipe.make(
|
||||
_quantity=2,
|
||||
product=iter(_product_recipe.make(archived=False, _quantity=2)),
|
||||
groups=[group],
|
||||
)
|
||||
customer_prices = counter.get_prices_for(customer)
|
||||
assert unarchived_prices == customer_prices
|
||||
|
||||
|
||||
class TestCounterStats(TestCase):
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
cls.users = subscriber_user.make(_quantity=4)
|
||||
product = product_recipe.make(selling_price=1)
|
||||
product = price_recipe.make(amount=1).product
|
||||
cls.counter = baker.make(
|
||||
Counter, type=["BAR"], sellers=cls.users[:4], products=[product]
|
||||
)
|
||||
@@ -785,9 +803,6 @@ class TestClubCounterClickAccess(TestCase):
|
||||
|
||||
cls.user = subscriber_user.make()
|
||||
|
||||
def setUp(self):
|
||||
cache.clear()
|
||||
|
||||
def test_anonymous(self):
|
||||
res = self.client.get(self.click_url)
|
||||
assert res.status_code == 403
|
||||
|
||||
@@ -341,7 +341,7 @@ def test_update_balance():
|
||||
def test_update_returnable_balance():
|
||||
ReturnableProduct.objects.all().delete()
|
||||
customer = baker.make(Customer)
|
||||
products = product_recipe.make(selling_price=0, _quantity=4, _bulk_create=True)
|
||||
products = product_recipe.make(_quantity=4, _bulk_create=True)
|
||||
returnables = [
|
||||
baker.make(
|
||||
ReturnableProduct, product=products[0], returned_product=products[1]
|
||||
|
||||
@@ -7,12 +7,7 @@ from counter.forms import ProductFormulaForm
|
||||
class TestFormulaForm(TestCase):
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
cls.products = product_recipe.make(
|
||||
selling_price=iter([1.5, 1, 1]),
|
||||
special_selling_price=iter([1.4, 0.9, 0.9]),
|
||||
_quantity=3,
|
||||
_bulk_create=True,
|
||||
)
|
||||
cls.products = product_recipe.make(_quantity=3, _bulk_create=True)
|
||||
|
||||
def test_ok(self):
|
||||
form = ProductFormulaForm(
|
||||
@@ -26,23 +21,6 @@ class TestFormulaForm(TestCase):
|
||||
assert formula.result == self.products[0]
|
||||
assert set(formula.products.all()) == set(self.products[1:])
|
||||
|
||||
def test_price_invalid(self):
|
||||
self.products[0].selling_price = 2.1
|
||||
self.products[0].save()
|
||||
form = ProductFormulaForm(
|
||||
data={
|
||||
"result": self.products[0].id,
|
||||
"products": [self.products[1].id, self.products[2].id],
|
||||
}
|
||||
)
|
||||
assert not form.is_valid()
|
||||
assert form.errors == {
|
||||
"result": [
|
||||
"Le résultat ne peut pas être plus cher "
|
||||
"que le total des autres produits."
|
||||
]
|
||||
}
|
||||
|
||||
def test_product_both_in_result_and_products(self):
|
||||
form = ProductFormulaForm(
|
||||
data={
|
||||
|
||||
@@ -9,6 +9,7 @@ from django.core.files.uploadedfile import SimpleUploadedFile
|
||||
from django.test import Client, TestCase
|
||||
from django.urls import reverse
|
||||
from model_bakery import baker
|
||||
from model_bakery.recipe import Recipe
|
||||
from PIL import Image
|
||||
from pytest_django.asserts import assertNumQueries, assertRedirects
|
||||
|
||||
@@ -16,8 +17,8 @@ from club.models import Club
|
||||
from core.baker_recipes import board_user, subscriber_user
|
||||
from core.models import Group, User
|
||||
from counter.baker_recipes import product_recipe
|
||||
from counter.forms import ProductForm
|
||||
from counter.models import Product, ProductFormula, ProductType
|
||||
from counter.forms import ProductForm, ProductPriceFormSet
|
||||
from counter.models import Price, Product, ProductType
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@@ -81,11 +82,11 @@ def test_fetch_product_access(
|
||||
def test_fetch_product_nb_queries(client: Client):
|
||||
client.force_login(baker.make(User, is_superuser=True))
|
||||
cache.clear()
|
||||
with assertNumQueries(5):
|
||||
with assertNumQueries(6):
|
||||
# - 2 for authentication
|
||||
# - 1 for pagination
|
||||
# - 1 for the actual request
|
||||
# - 1 to prefetch the related buying_groups
|
||||
# - 2 to prefetch the related prices and groups
|
||||
client.get(reverse("api:search_products_detailed"))
|
||||
|
||||
|
||||
@@ -107,48 +108,21 @@ class TestCreateProduct(TestCase):
|
||||
"selling_price": 1.0,
|
||||
"special_selling_price": 1.0,
|
||||
"limit_age": 0,
|
||||
"form-TOTAL_FORMS": 0,
|
||||
"form-INITIAL_FORMS": 0,
|
||||
"price-TOTAL_FORMS": 0,
|
||||
"price-INITIAL_FORMS": 0,
|
||||
"action-TOTAL_FORMS": 0,
|
||||
"action-INITIAL_FORMS": 0,
|
||||
}
|
||||
|
||||
def test_form(self):
|
||||
def test_form_simple(self):
|
||||
form = ProductForm(data=self.data)
|
||||
assert form.is_valid()
|
||||
instance = form.save()
|
||||
assert instance.club == self.club
|
||||
assert instance.product_type == self.product_type
|
||||
assert instance.name == "foo"
|
||||
assert instance.selling_price == 1.0
|
||||
|
||||
def test_form_with_product_from_formula(self):
|
||||
"""Test when the edited product is a result of a formula."""
|
||||
self.client.force_login(self.counter_admin)
|
||||
products = product_recipe.make(
|
||||
selling_price=iter([1.5, 1, 1]),
|
||||
special_selling_price=iter([1.4, 0.9, 0.9]),
|
||||
_quantity=3,
|
||||
_bulk_create=True,
|
||||
)
|
||||
baker.make(ProductFormula, result=products[0], products=products[1:])
|
||||
|
||||
data = self.data | {"selling_price": 1.7, "special_selling_price": 1.5}
|
||||
form = ProductForm(data=data, instance=products[0])
|
||||
assert form.is_valid()
|
||||
|
||||
# it shouldn't be possible to give a price higher than the formula's products
|
||||
data = self.data | {"selling_price": 2.1, "special_selling_price": 1.9}
|
||||
form = ProductForm(data=data, instance=products[0])
|
||||
assert not form.is_valid()
|
||||
assert form.errors == {
|
||||
"selling_price": [
|
||||
"Assurez-vous que cette valeur est inférieure ou égale à 2.00."
|
||||
],
|
||||
"special_selling_price": [
|
||||
"Assurez-vous que cette valeur est inférieure ou égale à 1.80."
|
||||
],
|
||||
}
|
||||
|
||||
def test_view(self):
|
||||
def test_view_simple(self):
|
||||
self.client.force_login(self.counter_admin)
|
||||
url = reverse("counter:new_product")
|
||||
response = self.client.get(url)
|
||||
@@ -159,3 +133,92 @@ class TestCreateProduct(TestCase):
|
||||
assert product.name == "foo"
|
||||
assert product.club == self.club
|
||||
assert product.product_type == self.product_type
|
||||
|
||||
|
||||
class TestPriceFormSet(TestCase):
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
cls.product = product_recipe.make()
|
||||
cls.counter_admin = baker.make(
|
||||
User, groups=[Group.objects.get(id=settings.SITH_GROUP_COUNTER_ADMIN_ID)]
|
||||
)
|
||||
cls.groups = baker.make(Group, _quantity=3)
|
||||
|
||||
def test_add_price(self):
|
||||
data = {
|
||||
"prices-0-amount": 2,
|
||||
"prices-0-label": "foo",
|
||||
"prices-0-groups": [self.groups[0].id, self.groups[1].id],
|
||||
"prices-0-is_always_shown": True,
|
||||
"prices-1-amount": 1.5,
|
||||
"prices-1-label": "",
|
||||
"prices-1-groups": [self.groups[1].id, self.groups[2].id],
|
||||
"prices-1-is_always_shown": False,
|
||||
"prices-TOTAL_FORMS": 2,
|
||||
"prices-INITIAL_FORMS": 0,
|
||||
}
|
||||
form = ProductPriceFormSet(instance=self.product, data=data)
|
||||
assert form.is_valid()
|
||||
form.save()
|
||||
prices = list(self.product.prices.order_by("amount"))
|
||||
assert len(prices) == 2
|
||||
assert prices[0].amount == 1.5
|
||||
assert prices[0].label == ""
|
||||
assert prices[0].is_always_shown is False
|
||||
assert set(prices[0].groups.all()) == {self.groups[1], self.groups[2]}
|
||||
assert prices[1].amount == 2
|
||||
assert prices[1].label == "foo"
|
||||
assert prices[1].is_always_shown is True
|
||||
assert set(prices[1].groups.all()) == {self.groups[0], self.groups[1]}
|
||||
|
||||
def test_change_prices(self):
|
||||
price_a = baker.make(
|
||||
Price, product=self.product, amount=1.5, groups=self.groups[:1]
|
||||
)
|
||||
price_b = baker.make(
|
||||
Price, product=self.product, amount=2, groups=self.groups[1:]
|
||||
)
|
||||
data = {
|
||||
"prices-0-id": price_a.id,
|
||||
"prices-0-DELETE": True,
|
||||
"prices-1-id": price_b.id,
|
||||
"prices-1-DELETE": False,
|
||||
"prices-1-amount": 3,
|
||||
"prices-1-label": "foo",
|
||||
"prices-1-groups": [self.groups[1].id],
|
||||
"prices-1-is_always_shown": True,
|
||||
"prices-TOTAL_FORMS": 2,
|
||||
"prices-INITIAL_FORMS": 2,
|
||||
}
|
||||
form = ProductPriceFormSet(instance=self.product, data=data)
|
||||
assert form.is_valid()
|
||||
form.save()
|
||||
prices = list(self.product.prices.order_by("amount"))
|
||||
assert len(prices) == 1
|
||||
assert prices[0].amount == 3
|
||||
assert prices[0].label == "foo"
|
||||
assert prices[0].is_always_shown is True
|
||||
assert set(prices[0].groups.all()) == {self.groups[1]}
|
||||
assert not Price.objects.filter(id=price_a.id).exists()
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_price_for_user():
|
||||
groups = baker.make(Group, _quantity=4)
|
||||
users = [
|
||||
baker.make(User, groups=groups[:2]),
|
||||
baker.make(User, groups=groups[1:3]),
|
||||
baker.make(User, groups=[groups[3]]),
|
||||
]
|
||||
recipe = Recipe(Price, product=product_recipe.make())
|
||||
prices = [
|
||||
recipe.make(amount=5, groups=groups, is_always_shown=True),
|
||||
recipe.make(amount=4, groups=[groups[0]], is_always_shown=True),
|
||||
recipe.make(amount=3, groups=[groups[1]], is_always_shown=False),
|
||||
recipe.make(amount=2, groups=[groups[3]], is_always_shown=False),
|
||||
recipe.make(amount=1, groups=[groups[1]], is_always_shown=False),
|
||||
]
|
||||
qs = Price.objects.order_by("-amount")
|
||||
assert set(qs.for_user(users[0])) == {prices[0], prices[1], prices[4]}
|
||||
assert set(qs.for_user(users[1])) == {prices[0], prices[4]}
|
||||
assert set(qs.for_user(users[2])) == {prices[0], prices[3]}
|
||||
|
||||
@@ -73,7 +73,7 @@ class CounterClick(
|
||||
kwargs["form_kwargs"] = {
|
||||
"customer": self.customer,
|
||||
"counter": self.object,
|
||||
"allowed_products": {product.id: product for product in self.products},
|
||||
"allowed_prices": {price.id: price for price in self.prices},
|
||||
}
|
||||
return kwargs
|
||||
|
||||
@@ -103,7 +103,7 @@ class CounterClick(
|
||||
):
|
||||
return redirect(obj) # Redirect to counter
|
||||
|
||||
self.products = obj.get_products_for(self.customer)
|
||||
self.prices = obj.get_prices_for(self.customer)
|
||||
|
||||
return super().dispatch(request, *args, **kwargs)
|
||||
|
||||
@@ -121,32 +121,31 @@ class CounterClick(
|
||||
# This is important because some items have a negative price
|
||||
# Negative priced items gives money to the customer and should
|
||||
# be processed first so that we don't throw a not enough money error
|
||||
for form in sorted(formset, key=lambda form: form.product.price):
|
||||
for form in sorted(formset, key=lambda form: form.price.amount):
|
||||
self.request.session["last_basket"].append(
|
||||
f"{form.cleaned_data['quantity']} x {form.product.name}"
|
||||
f"{form.cleaned_data['quantity']} x {form.price.full_label}"
|
||||
)
|
||||
|
||||
common_kwargs = {
|
||||
"product": form.price.product,
|
||||
"club_id": form.price.product.club_id,
|
||||
"counter": self.object,
|
||||
"seller": operator,
|
||||
"customer": self.customer,
|
||||
}
|
||||
Selling(
|
||||
label=form.product.name,
|
||||
product=form.product,
|
||||
club=form.product.club,
|
||||
counter=self.object,
|
||||
unit_price=form.product.price,
|
||||
**common_kwargs,
|
||||
label=form.price.full_label,
|
||||
unit_price=form.price.amount,
|
||||
quantity=form.cleaned_data["quantity"]
|
||||
- form.cleaned_data["bonus_quantity"],
|
||||
seller=operator,
|
||||
customer=self.customer,
|
||||
).save()
|
||||
if form.cleaned_data["bonus_quantity"] > 0:
|
||||
Selling(
|
||||
label=f"{form.product.name} (Plateau)",
|
||||
product=form.product,
|
||||
club=form.product.club,
|
||||
counter=self.object,
|
||||
**common_kwargs,
|
||||
label=f"{form.price.full_label} (Plateau)",
|
||||
unit_price=0,
|
||||
quantity=form.cleaned_data["bonus_quantity"],
|
||||
seller=operator,
|
||||
customer=self.customer,
|
||||
).save()
|
||||
|
||||
self.customer.update_returnable_balance()
|
||||
@@ -207,14 +206,13 @@ class CounterClick(
|
||||
def get_context_data(self, **kwargs):
|
||||
"""Add customer to the context."""
|
||||
kwargs = super().get_context_data(**kwargs)
|
||||
kwargs["products"] = self.products
|
||||
kwargs["prices"] = self.prices
|
||||
kwargs["formulas"] = ProductFormula.objects.filter(
|
||||
result__in=self.products
|
||||
result__in=[p.product_id for p in self.prices]
|
||||
).prefetch_related("products")
|
||||
kwargs["categories"] = defaultdict(list)
|
||||
for product in kwargs["products"]:
|
||||
if product.product_type:
|
||||
kwargs["categories"][product.product_type].append(product)
|
||||
for price in self.prices:
|
||||
kwargs["categories"][price.product.product_type].append(price)
|
||||
kwargs["customer"] = self.customer
|
||||
kwargs["cancel_url"] = self.get_success_url()
|
||||
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
::: api.schemas
|
||||
@@ -1 +0,0 @@
|
||||
::: api.views
|
||||
@@ -1,353 +0,0 @@
|
||||
Le site AE offre des mécanismes permettant aux applications tierces
|
||||
de récupérer les informations sur un utilisateur du site AE.
|
||||
De cette manière, il devient possible de synchroniser les informations
|
||||
qu possède l'application tierce sur l'utilisateur, directement depuis
|
||||
le site AE.
|
||||
|
||||
## Fonctionnement général
|
||||
|
||||
Pour authentifier vos utilisateurs, vous aurez besoin d'un serveur web
|
||||
et d'un client d'API (celui auquel est liée votre
|
||||
[clef d'API](./connect.md#obtenir-une-clef-dapi)).
|
||||
Deux informations vous sont nécessaires, en plus de votre clef d'API :
|
||||
|
||||
- l'id du client : vous pouvez l'obtenir soit en le demandant à l'équipe info,
|
||||
soit en appelant la route `GET /client/me` avec votre clef d'API
|
||||
renseignée dans le header [X-APIKey](./connect.md#x-apikey)
|
||||
- la clef HMAC du client : vous devez la demander à l'équipe info.
|
||||
|
||||
Grâce à ces informations, vous allez pouvoir fournir le contexte nécessaire
|
||||
au site AE pour qu'il authentifie vos utilisateurs.
|
||||
|
||||
En effet, la démarche d'authentification s'effectue presque entièrement
|
||||
sur le site : le travail de l'application tierce consiste uniquement
|
||||
à fournir à l'utilisateur une url avec les bons paramètres, puis
|
||||
à recevoir la réponse du serveur si tout s'est bien passé.
|
||||
|
||||
Comme un dessin vaut parfois mieux que mille mots,
|
||||
voici les diagrammes décrivant le processus.
|
||||
L'un montre l'entièreté de la démarche ;
|
||||
l'autre dans un souci de simplicité, ne montre que ce qui est visible
|
||||
directement par l'application tierce.
|
||||
|
||||
=== "Intégralité du processus"
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
actor User
|
||||
participant App
|
||||
User->>+App: Authentifie-moi, stp
|
||||
App-->>-User: url de connexion<br/>avec signature
|
||||
User->>+Sith: GET url
|
||||
opt Utilisateur non-connecté
|
||||
Sith->>+User: Formulaire de connexion
|
||||
User-->>-Sith: Connexion
|
||||
end
|
||||
Sith->>Sith: vérification de la signature
|
||||
Sith->>+User: Formulaire<br/>des conditions<br/>d'utilisation
|
||||
User-->>-Sith: Validation
|
||||
Sith->>+App: URL de retour<br/>avec données utilisateur
|
||||
App->>App: Traitement des <br/>données utilisateur
|
||||
App-->>-Sith: 204 OK, No content
|
||||
Sith-->>-User: Message de succès
|
||||
App--)User: Message de succès
|
||||
```
|
||||
|
||||
=== "Point de vue de l'application tierce"
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
actor User
|
||||
participant App
|
||||
User->>+App: Authentifie-moi, stp
|
||||
App-->>-User: url de connexion<br/>avec signature
|
||||
opt
|
||||
Sith->>+App: URL de retour<br/>avec données utilisateur
|
||||
App->>App: Traitement des <br/>données utilisateur
|
||||
App-->>-Sith: 204 OK, No content
|
||||
App--)User: Message de succès
|
||||
end
|
||||
```
|
||||
|
||||
## Données attendues
|
||||
|
||||
### URL de connexion
|
||||
|
||||
L'URL de connexion que vous allez fournir à l'utilisateur doit
|
||||
être `https://ae.utbm.fr/api-link/auth/`
|
||||
et doit contenir les données décrites dans
|
||||
[`ThirdPartyAuthParamsSchema`][api.schemas.ThirdPartyAuthParamsSchema] :
|
||||
|
||||
- `client_id` (integer) : l'id de votre client, que vous pouvez obtenir
|
||||
de la manière décrite plus haut
|
||||
- `third_party_app`(string) : le nom de la plateforme pour laquelle
|
||||
l'authentification va être réalisée (si votre application est un bot
|
||||
discord, mettez la valeur "discord")
|
||||
- `privacy_link`(URL) : l'URL vers la page de politique de confidentialité
|
||||
qui s'appliquera dans le cadre de l'application
|
||||
(s'il s'agit d'un bot discord, donnez le lien vers celles de Discord)
|
||||
- `username`(string) : le pseudonyme que l'utilisateur possède sur
|
||||
votre application
|
||||
- `callback_url`(URL) : l'URL que le site AE appellera si l'authentification
|
||||
réussit
|
||||
- `signature`(string) : la signature des données de la requête.
|
||||
|
||||
Ces données doivent être url-encodées et passées dans les paramètres GET.
|
||||
|
||||
!!!tip "URL de retour"
|
||||
|
||||
Notre système n'impose aucune contrainte quant à la manière
|
||||
de construire votre URL (hormis le fait que ce doit être une URL HTTPS valide),
|
||||
mais il est tout de même conseillé d'utiliser l'identifiant de votre
|
||||
utilisateur comme paramètre dans l'URL
|
||||
(par exemple `GET /callback/{int:user_id}/`).
|
||||
|
||||
???Example
|
||||
|
||||
Supposons que votre client d'API soit utilisé dans le cadre d'un bot Discord,
|
||||
avec les données suivantes :
|
||||
|
||||
- l'id du client est 15
|
||||
- sa clef HMAC est "beb99dd53"
|
||||
(c'est pour l'exemple, une vraie clef sera beaucoup plus longue)
|
||||
- le pseudonyme discord de votre utilisateur est Brian
|
||||
- son id sur discord est 123456789
|
||||
- votre route de callback est `GET /callback/{int:user_id}/`,
|
||||
accessible au domaine `https://bot.ae.utbm.fr`
|
||||
|
||||
Alors les paramètres de votre URL seront :
|
||||
|
||||
| Paramètre | valeur |
|
||||
|-----------------|-----------------------------------------------------------------------|
|
||||
| client_id | 15 |
|
||||
| third_party_app | discord |
|
||||
| privacy_link | `https://discord.com/privacy` |
|
||||
| username | Brian |
|
||||
| callback_url | `https://bot.ae.utbm.fr/callback/123456789/` |
|
||||
| signature | 1a383c51060be64f07772aa42e07<br/>18ae096b8f21f2cdb4061c0834a416d12101 |
|
||||
|
||||
Et l'url fournie à l'utilisateur sera :
|
||||
|
||||
`https://ae.utbm.fr/api-link/auth/?client_id=15&third_party_app=discord
|
||||
&privacy_link=https%3A%2F%2Fdiscord.com%2Fprivacy&username=Brian
|
||||
&callback_url=https%3A%2F%2Fbot.ae.utbm.fr%2Fcallback%2F123456789%2F
|
||||
&signature=1a383c51060be64f07772aa42e0718ae096b8f21f2cdb4061c0834a416d12101`
|
||||
|
||||
### Données de retour
|
||||
|
||||
Si l'authentification réussit, le site AE enverra une requête HTTP POST
|
||||
à l'URL de retour fournie dans l'URL de connexion.
|
||||
|
||||
Le corps de la requête de callback et au format JSON
|
||||
et contient deux paires clef-valeur :
|
||||
|
||||
- `user` : les données utilisateur, telles que décrites
|
||||
par [UserProfileSchema][core.schemas.UserProfileSchema]
|
||||
- `signature` : la signature des données utilisateur
|
||||
|
||||
???Example
|
||||
|
||||
En reprenant les mêmes paramètres que dans l'exemple précédent,
|
||||
le site AE pourra renvoyer à l'application la requête suivante :
|
||||
|
||||
```http
|
||||
POST https://bot.ae.utbm.fr/callback/123456789/
|
||||
content-type: application/json
|
||||
body: {
|
||||
"user": {
|
||||
"id": 144131,
|
||||
"nick_name": "inzekitchen",
|
||||
"first_name": "Brian",
|
||||
...
|
||||
},
|
||||
"signature": "f16955bab6b805f6e1abbb98a86dfee53fed0bf812aa6513ca46cfd461b70020"
|
||||
}
|
||||
```
|
||||
|
||||
L'application doit répondre avec un des codes HTTP suivants :
|
||||
|
||||
| Code | Raison |
|
||||
|------|--------------------------------------------------------------------------------|
|
||||
| 204 | Tout s'est bien passé |
|
||||
| 403 | Les données de retour ne sont <br>pas signées ou sont mal signées |
|
||||
| 404 | L'URL de retour ne permet pas <br>d'identifier un utilisateur de l'application |
|
||||
|
||||
!!!note "Code d'erreur par défaut"
|
||||
|
||||
Si l'appel de la route fait face à plusieurs problèmes en même temps
|
||||
(par exemple, l'URL ne permet pas de retrouver votre utilisateur,
|
||||
et en plus les données sont mal signées),
|
||||
le 403 prime et doit être retourné par défaut.
|
||||
|
||||
## Signature des données
|
||||
|
||||
Les données de l'URL de connexion doivent être signées,
|
||||
et la signature de l'URL de retour doit être vérifiée.
|
||||
|
||||
Dans le deux cas, la signature est le digest HMAC-SHA512
|
||||
des données url-encodées, en utilisant la clef HMAC du client d'API.
|
||||
|
||||
???Example "Signature de l'URL de connexion"
|
||||
|
||||
En reprenant le même exemple que les fois précédentes,
|
||||
l'url-encodage des données est :
|
||||
|
||||
`client_id=15&third_party_app=discord
|
||||
&privacy_link=https%3A%2F%2Fdiscord.com%2Fprivacy%2F&username=Brian
|
||||
&callback_url=https%3A%2F%2Fbot.ae.utbm.fr%2Fcallback%2F123456789%2F`
|
||||
|
||||
Notez que la signature n'est pas (encore) dedans.
|
||||
Cette dernière peut-être obtenue avec le code suivant :
|
||||
|
||||
=== ":simple-python: Python"
|
||||
|
||||
Dépendances :
|
||||
|
||||
- `environs` (>=14.1)
|
||||
|
||||
```python
|
||||
import hmac
|
||||
from urllib.parse import urlencode
|
||||
|
||||
from environs import Env
|
||||
|
||||
env = Env()
|
||||
env.read_env()
|
||||
|
||||
key = env.str("HMAC_KEY").encode()
|
||||
data = {
|
||||
"client_id": 15,
|
||||
"third_party_app": "discord",
|
||||
"privacy_link": "https://discord.com/privacy/",
|
||||
"username": "Brian",
|
||||
"callback_url": "https://bot.ae.utbm.fr/callback/123456789/",
|
||||
}
|
||||
urlencoded = urlencode(data)
|
||||
data["signature"] = hmac.digest(key, urlencoded.encode(), "sha512").hex()
|
||||
|
||||
# URL a fournir à l'utilisateur pour son authentification
|
||||
user_url = f"https://ae.ubtm.fr/api-link/auth/?{urlencode(data)}"
|
||||
```
|
||||
|
||||
=== ":simple-rust: Rust"
|
||||
|
||||
Dépendances :
|
||||
|
||||
- `hmac` (>=0.12.1)
|
||||
- `url` (>=2.5.7, features `serde`)
|
||||
- `serde` (>=1.0.228, features `derive`)
|
||||
- `serde_urlencoded` (>="0.7.1)
|
||||
- `sha2` (>=0.10.9)
|
||||
- `dotenvy` (>= 0.15)
|
||||
|
||||
```rust
|
||||
use hmac::{Mac, SimpleHmac};
|
||||
use serde::Serialize;
|
||||
use sha2::Sha512;
|
||||
use url::Url;
|
||||
|
||||
#[derive(Serialize, Debug)]
|
||||
struct UrlData<'a> {
|
||||
client_id: u32,
|
||||
third_party_app: &'a str,
|
||||
privacy_link: Url,
|
||||
username: &'a str,
|
||||
callback_url: Url,
|
||||
}
|
||||
|
||||
impl<'a> UrlData<'a> {
|
||||
pub fn signature(&self, key: &[u8]) -> CtOutput<SimpleHmac<Sha512>> {
|
||||
let urlencoded = serde_urlencoded::to_string(self).unwrap();
|
||||
SimpleHmac::<Sha512>::new_from_slice(key)
|
||||
.unwrap()
|
||||
.chain_update(urlencoded.as_bytes())
|
||||
.finalize()
|
||||
}
|
||||
}
|
||||
|
||||
impl Into<Url> for UrlData<'_> {
|
||||
fn into(self) -> Url {
|
||||
let key = std::env::var("HMAC_KEY").unwrap();
|
||||
let mut url = Url::parse("http://ae.utbm.fr/api-link/auth/").unwrap();
|
||||
url.set_query(Some(
|
||||
format!(
|
||||
"{}&signature={:x}",
|
||||
serde_urlencoded::to_string(&self).unwrap(),
|
||||
self.signature(key.as_bytes()).into_bytes()
|
||||
)
|
||||
.as_str(),
|
||||
));
|
||||
url
|
||||
}
|
||||
}
|
||||
|
||||
fn main() {
|
||||
dotenvy::dotenv().expect("Couldn't load env");
|
||||
let data = UrlData {
|
||||
client_id: 1,
|
||||
third_party_app: "discord",
|
||||
privacy_link: "https://discord.com/privacy/".parse().unwrap(),
|
||||
username: "Brian",
|
||||
callback_url: "https://bot.ae.utbm.fr/callback/123456789/"
|
||||
.parse()
|
||||
.unwrap(),
|
||||
};
|
||||
let url: Url = data.into();
|
||||
println!("{:?}", url);
|
||||
}
|
||||
```
|
||||
|
||||
???Example "Vérification de la signature de la réponse"
|
||||
|
||||
Les données utilisateur peuvent ressembler à :
|
||||
|
||||
```json
|
||||
{
|
||||
"user": {
|
||||
"display_name": "Matthieu Vincent",
|
||||
"profile_url": "/user/380/",
|
||||
"profile_pict": "/static/core/img/unknown.jpg",
|
||||
"id": 380,
|
||||
"nick_name": None,
|
||||
"first_name": "Matthieu",
|
||||
"last_name": "Vincent",
|
||||
},
|
||||
"signature": "3802a280fbb01bd9fetc."
|
||||
}
|
||||
```
|
||||
|
||||
Vous pouvez vérifier la signature ainsi :
|
||||
|
||||
```python
|
||||
import hmac
|
||||
from urllib.parse import urlencode
|
||||
|
||||
from environs import Env
|
||||
|
||||
env = Env()
|
||||
env.read_env()
|
||||
|
||||
def is_signature_valid(user_data: dict, signature: str) -> bool:
|
||||
key = env.str("HMAC_KEY").encode()
|
||||
urlencoded = urlencode(user_data)
|
||||
return hmac.compare_digest(
|
||||
hmac.digest(key, urlencoded.encode(), "sha512").hex(),
|
||||
signature,
|
||||
)
|
||||
|
||||
|
||||
post_data = <récupération des données POST>
|
||||
print(
|
||||
"signature valide :",
|
||||
is_signature_valid(post_data["user"], post_data["signature"]
|
||||
)
|
||||
```
|
||||
|
||||
!!!Warning
|
||||
|
||||
Vous devez impérativement vérifier la signature
|
||||
des données de la requête de callback !
|
||||
|
||||
Si l'équipe informatique se rend compte que vous ne le faites pas,
|
||||
elle se réserve le droit de suspendre votre application,
|
||||
immédiatement et sans préavis.
|
||||
@@ -112,7 +112,7 @@ cf. [HTTP persistant connection (wikipedia)](https://en.wikipedia.org/wiki/HTTP_
|
||||
|
||||
Voici quelques exemples :
|
||||
|
||||
=== ":simple-python: Python (requests)"
|
||||
=== "Python (requests)"
|
||||
|
||||
Dépendances :
|
||||
|
||||
@@ -132,7 +132,7 @@ Voici quelques exemples :
|
||||
print(response.json())
|
||||
```
|
||||
|
||||
=== ":simple-python: Python (aiohttp)"
|
||||
=== "Python (aiohttp)"
|
||||
|
||||
Dépendances :
|
||||
|
||||
@@ -158,7 +158,7 @@ Voici quelques exemples :
|
||||
asyncio.run(main())
|
||||
```
|
||||
|
||||
=== ":simple-javascript: Javascript (axios)"
|
||||
=== "Javascript (axios)"
|
||||
|
||||
Dépendances :
|
||||
|
||||
@@ -178,7 +178,7 @@ Voici quelques exemples :
|
||||
console.log(await instance.get("club/1").json());
|
||||
```
|
||||
|
||||
=== ":simple-rust: Rust (reqwest)"
|
||||
=== "Rust (reqwest)"
|
||||
|
||||
Dépendances :
|
||||
|
||||
|
||||
@@ -22,23 +22,22 @@ from eboutic.models import Basket, BasketItem, Invoice, InvoiceItem
|
||||
class BasketAdmin(admin.ModelAdmin):
|
||||
list_display = ("user", "date", "total")
|
||||
autocomplete_fields = ("user",)
|
||||
date_hierarchy = "date"
|
||||
|
||||
def get_queryset(self, request):
|
||||
return (
|
||||
super()
|
||||
.get_queryset(request)
|
||||
.annotate(
|
||||
total=Sum(
|
||||
F("items__quantity") * F("items__product_unit_price"), default=0
|
||||
)
|
||||
total=Sum(F("items__quantity") * F("items__unit_price"), default=0)
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@admin.register(BasketItem)
|
||||
class BasketItemAdmin(admin.ModelAdmin):
|
||||
list_display = ("basket", "product_name", "product_unit_price", "quantity")
|
||||
search_fields = ("product_name",)
|
||||
list_display = ("label", "unit_price", "quantity")
|
||||
search_fields = ("label",)
|
||||
|
||||
|
||||
@admin.register(Invoice)
|
||||
@@ -50,5 +49,6 @@ class InvoiceAdmin(admin.ModelAdmin):
|
||||
|
||||
@admin.register(InvoiceItem)
|
||||
class InvoiceItemAdmin(admin.ModelAdmin):
|
||||
list_display = ("invoice", "product_name", "product_unit_price", "quantity")
|
||||
search_fields = ("product_name",)
|
||||
list_display = ("label", "unit_price", "quantity")
|
||||
search_fields = ("label",)
|
||||
list_select_related = ("price",)
|
||||
|
||||
37
eboutic/converters.py
Normal file
37
eboutic/converters.py
Normal file
@@ -0,0 +1,37 @@
|
||||
#
|
||||
# Copyright 2022
|
||||
# - Maréchal <thgirod@hotmail.com
|
||||
#
|
||||
# Ce fichier fait partie du site de l'Association des Étudiants de l'UTBM,
|
||||
# http://ae.utbm.fr.
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or modify it under
|
||||
# the terms of the GNU General Public License a published by the Free Software
|
||||
# Foundation; either version 3 of the License, or (at your option) any later
|
||||
# version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful, but WITHOUT
|
||||
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
|
||||
# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
|
||||
# details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License along with
|
||||
# this program; if not, write to the Free Sofware Foundation, Inc., 59 Temple
|
||||
# Place - Suite 330, Boston, MA 02111-1307, USA.
|
||||
|
||||
|
||||
class PaymentResultConverter:
|
||||
"""Converter used for url mapping of the `eboutic.views.payment_result` view.
|
||||
|
||||
It's meant to build an url that can match
|
||||
either `/eboutic/pay/success/` or `/eboutic/pay/failure/`
|
||||
but nothing else.
|
||||
"""
|
||||
|
||||
regex = "(success|failure)"
|
||||
|
||||
def to_python(self, value):
|
||||
return str(value)
|
||||
|
||||
def to_url(self, value):
|
||||
return str(value)
|
||||
@@ -0,0 +1,53 @@
|
||||
# Generated by Django 5.2.11 on 2026-02-22 18:13
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [("counter", "0038_price"), ("eboutic", "0002_auto_20221005_2243")]
|
||||
|
||||
operations = [
|
||||
migrations.RenameField(
|
||||
model_name="basketitem", old_name="product_name", new_name="label"
|
||||
),
|
||||
migrations.RenameField(
|
||||
model_name="basketitem",
|
||||
old_name="product_unit_price",
|
||||
new_name="unit_price",
|
||||
),
|
||||
migrations.RenameField(
|
||||
model_name="basketitem", old_name="product_id", new_name="product"
|
||||
),
|
||||
migrations.RenameField(
|
||||
model_name="invoiceitem", old_name="product_name", new_name="label"
|
||||
),
|
||||
migrations.RenameField(
|
||||
model_name="invoiceitem",
|
||||
old_name="product_unit_price",
|
||||
new_name="unit_price",
|
||||
),
|
||||
migrations.RenameField(
|
||||
model_name="invoiceitem", old_name="product_id", new_name="product"
|
||||
),
|
||||
migrations.RemoveField(model_name="basketitem", name="type_id"),
|
||||
migrations.RemoveField(model_name="invoiceitem", name="type_id"),
|
||||
migrations.AlterField(
|
||||
model_name="basketitem",
|
||||
name="product",
|
||||
field=models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.PROTECT,
|
||||
to="counter.product",
|
||||
verbose_name="product",
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="invoiceitem",
|
||||
name="product",
|
||||
field=models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.PROTECT,
|
||||
to="counter.product",
|
||||
verbose_name="product",
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -17,7 +17,7 @@ from __future__ import annotations
|
||||
import hmac
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
from typing import Any, Self
|
||||
from typing import Self
|
||||
|
||||
from dict2xml import dict2xml
|
||||
from django.conf import settings
|
||||
@@ -30,8 +30,8 @@ from core.models import User
|
||||
from counter.fields import CurrencyField
|
||||
from counter.models import (
|
||||
BillingInfo,
|
||||
Counter,
|
||||
Customer,
|
||||
Price,
|
||||
Product,
|
||||
Refilling,
|
||||
Selling,
|
||||
@@ -39,22 +39,6 @@ from counter.models import (
|
||||
)
|
||||
|
||||
|
||||
def get_eboutic_products(user: User) -> list[Product]:
|
||||
products = (
|
||||
get_eboutic()
|
||||
.products.filter(product_type__isnull=False)
|
||||
.filter(archived=False, limit_age__lte=user.age)
|
||||
.annotate(
|
||||
order=F("product_type__order"),
|
||||
category=F("product_type__name"),
|
||||
category_comment=F("product_type__comment"),
|
||||
price=F("selling_price"), # <-- selected price for basket validation
|
||||
)
|
||||
.prefetch_related("buying_groups") # <-- used in `Product.can_be_sold_to`
|
||||
)
|
||||
return [p for p in products if p.can_be_sold_to(user)]
|
||||
|
||||
|
||||
class BillingInfoState(Enum):
|
||||
VALID = 1
|
||||
EMPTY = 2
|
||||
@@ -94,21 +78,21 @@ class Basket(models.Model):
|
||||
def __str__(self):
|
||||
return f"{self.user}'s basket ({self.items.all().count()} items)"
|
||||
|
||||
def can_be_viewed_by(self, user):
|
||||
def can_be_viewed_by(self, user: User):
|
||||
return self.user == user
|
||||
|
||||
@cached_property
|
||||
def contains_refilling_item(self) -> bool:
|
||||
return self.items.filter(
|
||||
type_id=settings.SITH_COUNTER_PRODUCTTYPE_REFILLING
|
||||
product__product_type_id=settings.SITH_COUNTER_PRODUCTTYPE_REFILLING
|
||||
).exists()
|
||||
|
||||
@cached_property
|
||||
def total(self) -> float:
|
||||
return float(
|
||||
self.items.aggregate(
|
||||
total=Sum(F("quantity") * F("product_unit_price"), default=0)
|
||||
)["total"]
|
||||
self.items.aggregate(total=Sum(F("quantity") * F("unit_price"), default=0))[
|
||||
"total"
|
||||
]
|
||||
)
|
||||
|
||||
def generate_sales(
|
||||
@@ -120,7 +104,8 @@ class Basket(models.Model):
|
||||
Example:
|
||||
```python
|
||||
counter = Counter.objects.get(name="Eboutic")
|
||||
sales = basket.generate_sales(counter, "SITH_ACCOUNT")
|
||||
user = User.objects.get(username="bibou")
|
||||
sales = basket.generate_sales(counter, user, Selling.PaymentMethod.SITH_ACCOUNT)
|
||||
# here the basket is in the same state as before the method call
|
||||
|
||||
with transaction.atomic():
|
||||
@@ -131,31 +116,23 @@ class Basket(models.Model):
|
||||
# thus only the sales remain
|
||||
```
|
||||
"""
|
||||
# I must proceed with two distinct requests instead of
|
||||
# only one with a join because the AbstractBaseItem model has been
|
||||
# poorly designed. If you refactor the model, please refactor this too.
|
||||
items = self.items.order_by("product_id")
|
||||
ids = [item.product_id for item in items]
|
||||
products = Product.objects.filter(id__in=ids).order_by("id")
|
||||
# items and products are sorted in the same order
|
||||
sales = []
|
||||
for item, product in zip(items, products, strict=False):
|
||||
sales.append(
|
||||
Selling(
|
||||
label=product.name,
|
||||
counter=counter,
|
||||
club=product.club,
|
||||
product=product,
|
||||
seller=seller,
|
||||
customer=Customer.get_or_create(self.user)[0],
|
||||
unit_price=item.product_unit_price,
|
||||
quantity=item.quantity,
|
||||
payment_method=payment_method,
|
||||
)
|
||||
customer = Customer.get_or_create(self.user)[0]
|
||||
return [
|
||||
Selling(
|
||||
label=item.label,
|
||||
counter=counter,
|
||||
club_id=item.product.club_id,
|
||||
product=item.product,
|
||||
seller=seller,
|
||||
customer=customer,
|
||||
unit_price=item.unit_price,
|
||||
quantity=item.quantity,
|
||||
payment_method=payment_method,
|
||||
)
|
||||
return sales
|
||||
for item in self.items.select_related("product")
|
||||
]
|
||||
|
||||
def get_e_transaction_data(self) -> list[tuple[str, Any]]:
|
||||
def get_e_transaction_data(self) -> list[tuple[str, str]]:
|
||||
user = self.user
|
||||
if not hasattr(user, "customer"):
|
||||
raise Customer.DoesNotExist
|
||||
@@ -201,7 +178,7 @@ class InvoiceQueryset(models.QuerySet):
|
||||
def annotate_total(self) -> Self:
|
||||
"""Annotate the queryset with the total amount of each invoice.
|
||||
|
||||
The total amount is the sum of (product_unit_price * quantity)
|
||||
The total amount is the sum of (unit_price * quantity)
|
||||
for all items related to the invoice.
|
||||
"""
|
||||
# aggregates within subqueries require a little bit of black magic,
|
||||
@@ -211,7 +188,7 @@ class InvoiceQueryset(models.QuerySet):
|
||||
total=Subquery(
|
||||
InvoiceItem.objects.filter(invoice_id=OuterRef("pk"))
|
||||
.values("invoice_id")
|
||||
.annotate(total=Sum(F("product_unit_price") * F("quantity")))
|
||||
.annotate(total=Sum(F("unit_price") * F("quantity")))
|
||||
.values("total")
|
||||
)
|
||||
)
|
||||
@@ -221,11 +198,7 @@ class Invoice(models.Model):
|
||||
"""Invoices are generated once the payment has been validated."""
|
||||
|
||||
user = models.ForeignKey(
|
||||
User,
|
||||
related_name="invoices",
|
||||
verbose_name=_("user"),
|
||||
blank=False,
|
||||
on_delete=models.CASCADE,
|
||||
User, related_name="invoices", verbose_name=_("user"), on_delete=models.CASCADE
|
||||
)
|
||||
date = models.DateTimeField(_("date"), auto_now=True)
|
||||
validated = models.BooleanField(_("validated"), default=False)
|
||||
@@ -246,53 +219,44 @@ class Invoice(models.Model):
|
||||
if self.validated:
|
||||
raise DataError(_("Invoice already validated"))
|
||||
customer, _created = Customer.get_or_create(user=self.user)
|
||||
eboutic = Counter.objects.filter(type="EBOUTIC").first()
|
||||
for i in self.items.all():
|
||||
if i.type_id == settings.SITH_COUNTER_PRODUCTTYPE_REFILLING:
|
||||
new = Refilling(
|
||||
counter=eboutic,
|
||||
customer=customer,
|
||||
operator=self.user,
|
||||
amount=i.product_unit_price * i.quantity,
|
||||
payment_method=Refilling.PaymentMethod.CARD,
|
||||
date=self.date,
|
||||
kwargs = {
|
||||
"counter": get_eboutic(),
|
||||
"customer": customer,
|
||||
"date": self.date,
|
||||
"payment_method": Selling.PaymentMethod.CARD,
|
||||
}
|
||||
for i in self.items.select_related("product"):
|
||||
if i.product.product_type_id == settings.SITH_COUNTER_PRODUCTTYPE_REFILLING:
|
||||
Refilling.objects.create(
|
||||
**kwargs, operator=self.user, amount=i.unit_price * i.quantity
|
||||
)
|
||||
new.save()
|
||||
else:
|
||||
product = Product.objects.filter(id=i.product_id).first()
|
||||
new = Selling(
|
||||
label=i.product_name,
|
||||
counter=eboutic,
|
||||
club=product.club,
|
||||
product=product,
|
||||
Selling.objects.create(
|
||||
**kwargs,
|
||||
label=i.label,
|
||||
club_id=i.product.club_id,
|
||||
product=i.product,
|
||||
seller=self.user,
|
||||
customer=customer,
|
||||
unit_price=i.product_unit_price,
|
||||
unit_price=i.unit_price,
|
||||
quantity=i.quantity,
|
||||
payment_method=Selling.PaymentMethod.CARD,
|
||||
date=self.date,
|
||||
)
|
||||
new.save()
|
||||
self.validated = True
|
||||
self.save()
|
||||
|
||||
|
||||
class AbstractBaseItem(models.Model):
|
||||
product_id = models.IntegerField(_("product id"))
|
||||
product_name = models.CharField(_("product name"), max_length=255)
|
||||
type_id = models.IntegerField(_("product type id"))
|
||||
product_unit_price = CurrencyField(_("unit price"))
|
||||
product = models.ForeignKey(
|
||||
Product, verbose_name=_("product"), on_delete=models.PROTECT
|
||||
)
|
||||
label = models.CharField(_("product name"), max_length=255)
|
||||
unit_price = CurrencyField(_("unit price"))
|
||||
quantity = models.PositiveIntegerField(_("quantity"))
|
||||
|
||||
class Meta:
|
||||
abstract = True
|
||||
|
||||
def __str__(self):
|
||||
return "Item: %s (%s) x%d" % (
|
||||
self.product_name,
|
||||
self.product_unit_price,
|
||||
self.quantity,
|
||||
)
|
||||
return "Item: %s (%s) x%d" % (self.product.name, self.unit_price, self.quantity)
|
||||
|
||||
|
||||
class BasketItem(AbstractBaseItem):
|
||||
@@ -301,21 +265,16 @@ class BasketItem(AbstractBaseItem):
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_product(cls, product: Product, quantity: int, basket: Basket):
|
||||
def from_price(cls, price: Price, quantity: int, basket: Basket):
|
||||
"""Create a BasketItem with the same characteristics as the
|
||||
product passed in parameters, with the specified quantity.
|
||||
|
||||
Warning:
|
||||
the basket field is not filled, so you must set
|
||||
it yourself before saving the model.
|
||||
product price passed in parameters, with the specified quantity.
|
||||
"""
|
||||
return cls(
|
||||
basket=basket,
|
||||
product_id=product.id,
|
||||
product_name=product.name,
|
||||
type_id=product.product_type_id,
|
||||
label=price.full_label,
|
||||
product_id=price.product_id,
|
||||
quantity=quantity,
|
||||
product_unit_price=product.selling_price,
|
||||
unit_price=price.amount,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
export {};
|
||||
|
||||
interface BasketItem {
|
||||
id: number;
|
||||
priceId: number;
|
||||
name: string;
|
||||
quantity: number;
|
||||
// biome-ignore lint/style/useNamingConvention: the python code is snake_case
|
||||
unit_price: number;
|
||||
unitPrice: number;
|
||||
}
|
||||
|
||||
// increment the key number if the data schema of the cached basket changes
|
||||
const BASKET_CACHE_KEY = "basket1";
|
||||
|
||||
document.addEventListener("alpine:init", () => {
|
||||
Alpine.data("basket", (lastPurchaseTime?: number) => ({
|
||||
basket: [] as BasketItem[],
|
||||
@@ -30,24 +32,24 @@ document.addEventListener("alpine:init", () => {
|
||||
// It's quite tricky to manually apply attributes to the management part
|
||||
// of a formset so we dynamically apply it here
|
||||
this.$refs.basketManagementForm
|
||||
.querySelector("#id_form-TOTAL_FORMS")
|
||||
.getElementById("#id_form-TOTAL_FORMS")
|
||||
.setAttribute(":value", "basket.length");
|
||||
},
|
||||
|
||||
loadBasket(): BasketItem[] {
|
||||
if (localStorage.basket === undefined) {
|
||||
if (localStorage.getItem(BASKET_CACHE_KEY) === null) {
|
||||
return [];
|
||||
}
|
||||
try {
|
||||
return JSON.parse(localStorage.basket);
|
||||
return JSON.parse(localStorage.getItem(BASKET_CACHE_KEY));
|
||||
} catch (_err) {
|
||||
return [];
|
||||
}
|
||||
},
|
||||
|
||||
saveBasket() {
|
||||
localStorage.basket = JSON.stringify(this.basket);
|
||||
localStorage.basketTimestamp = Date.now();
|
||||
localStorage.setItem(BASKET_CACHE_KEY, JSON.stringify(this.basket));
|
||||
localStorage.setItem("basketTimestamp", Date.now().toString());
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -56,7 +58,7 @@ document.addEventListener("alpine:init", () => {
|
||||
*/
|
||||
getTotal() {
|
||||
return this.basket.reduce(
|
||||
(acc: number, item: BasketItem) => acc + item.quantity * item.unit_price,
|
||||
(acc: number, item: BasketItem) => acc + item.quantity * item.unitPrice,
|
||||
0,
|
||||
);
|
||||
},
|
||||
@@ -74,7 +76,7 @@ document.addEventListener("alpine:init", () => {
|
||||
* @param itemId the id of the item to remove
|
||||
*/
|
||||
remove(itemId: number) {
|
||||
const index = this.basket.findIndex((e: BasketItem) => e.id === itemId);
|
||||
const index = this.basket.findIndex((e: BasketItem) => e.priceId === itemId);
|
||||
|
||||
if (index < 0) {
|
||||
return;
|
||||
@@ -83,7 +85,7 @@ document.addEventListener("alpine:init", () => {
|
||||
|
||||
if (this.basket[index].quantity === 0) {
|
||||
this.basket = this.basket.filter(
|
||||
(e: BasketItem) => e.id !== this.basket[index].id,
|
||||
(e: BasketItem) => e.priceId !== this.basket[index].id,
|
||||
);
|
||||
}
|
||||
},
|
||||
@@ -104,11 +106,10 @@ document.addEventListener("alpine:init", () => {
|
||||
*/
|
||||
createItem(id: number, name: string, price: number): BasketItem {
|
||||
const newItem = {
|
||||
id,
|
||||
priceId: id,
|
||||
name,
|
||||
quantity: 0,
|
||||
// biome-ignore lint/style/useNamingConvention: the python code is snake_case
|
||||
unit_price: price,
|
||||
unitPrice: price,
|
||||
} as BasketItem;
|
||||
|
||||
this.basket.push(newItem);
|
||||
@@ -125,7 +126,7 @@ document.addEventListener("alpine:init", () => {
|
||||
* @param price The unit price of the product
|
||||
*/
|
||||
addFromCatalog(id: number, name: string, price: number) {
|
||||
let item = this.basket.find((e: BasketItem) => e.id === id);
|
||||
let item = this.basket.find((e: BasketItem) => e.priceId === id);
|
||||
|
||||
// if the item is not in the basket, we create it
|
||||
// else we add + 1 to it
|
||||
|
||||
@@ -32,9 +32,9 @@
|
||||
<tbody>
|
||||
{% for item in basket.items.all() %}
|
||||
<tr>
|
||||
<td>{{ item.product_name }}</td>
|
||||
<td>{{ item.label }}</td>
|
||||
<td>{{ item.quantity }}</td>
|
||||
<td>{{ item.product_unit_price }} €</td>
|
||||
<td>{{ item.unit_price }} €</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
|
||||
@@ -41,7 +41,7 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
<ul class="item-list">
|
||||
{# Starting money #}
|
||||
{# Starting money #}
|
||||
<li>
|
||||
<span class="item-name">
|
||||
<strong>{% trans %}Current account amount: {% endtrans %}</strong>
|
||||
@@ -51,15 +51,15 @@
|
||||
</span>
|
||||
</li>
|
||||
|
||||
<template x-for="(item, index) in Object.values(basket)" :key="item.id">
|
||||
<template x-for="(item, index) in Object.values(basket)" :key="item.priceId">
|
||||
<li class="item-row" x-show="item.quantity > 0">
|
||||
<div class="item-quantity">
|
||||
<i class="fa fa-minus fa-xs" @click="remove(item.id)"></i>
|
||||
<i class="fa fa-minus fa-xs" @click="remove(item.priceId)"></i>
|
||||
<span x-text="item.quantity"></span>
|
||||
<i class="fa fa-plus" @click="add(item)"></i>
|
||||
</div>
|
||||
<span class="item-name" x-text="item.name"></span>
|
||||
<span class="item-price" x-text="(item.unit_price * item.quantity).toFixed(2) + ' €'"></span>
|
||||
<span class="item-price" x-text="(item.unitPrice * item.quantity).toFixed(2) + ' €'"></span>
|
||||
|
||||
<input
|
||||
type="hidden"
|
||||
@@ -71,16 +71,16 @@
|
||||
>
|
||||
<input
|
||||
type="hidden"
|
||||
:value="item.id"
|
||||
:id="`id_form-${index}-id`"
|
||||
:name="`form-${index}-id`"
|
||||
:value="item.priceId"
|
||||
:id="`id_form-${index}-price_id`"
|
||||
:name="`form-${index}-price_id`"
|
||||
required
|
||||
readonly
|
||||
>
|
||||
|
||||
</li>
|
||||
</template>
|
||||
{# Total price #}
|
||||
{# Total price #}
|
||||
<li style="margin-top: 20px">
|
||||
<span class="item-name"><strong>{% trans %}Basket amount: {% endtrans %}</strong></span>
|
||||
<span x-text="getTotal().toFixed(2) + ' €'" class="item-price"></span>
|
||||
@@ -116,45 +116,40 @@
|
||||
</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% for priority_groups in products|groupby('order') %}
|
||||
{% for category, items in priority_groups.list|groupby('category') %}
|
||||
{% if items|count > 0 %}
|
||||
<section>
|
||||
{# I would have wholeheartedly directly used the header element instead
|
||||
but it has already been made messy in core/style.scss #}
|
||||
<div class="category-header">
|
||||
<h3>{{ category }}</h3>
|
||||
{% if items[0].category_comment %}
|
||||
<p><i>{{ items[0].category_comment }}</i></p>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="product-group">
|
||||
{% for p in items %}
|
||||
<button
|
||||
id="{{ p.id }}"
|
||||
class="card product-button clickable shadow"
|
||||
:class="{selected: basket.some((i) => i.id === {{ p.id }})}"
|
||||
@click='addFromCatalog({{ p.id }}, {{ p.name|tojson }}, {{ p.selling_price }})'
|
||||
{% for prices in categories %}
|
||||
{% set category = prices[0].product.product_type %}
|
||||
<section>
|
||||
<div class="category-header">
|
||||
<h3>{{ category.name }}</h3>
|
||||
{% if category.comment %}
|
||||
<p><i>{{ category.comment }}</i></p>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="product-group">
|
||||
{% for price in prices %}
|
||||
<button
|
||||
id="{{ price.id }}"
|
||||
class="card product-button clickable shadow"
|
||||
:class="{selected: basket.some((i) => i.priceId === {{ price.id }})}"
|
||||
@click='addFromCatalog({{ price.id }}, {{ price.full_label|tojson }}, {{ price.amount }})'
|
||||
>
|
||||
{% if price.product.icon %}
|
||||
<img
|
||||
class="card-image"
|
||||
src="{{ price.product.icon.url }}"
|
||||
alt="image de {{ price.full_label }}"
|
||||
>
|
||||
{% if p.icon %}
|
||||
<img
|
||||
class="card-image"
|
||||
src="{{ p.icon.url }}"
|
||||
alt="image de {{ p.name }}"
|
||||
>
|
||||
{% else %}
|
||||
<i class="fa-regular fa-image fa-2x card-image"></i>
|
||||
{% endif %}
|
||||
<div class="card-content">
|
||||
<h4 class="card-title">{{ p.name }}</h4>
|
||||
<p>{{ p.selling_price }} €</p>
|
||||
</div>
|
||||
</button>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</section>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<i class="fa-regular fa-image fa-2x card-image"></i>
|
||||
{% endif %}
|
||||
<div class="card-content">
|
||||
<h4 class="card-title">{{ price.full_label }}</h4>
|
||||
<p>{{ price.amount }} €</p>
|
||||
</div>
|
||||
</button>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</section>
|
||||
{% else %}
|
||||
<p>{% trans %}There are no items available for sale{% endtrans %}</p>
|
||||
{% endfor %}
|
||||
|
||||
@@ -11,7 +11,12 @@ from pytest_django.asserts import assertRedirects
|
||||
|
||||
from core.baker_recipes import subscriber_user
|
||||
from core.models import Group, User
|
||||
from counter.baker_recipes import product_recipe, refill_recipe, sale_recipe
|
||||
from counter.baker_recipes import (
|
||||
price_recipe,
|
||||
product_recipe,
|
||||
refill_recipe,
|
||||
sale_recipe,
|
||||
)
|
||||
from counter.models import (
|
||||
Counter,
|
||||
Customer,
|
||||
@@ -147,29 +152,29 @@ class TestEboutic(TestCase):
|
||||
|
||||
product_type = baker.make(ProductType)
|
||||
|
||||
cls.snack = product_recipe.make(
|
||||
selling_price=1.5, special_selling_price=1, product_type=product_type
|
||||
cls.snack = price_recipe.make(
|
||||
amount=1.5, product=product_recipe.make(product_type=product_type)
|
||||
)
|
||||
cls.beer = product_recipe.make(
|
||||
limit_age=18,
|
||||
selling_price=2.5,
|
||||
special_selling_price=1,
|
||||
product_type=product_type,
|
||||
cls.beer = price_recipe.make(
|
||||
product=product_recipe.make(limit_age=18, product_type=product_type),
|
||||
amount=2.5,
|
||||
)
|
||||
cls.not_in_counter = product_recipe.make(
|
||||
selling_price=3.5, product_type=product_type
|
||||
cls.not_in_counter = price_recipe.make(
|
||||
product=product_recipe.make(product_type=product_type), amount=3.5
|
||||
)
|
||||
cls.cotiz = price_recipe.make(
|
||||
amount=10, product=product_recipe.make(product_type=product_type)
|
||||
)
|
||||
cls.cotiz = product_recipe.make(selling_price=10, product_type=product_type)
|
||||
|
||||
cls.group_public.products.add(cls.snack, cls.beer, cls.not_in_counter)
|
||||
cls.group_cotiz.products.add(cls.cotiz)
|
||||
cls.group_public.prices.add(cls.snack, cls.beer, cls.not_in_counter)
|
||||
cls.group_cotiz.prices.add(cls.cotiz)
|
||||
|
||||
cls.subscriber.groups.add(cls.group_cotiz, cls.group_public)
|
||||
cls.new_customer.groups.add(cls.group_public)
|
||||
cls.new_customer_adult.groups.add(cls.group_public)
|
||||
|
||||
cls.eboutic = get_eboutic()
|
||||
cls.eboutic.products.add(cls.cotiz, cls.beer, cls.snack)
|
||||
cls.eboutic.products.add(cls.cotiz.product, cls.beer.product, cls.snack.product)
|
||||
|
||||
@classmethod
|
||||
def set_age(cls, user: User, age: int):
|
||||
@@ -253,7 +258,7 @@ class TestEboutic(TestCase):
|
||||
self.submit_basket([BasketItem(self.snack.id, 2)]),
|
||||
reverse("eboutic:checkout", kwargs={"basket_id": 1}),
|
||||
)
|
||||
assert Basket.objects.get(id=1).total == self.snack.selling_price * 2
|
||||
assert Basket.objects.get(id=1).total == self.snack.amount * 2
|
||||
|
||||
self.client.force_login(self.new_customer_adult)
|
||||
assertRedirects(
|
||||
@@ -263,8 +268,7 @@ class TestEboutic(TestCase):
|
||||
reverse("eboutic:checkout", kwargs={"basket_id": 2}),
|
||||
)
|
||||
assert (
|
||||
Basket.objects.get(id=2).total
|
||||
== self.snack.selling_price * 2 + self.beer.selling_price
|
||||
Basket.objects.get(id=2).total == self.snack.amount * 2 + self.beer.amount
|
||||
)
|
||||
|
||||
self.client.force_login(self.subscriber)
|
||||
@@ -280,7 +284,5 @@ class TestEboutic(TestCase):
|
||||
)
|
||||
assert (
|
||||
Basket.objects.get(id=3).total
|
||||
== self.snack.selling_price * 2
|
||||
+ self.beer.selling_price
|
||||
+ self.cotiz.selling_price
|
||||
== self.snack.amount * 2 + self.beer.amount + self.cotiz.amount
|
||||
)
|
||||
|
||||
@@ -16,7 +16,7 @@ from model_bakery import baker
|
||||
from pytest_django.asserts import assertRedirects
|
||||
|
||||
from core.baker_recipes import old_subscriber_user, subscriber_user
|
||||
from counter.baker_recipes import product_recipe
|
||||
from counter.baker_recipes import price_recipe, product_recipe
|
||||
from counter.models import Product, ProductType, Selling
|
||||
from counter.tests.test_counter import force_refill_user
|
||||
from eboutic.models import Basket, BasketItem
|
||||
@@ -32,23 +32,22 @@ class TestPaymentBase(TestCase):
|
||||
cls.basket = baker.make(Basket, user=cls.customer)
|
||||
cls.refilling = product_recipe.make(
|
||||
product_type_id=settings.SITH_COUNTER_PRODUCTTYPE_REFILLING,
|
||||
selling_price=15,
|
||||
prices=[price_recipe.make(amount=15)],
|
||||
)
|
||||
|
||||
product_type = baker.make(ProductType)
|
||||
|
||||
cls.snack = product_recipe.make(
|
||||
selling_price=1.5, special_selling_price=1, product_type=product_type
|
||||
product_type=product_type, prices=[price_recipe.make(amount=1.5)]
|
||||
)
|
||||
cls.beer = product_recipe.make(
|
||||
limit_age=18,
|
||||
selling_price=2.5,
|
||||
special_selling_price=1,
|
||||
product_type=product_type,
|
||||
prices=[price_recipe.make(amount=2.5)],
|
||||
)
|
||||
|
||||
BasketItem.from_product(cls.snack, 1, cls.basket).save()
|
||||
BasketItem.from_product(cls.beer, 2, cls.basket).save()
|
||||
BasketItem.from_price(cls.snack.prices.first(), 1, cls.basket).save()
|
||||
BasketItem.from_price(cls.beer.prices.first(), 2, cls.basket).save()
|
||||
|
||||
|
||||
class TestPaymentSith(TestPaymentBase):
|
||||
@@ -116,13 +115,13 @@ class TestPaymentSith(TestPaymentBase):
|
||||
assert len(sellings) == 2
|
||||
assert sellings[0].payment_method == Selling.PaymentMethod.SITH_ACCOUNT
|
||||
assert sellings[0].quantity == 1
|
||||
assert sellings[0].unit_price == self.snack.selling_price
|
||||
assert sellings[0].unit_price == self.snack.prices.first().amount
|
||||
assert sellings[0].counter.type == "EBOUTIC"
|
||||
assert sellings[0].product == self.snack
|
||||
|
||||
assert sellings[1].payment_method == Selling.PaymentMethod.SITH_ACCOUNT
|
||||
assert sellings[1].quantity == 2
|
||||
assert sellings[1].unit_price == self.beer.selling_price
|
||||
assert sellings[1].unit_price == self.beer.prices.first().amount
|
||||
assert sellings[1].counter.type == "EBOUTIC"
|
||||
assert sellings[1].product == self.beer
|
||||
|
||||
@@ -146,7 +145,7 @@ class TestPaymentSith(TestPaymentBase):
|
||||
)
|
||||
|
||||
def test_refilling_in_basket(self):
|
||||
BasketItem.from_product(self.refilling, 1, self.basket).save()
|
||||
BasketItem.from_price(self.refilling.prices.first(), 1, self.basket).save()
|
||||
self.client.force_login(self.customer)
|
||||
force_refill_user(self.customer, self.basket.total + 1)
|
||||
self.customer.customer.refresh_from_db()
|
||||
@@ -191,8 +190,8 @@ class TestPaymentCard(TestPaymentBase):
|
||||
def test_buy_success(self):
|
||||
response = self.client.get(self.generate_bank_valid_answer(self.basket))
|
||||
assert response.status_code == 200
|
||||
assert response.content.decode("utf-8") == "Payment successful"
|
||||
assert Basket.objects.filter(id=self.basket.id).first() is None
|
||||
assert response.content.decode() == "Payment successful"
|
||||
assert not Basket.objects.filter(id=self.basket.id).exists()
|
||||
|
||||
sellings = Selling.objects.filter(customer=self.customer.customer).order_by(
|
||||
"quantity"
|
||||
@@ -200,13 +199,13 @@ class TestPaymentCard(TestPaymentBase):
|
||||
assert len(sellings) == 2
|
||||
assert sellings[0].payment_method == Selling.PaymentMethod.CARD
|
||||
assert sellings[0].quantity == 1
|
||||
assert sellings[0].unit_price == self.snack.selling_price
|
||||
assert sellings[0].unit_price == self.snack.prices.first().amount
|
||||
assert sellings[0].counter.type == "EBOUTIC"
|
||||
assert sellings[0].product == self.snack
|
||||
|
||||
assert sellings[1].payment_method == Selling.PaymentMethod.CARD
|
||||
assert sellings[1].quantity == 2
|
||||
assert sellings[1].unit_price == self.beer.selling_price
|
||||
assert sellings[1].unit_price == self.beer.prices.first().amount
|
||||
assert sellings[1].counter.type == "EBOUTIC"
|
||||
assert sellings[1].product == self.beer
|
||||
|
||||
@@ -216,7 +215,9 @@ class TestPaymentCard(TestPaymentBase):
|
||||
assert not customer.subscriptions.first().is_valid_now()
|
||||
|
||||
basket = baker.make(Basket, user=customer)
|
||||
BasketItem.from_product(Product.objects.get(code="2SCOTIZ"), 1, basket).save()
|
||||
BasketItem.from_price(
|
||||
Product.objects.get(code="2SCOTIZ").prices.first(), 1, basket
|
||||
).save()
|
||||
response = self.client.get(self.generate_bank_valid_answer(basket))
|
||||
assert response.status_code == 200
|
||||
|
||||
@@ -228,12 +229,13 @@ class TestPaymentCard(TestPaymentBase):
|
||||
assert subscription.location == "EBOUTIC"
|
||||
|
||||
def test_buy_refilling(self):
|
||||
BasketItem.from_product(self.refilling, 2, self.basket).save()
|
||||
price = self.refilling.prices.first()
|
||||
BasketItem.from_price(price, 2, self.basket).save()
|
||||
response = self.client.get(self.generate_bank_valid_answer(self.basket))
|
||||
assert response.status_code == 200
|
||||
|
||||
self.customer.customer.refresh_from_db()
|
||||
assert self.customer.customer.amount == self.refilling.selling_price * 2
|
||||
assert self.customer.customer.amount == price.amount * 2
|
||||
|
||||
def test_multiple_responses(self):
|
||||
bank_response = self.generate_bank_valid_answer(self.basket)
|
||||
@@ -253,17 +255,17 @@ class TestPaymentCard(TestPaymentBase):
|
||||
self.basket.delete()
|
||||
response = self.client.get(bank_response)
|
||||
assert response.status_code == 500
|
||||
assert (
|
||||
response.text
|
||||
== "Basket processing failed with error: SuspiciousOperation('Basket does not exists')"
|
||||
assert response.text == (
|
||||
"Basket processing failed with error: "
|
||||
"SuspiciousOperation('Basket does not exists')"
|
||||
)
|
||||
|
||||
def test_altered_basket(self):
|
||||
bank_response = self.generate_bank_valid_answer(self.basket)
|
||||
BasketItem.from_product(self.snack, 1, self.basket).save()
|
||||
BasketItem.from_price(self.snack.prices.first(), 1, self.basket).save()
|
||||
response = self.client.get(bank_response)
|
||||
assert response.status_code == 500
|
||||
assert (
|
||||
response.text == "Basket processing failed with error: "
|
||||
assert response.text == (
|
||||
"Basket processing failed with error: "
|
||||
"SuspiciousOperation('Basket total and amount do not match')"
|
||||
)
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
|
||||
from django.urls import path, register_converter
|
||||
|
||||
from core.converters import ResultConverter
|
||||
from eboutic.converters import PaymentResultConverter
|
||||
from eboutic.views import (
|
||||
BillingInfoFormFragment,
|
||||
EbouticCheckout,
|
||||
@@ -34,7 +34,7 @@ from eboutic.views import (
|
||||
payment_result,
|
||||
)
|
||||
|
||||
register_converter(ResultConverter, "res")
|
||||
register_converter(PaymentResultConverter, "res")
|
||||
|
||||
urlpatterns = [
|
||||
# Subscription views
|
||||
|
||||
@@ -17,6 +17,7 @@ from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import contextlib
|
||||
import itertools
|
||||
import json
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
@@ -28,9 +29,7 @@ from cryptography.hazmat.primitives.serialization import load_pem_public_key
|
||||
from django.conf import settings
|
||||
from django.contrib import messages
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.contrib.auth.mixins import (
|
||||
LoginRequiredMixin,
|
||||
)
|
||||
from django.contrib.auth.mixins import LoginRequiredMixin
|
||||
from django.contrib.messages.views import SuccessMessageMixin
|
||||
from django.core.exceptions import SuspiciousOperation, ValidationError
|
||||
from django.db import DatabaseError, transaction
|
||||
@@ -48,23 +47,16 @@ from django_countries.fields import Country
|
||||
|
||||
from core.auth.mixins import CanViewMixin
|
||||
from core.views.mixins import FragmentMixin, UseFragmentsMixin
|
||||
from counter.forms import BaseBasketForm, BasketProductForm, BillingInfoForm
|
||||
from counter.forms import BaseBasketForm, BasketItemForm, BillingInfoForm
|
||||
from counter.models import (
|
||||
BillingInfo,
|
||||
Customer,
|
||||
Product,
|
||||
Price,
|
||||
Refilling,
|
||||
Selling,
|
||||
get_eboutic,
|
||||
)
|
||||
from eboutic.models import (
|
||||
Basket,
|
||||
BasketItem,
|
||||
BillingInfoState,
|
||||
Invoice,
|
||||
InvoiceItem,
|
||||
get_eboutic_products,
|
||||
)
|
||||
from eboutic.models import Basket, BasketItem, BillingInfoState, Invoice, InvoiceItem
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from cryptography.hazmat.primitives.asymmetric.rsa import RSAPublicKey
|
||||
@@ -78,7 +70,7 @@ class BaseEbouticBasketForm(BaseBasketForm):
|
||||
|
||||
|
||||
EbouticBasketForm = forms.formset_factory(
|
||||
BasketProductForm, formset=BaseEbouticBasketForm, absolute_max=None, min_num=1
|
||||
BasketItemForm, formset=BaseEbouticBasketForm, absolute_max=None, min_num=1
|
||||
)
|
||||
|
||||
|
||||
@@ -88,7 +80,6 @@ class EbouticMainView(LoginRequiredMixin, FormView):
|
||||
The purchasable products are those of the eboutic which
|
||||
belong to a category of products of a product category
|
||||
(orphan products are inaccessible).
|
||||
|
||||
"""
|
||||
|
||||
template_name = "eboutic/eboutic_main.jinja"
|
||||
@@ -99,7 +90,7 @@ class EbouticMainView(LoginRequiredMixin, FormView):
|
||||
kwargs["form_kwargs"] = {
|
||||
"customer": self.customer,
|
||||
"counter": get_eboutic(),
|
||||
"allowed_products": {product.id: product for product in self.products},
|
||||
"allowed_prices": {price.id: price for price in self.prices},
|
||||
}
|
||||
return kwargs
|
||||
|
||||
@@ -110,19 +101,25 @@ class EbouticMainView(LoginRequiredMixin, FormView):
|
||||
|
||||
with transaction.atomic():
|
||||
self.basket = Basket.objects.create(user=self.request.user)
|
||||
for form in formset:
|
||||
BasketItem.from_product(
|
||||
form.product, form.cleaned_data["quantity"], self.basket
|
||||
).save()
|
||||
self.basket.save()
|
||||
BasketItem.objects.bulk_create(
|
||||
[
|
||||
BasketItem.from_price(
|
||||
form.price, form.cleaned_data["quantity"], self.basket
|
||||
)
|
||||
for form in formset
|
||||
]
|
||||
)
|
||||
return super().form_valid(formset)
|
||||
|
||||
def get_success_url(self):
|
||||
return reverse("eboutic:checkout", kwargs={"basket_id": self.basket.id})
|
||||
|
||||
@cached_property
|
||||
def products(self) -> list[Product]:
|
||||
return get_eboutic_products(self.request.user)
|
||||
def prices(self) -> list[Price]:
|
||||
return get_eboutic().get_prices_for(
|
||||
self.customer,
|
||||
order_by=["product__product_type__order", "product_id", "amount"],
|
||||
)
|
||||
|
||||
@cached_property
|
||||
def customer(self) -> Customer:
|
||||
@@ -130,7 +127,12 @@ class EbouticMainView(LoginRequiredMixin, FormView):
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
context["products"] = self.products
|
||||
context["categories"] = [
|
||||
list(i[1])
|
||||
for i in itertools.groupby(
|
||||
self.prices, key=lambda p: p.product.product_type_id
|
||||
)
|
||||
]
|
||||
context["customer_amount"] = self.request.user.account_balance
|
||||
|
||||
purchases = (
|
||||
@@ -267,11 +269,8 @@ class EbouticPayWithSith(CanViewMixin, SingleObjectMixin, View):
|
||||
def post(self, request, *args, **kwargs):
|
||||
basket = self.get_object()
|
||||
refilling = settings.SITH_COUNTER_PRODUCTTYPE_REFILLING
|
||||
if basket.items.filter(type_id=refilling).exists():
|
||||
messages.error(
|
||||
self.request,
|
||||
_("You can't buy a refilling with sith money"),
|
||||
)
|
||||
if basket.items.filter(product__product_type_id=refilling).exists():
|
||||
messages.error(self.request, _("You can't buy a refilling with sith money"))
|
||||
return redirect("eboutic:payment_result", "failure")
|
||||
|
||||
eboutic = get_eboutic()
|
||||
@@ -326,22 +325,23 @@ class EtransactionAutoAnswer(View):
|
||||
raise SuspiciousOperation(
|
||||
"Basket total and amount do not match"
|
||||
)
|
||||
i = Invoice()
|
||||
i.user = b.user
|
||||
i.payment_method = "CARD"
|
||||
i.save()
|
||||
for it in b.items.all():
|
||||
InvoiceItem(
|
||||
invoice=i,
|
||||
product_id=it.product_id,
|
||||
product_name=it.product_name,
|
||||
type_id=it.type_id,
|
||||
product_unit_price=it.product_unit_price,
|
||||
quantity=it.quantity,
|
||||
).save()
|
||||
i = Invoice.objects.create(user=b.user)
|
||||
InvoiceItem.objects.bulk_create(
|
||||
[
|
||||
InvoiceItem(
|
||||
invoice=i,
|
||||
product_id=item.product_id,
|
||||
label=item.label,
|
||||
unit_price=item.unit_price,
|
||||
quantity=item.quantity,
|
||||
)
|
||||
for item in b.items.all()
|
||||
]
|
||||
)
|
||||
i.validate()
|
||||
b.delete()
|
||||
except Exception as e:
|
||||
sentry_sdk.capture_exception(e)
|
||||
return HttpResponse(
|
||||
"Basket processing failed with error: " + repr(e), status=500
|
||||
)
|
||||
|
||||
@@ -35,10 +35,6 @@ msgstr ""
|
||||
"True si gardé à jour par le biais d'un fournisseur externe de domains "
|
||||
"toxics, False sinon"
|
||||
|
||||
#: api/admin.py
|
||||
msgid "Reset HMAC key"
|
||||
msgstr "Réinitialiser la clef HMAC"
|
||||
|
||||
#: api/admin.py
|
||||
#, python-format
|
||||
msgid ""
|
||||
@@ -52,23 +48,6 @@ msgstr ""
|
||||
msgid "Revoke selected API keys"
|
||||
msgstr "Révoquer les clefs d'API sélectionnées"
|
||||
|
||||
#: api/forms.py
|
||||
msgid "I have read and I accept the terms and conditions of use"
|
||||
msgstr "J'ai lu et j'accepte les conditions générales d'utilisation."
|
||||
|
||||
#: api/forms.py
|
||||
msgid "You must approve the terms and conditions of use."
|
||||
msgstr "Vous devez approuver les conditions générales d'utilisation."
|
||||
|
||||
#: api/forms.py
|
||||
msgid "You must confirm that this is your username."
|
||||
msgstr "Vous devez confirmer que c'est bien votre nom d'utilisateur."
|
||||
|
||||
#: api/forms.py
|
||||
#, python-format
|
||||
msgid "I confirm that %(username)s is my username on %(app)s"
|
||||
msgstr "Je confirme que %(username)s est mon nom d'utilisateur sur %(app)s"
|
||||
|
||||
#: api/models.py club/models.py com/models.py counter/models.py forum/models.py
|
||||
msgid "name"
|
||||
msgstr "nom"
|
||||
@@ -77,7 +56,7 @@ msgstr "nom"
|
||||
msgid "owner"
|
||||
msgstr "propriétaire"
|
||||
|
||||
#: api/models.py core/models.py
|
||||
#: api/models.py core/models.py counter/models.py
|
||||
msgid "groups"
|
||||
msgstr "groupes"
|
||||
|
||||
@@ -89,10 +68,6 @@ msgstr "permissions du client"
|
||||
msgid "Specific permissions for this api client."
|
||||
msgstr "Permissions spécifiques pour ce client d'API"
|
||||
|
||||
#: api/models.py
|
||||
msgid "HMAC Key"
|
||||
msgstr "Clef HMAC"
|
||||
|
||||
#: api/models.py
|
||||
msgid "api client"
|
||||
msgstr "client d'api"
|
||||
@@ -122,63 +97,6 @@ msgstr "clef d'api"
|
||||
msgid "api keys"
|
||||
msgstr "clefs d'api"
|
||||
|
||||
#: api/templates/api/third_party/auth.jinja
|
||||
msgid "Confidentiality"
|
||||
msgstr "Confidentialité"
|
||||
|
||||
#: api/templates/api/third_party/auth.jinja
|
||||
#, python-format
|
||||
msgid ""
|
||||
"By ticking this box and clicking on the send button, you acknowledge and "
|
||||
"agree to provide %(app)s with your first name, last name, nickname and any "
|
||||
"other information that was the third party app was explicitly authorized to "
|
||||
"fetch and that it must have acknowledged to you, in a complete and accurate "
|
||||
"manner."
|
||||
msgstr ""
|
||||
"En cochant cette case et en cliquant sur le bouton « Envoyer », vous "
|
||||
"reconnaissez et acceptez de fournir à %(app)s votre prénom, nom, pseudonyme "
|
||||
"et toute autre information que l'application tierce a été explicitement "
|
||||
"autorisée à récupérer et qu'elle doit vous avoir communiqué de manière "
|
||||
"complète et exacte."
|
||||
|
||||
#: api/templates/api/third_party/auth.jinja
|
||||
#, python-format
|
||||
msgid ""
|
||||
"The privacy policies of <a href=\"%(privacy_link)s\">%(app)s</a> and of <a "
|
||||
"href=\"%(sith_cgu_link)s\">the Students' Association</a> applies as soon as "
|
||||
"the form is submitted."
|
||||
msgstr ""
|
||||
"Les politiques de confidentialité de <a href=\"%(privacy_link)s\">%(app)s</a> et de <a "
|
||||
"href=\"%(sith_cgu_link)s\">l'Association des Etudiants</a> s'appliquent dès la soumission "
|
||||
"du formulaire."
|
||||
|
||||
#: api/templates/api/third_party/auth.jinja
|
||||
msgid "Confirmation of identity"
|
||||
msgstr "Confirmation d'identité"
|
||||
|
||||
#: api/views.py
|
||||
#, python-format
|
||||
msgid ""
|
||||
"You are going to link your AE account and your %(app)s account. Continue "
|
||||
"only if this page was opened from %(app)s."
|
||||
msgstr ""
|
||||
"Vous allez lier votre compte AE et votre compte %(app)s. Poursuivez "
|
||||
"uniquement si cette page a été ouverte depuis %(app)s."
|
||||
|
||||
#: api/views.py
|
||||
msgid "You have been successfully authenticated. You can now close this page."
|
||||
msgstr "Vous avez été authentifié avec succès. Vous pouvez maintenant fermer cette page."
|
||||
|
||||
#: api/views.py
|
||||
msgid ""
|
||||
"Your authentication on the AE website was successful, but an error happened "
|
||||
"during the interaction with the third-party application. Please contact the "
|
||||
"managers of the latter."
|
||||
msgstr ""
|
||||
"Votre authentification sur le site AE a fonctionné, mais une erreur est arrivée "
|
||||
"durant l'interaction avec l'application tierce. Veuillez contacter les responsables "
|
||||
"de cette dernière."
|
||||
|
||||
#: club/forms.py
|
||||
msgid "Users to add"
|
||||
msgstr "Utilisateurs à ajouter"
|
||||
@@ -3047,24 +2965,6 @@ msgstr ""
|
||||
"Décrivez le produit. Si c'est un click pour un évènement, donnez quelques "
|
||||
"détails dessus, comme la date (en incluant l'année)."
|
||||
|
||||
#: counter/forms.py
|
||||
#, python-format
|
||||
msgid ""
|
||||
"This product is a formula. Its price cannot be greater than the price of the "
|
||||
"products constituting it, which is %(price)s €"
|
||||
msgstr ""
|
||||
"Ce produit est une formule. Son prix ne peut pas être supérieur au prix des "
|
||||
"produits qui la constituent, soit %(price)s €."
|
||||
|
||||
#: counter/forms.py
|
||||
#, python-format
|
||||
msgid ""
|
||||
"This product is a formula. Its special price cannot be greater than the "
|
||||
"price of the products constituting it, which is %(price)s €"
|
||||
msgstr ""
|
||||
"Ce produit est une formule. Son prix spécial ne peut pas être supérieur au "
|
||||
"prix des produits qui la constituent, soit %(price)s €."
|
||||
|
||||
#: counter/forms.py
|
||||
msgid ""
|
||||
"The same product cannot be at the same time the result and a part of the "
|
||||
@@ -3073,19 +2973,13 @@ msgstr ""
|
||||
"Un même produit ne peut pas être à la fois le résultat et un élément de la "
|
||||
"formule."
|
||||
|
||||
#: counter/forms.py
|
||||
msgid ""
|
||||
"The result cannot be more expensive than the total of the other products."
|
||||
msgstr ""
|
||||
"Le résultat ne peut pas être plus cher que le total des autres produits."
|
||||
|
||||
#: counter/forms.py
|
||||
msgid "Refound this account"
|
||||
msgstr "Rembourser ce compte"
|
||||
|
||||
#: counter/forms.py
|
||||
msgid "The selected product isn't available for this user"
|
||||
msgstr "Le produit sélectionné n'est pas disponnible pour cet utilisateur"
|
||||
msgstr "Le produit sélectionné n'est pas disponible pour cet utilisateur"
|
||||
|
||||
#: counter/forms.py
|
||||
msgid "Submitted basket is invalid"
|
||||
@@ -3199,18 +3093,6 @@ msgstr "prix d'achat"
|
||||
msgid "Initial cost of purchasing the product"
|
||||
msgstr "Coût initial d'achat du produit"
|
||||
|
||||
#: counter/models.py
|
||||
msgid "selling price"
|
||||
msgstr "prix de vente"
|
||||
|
||||
#: counter/models.py
|
||||
msgid "special selling price"
|
||||
msgstr "prix de vente spécial"
|
||||
|
||||
#: counter/models.py
|
||||
msgid "Price for barmen during their permanence"
|
||||
msgstr "Prix pour les barmen durant leur permanence"
|
||||
|
||||
#: counter/models.py
|
||||
msgid "icon"
|
||||
msgstr "icône"
|
||||
@@ -3223,6 +3105,10 @@ msgstr "âge limite"
|
||||
msgid "tray price"
|
||||
msgstr "prix plateau"
|
||||
|
||||
#: counter/models.py
|
||||
msgid "Buy five, get the sixth free"
|
||||
msgstr "Pour cinq achetés, le sixième offert"
|
||||
|
||||
#: counter/models.py
|
||||
msgid "buying groups"
|
||||
msgstr "groupe d'achat"
|
||||
@@ -3235,10 +3121,35 @@ msgstr "archivé"
|
||||
msgid "updated at"
|
||||
msgstr "mis à jour le"
|
||||
|
||||
#: counter/models.py
|
||||
#: counter/models.py eboutic/models.py
|
||||
msgid "product"
|
||||
msgstr "produit"
|
||||
|
||||
#: counter/models.py
|
||||
msgid "always show"
|
||||
msgstr "toujours montrer"
|
||||
|
||||
#: counter/models.py
|
||||
msgid ""
|
||||
"If this option is enabled, people will see this price and be able to pay it, "
|
||||
"even if another cheaper price exists. Else it will visible only if it is the "
|
||||
"cheapest available price."
|
||||
msgstr ""
|
||||
"Si cette option est activée, les gens verront ce prix et pourront le payer, "
|
||||
"même si un autre moins cher existe. Dans le cas contraire, le prix sera "
|
||||
"visible uniquement s'il s'agit du prix disponible le plus faible."
|
||||
|
||||
#: counter/models.py
|
||||
msgid ""
|
||||
"A short label for easier differentiation if a user can see multiple prices."
|
||||
msgstr ""
|
||||
"Un court libellé pour faciliter la différentiation si un utilisateur peut "
|
||||
"voir plusieurs prix."
|
||||
|
||||
#: counter/models.py
|
||||
msgid "price"
|
||||
msgstr "prix"
|
||||
|
||||
#: counter/models.py
|
||||
msgid "products"
|
||||
msgstr "produits"
|
||||
@@ -3713,10 +3624,6 @@ msgstr ""
|
||||
msgid "New formula"
|
||||
msgstr "Nouvelle formule"
|
||||
|
||||
#: counter/templates/counter/formula_list.jinja
|
||||
msgid "instead of"
|
||||
msgstr "au lieu de"
|
||||
|
||||
#: counter/templates/counter/fragments/create_student_card.jinja
|
||||
msgid "No student card registered."
|
||||
msgstr "Aucune carte étudiante enregistrée."
|
||||
@@ -3839,6 +3746,10 @@ msgstr ""
|
||||
"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/product_form.jinja
|
||||
msgid "Remove price"
|
||||
msgstr "Retirer le prix"
|
||||
|
||||
#: counter/templates/counter/product_form.jinja
|
||||
msgid "Remove this action"
|
||||
msgstr "Retirer cette action"
|
||||
@@ -3860,6 +3771,14 @@ msgstr "Dernière mise à jour"
|
||||
msgid "Product creation"
|
||||
msgstr "Création de produit"
|
||||
|
||||
#: counter/templates/counter/product_form.jinja
|
||||
msgid "Prices"
|
||||
msgstr "Prix"
|
||||
|
||||
#: counter/templates/counter/product_form.jinja
|
||||
msgid "Add a price"
|
||||
msgstr "Ajouter un prix"
|
||||
|
||||
#: counter/templates/counter/product_form.jinja
|
||||
msgid "Automatic actions"
|
||||
msgstr "Actions automatiques"
|
||||
@@ -4107,18 +4026,10 @@ msgstr "validé"
|
||||
msgid "Invoice already validated"
|
||||
msgstr "Facture déjà validée"
|
||||
|
||||
#: eboutic/models.py
|
||||
msgid "product id"
|
||||
msgstr "ID du produit"
|
||||
|
||||
#: eboutic/models.py
|
||||
msgid "product name"
|
||||
msgstr "nom du produit"
|
||||
|
||||
#: eboutic/models.py
|
||||
msgid "product type id"
|
||||
msgstr "id du type du produit"
|
||||
|
||||
#: eboutic/models.py
|
||||
msgid "basket"
|
||||
msgstr "panier"
|
||||
|
||||
@@ -69,7 +69,6 @@ nav:
|
||||
- API:
|
||||
- Développement: tutorial/api/dev.md
|
||||
- Connexion à l'API: tutorial/api/connect.md
|
||||
- Liaison avec le compte AE: tutorial/api/account-link.md
|
||||
- Etransactions: tutorial/etransaction.md
|
||||
- How-to:
|
||||
- L'ORM de Django: howto/querysets.md
|
||||
@@ -92,8 +91,6 @@ nav:
|
||||
- reference/api/hashers.md
|
||||
- reference/api/models.md
|
||||
- reference/api/perms.md
|
||||
- reference/api/schemas.md
|
||||
- reference/api/views.md
|
||||
- club:
|
||||
- reference/club/models.md
|
||||
- reference/club/views.md
|
||||
|
||||
@@ -33,8 +33,6 @@ class TestMergeUser(TestCase):
|
||||
cls.club = baker.make(Club)
|
||||
cls.eboutic = Counter.objects.get(name="Eboutic")
|
||||
cls.barbar = Product.objects.get(code="BARB")
|
||||
cls.barbar.selling_price = 2
|
||||
cls.barbar.save()
|
||||
cls.root = User.objects.get(username="root")
|
||||
cls.to_keep = User.objects.create(
|
||||
username="to_keep", password="plop", email="u.1@utbm.fr"
|
||||
|
||||
@@ -405,8 +405,6 @@ SITH_FORUM_PAGE_LENGTH = 30
|
||||
SITH_SAS_ROOT_DIR_ID = env.int("SITH_SAS_ROOT_DIR_ID", default=4)
|
||||
SITH_SAS_IMAGES_PER_PAGE = 60
|
||||
|
||||
SITH_CGU_FILE_ID = env.int("SITH_CGU_FILE_ID", default=5)
|
||||
|
||||
SITH_PROFILE_DEPARTMENTS = [
|
||||
("TC", _("TC")),
|
||||
("IMSI", _("IMSI")),
|
||||
|
||||
@@ -34,7 +34,6 @@ urlpatterns = [
|
||||
path("", include(("core.urls", "core"), namespace="core")),
|
||||
path("sitemap.xml", cache_page(86400)(sitemap), {"sitemaps": sitemaps}),
|
||||
path("api/", api.urls),
|
||||
path("api-link/", include(("api.urls", "api-link"), namespace="api-link")),
|
||||
path("rootplace/", include(("rootplace.urls", "rootplace"), namespace="rootplace")),
|
||||
path(
|
||||
"subscription/",
|
||||
|
||||
Reference in New Issue
Block a user