Apply review comments

This commit is contained in:
Antoine Bartuccio 2024-12-15 21:33:43 +01:00
parent e9361697f7
commit cde864fdc7
6 changed files with 226 additions and 70 deletions

View File

@ -26,7 +26,7 @@ from counter.models import Counter, Customer, Product
from counter.schemas import ( from counter.schemas import (
CounterFilterSchema, CounterFilterSchema,
CounterSchema, CounterSchema,
CustomerBalance, CustomerSchema,
ProductSchema, ProductSchema,
SimplifiedCounterSchema, SimplifiedCounterSchema,
) )
@ -63,8 +63,13 @@ class CounterController(ControllerBase):
@api_controller("/customer") @api_controller("/customer")
class CustomerController(ControllerBase): class CustomerController(ControllerBase):
@route.get("/balance", response=CustomerBalance, permissions=[IsLoggedInCounter]) @route.get(
def get_balance(self, customer_id: int): "{customer_id}",
response=CustomerSchema,
permissions=[IsLoggedInCounter],
url_name="get_customer",
)
def get_customer(self, customer_id: int):
return self.get_object_or_exception(Customer, pk=customer_id) return self.get_object_or_exception(Customer, pk=customer_id)

View File

@ -650,6 +650,15 @@ class Counter(models.Model):
) )
)["total"] )["total"]
def customer_is_barman(self, customer: Customer | User) -> bool:
"""Check if current counter is a `bar` and that the customer is on the barmen_list
This is useful to compute special prices"""
if isinstance(customer, Customer):
customer: User = customer.user
return self.type == "BAR" and customer in self.barmen_list
class RefillingQuerySet(models.QuerySet): class RefillingQuerySet(models.QuerySet):
def annotate_total(self) -> Self: def annotate_total(self) -> Self:

View File

@ -16,10 +16,10 @@ class CounterSchema(ModelSchema):
fields = ["id", "name", "type", "club", "products"] fields = ["id", "name", "type", "club", "products"]
class CustomerBalance(ModelSchema): class CustomerSchema(ModelSchema):
class Meta: class Meta:
model = Customer model = Customer
fields = ["amount"] fields = ["user", "account_id", "amount", "recorded_products"]
class CounterFilterSchema(FilterSchema): class CounterFilterSchema(FilterSchema):

View File

@ -1,22 +1,53 @@
import { exportToHtml } from "#core:utils/globals";
import { customerGetCustomer } from "#openapi";
interface CounterConfig {
csrfToken: string;
clickApiUrl: string;
sessionBasket: Record<number, BasketItem>;
customerBalance: number;
customerId: number;
}
interface BasketItem {
// biome-ignore lint/style/useNamingConvention: talking with python
bonus_qty: number;
price: number;
qty: number;
}
exportToHtml("loadCounter", (config: CounterConfig) => {
document.addEventListener("alpine:init", () => { document.addEventListener("alpine:init", () => {
Alpine.data("counter", () => ({ Alpine.data("counter", () => ({
// biome-ignore lint/correctness/noUndeclaredVariables: defined in counter_click.jinja basket: config.sessionBasket,
basket: sessionBasket,
errors: [], errors: [],
customerBalance: config.customerBalance,
sumBasket() { sumBasket() {
if (!this.basket || Object.keys(this.basket).length === 0) { if (!this.basket || Object.keys(this.basket).length === 0) {
return 0; return 0;
} }
const total = Object.values(this.basket).reduce( const total = Object.values(this.basket).reduce(
(acc, cur) => acc + cur.qty * cur.price, (acc: number, cur: BasketItem) => acc + cur.qty * cur.price,
0, 0,
); ) as number;
return total / 100; return total / 100;
}, },
async handleCode(event) { async updateBalance() {
const code = $(event.target).find("#code_field").val().toUpperCase(); this.customerBalance = (
await customerGetCustomer({
path: {
// biome-ignore lint/style/useNamingConvention: api is in snake_case
customer_id: config.customerId,
},
})
).data.amount;
},
async handleCode(event: SubmitEvent) {
const code = (
$(event.target).find("#code_field").val() as string
).toUpperCase();
if (["FIN", "ANN"].includes(code)) { if (["FIN", "ANN"].includes(code)) {
$(event.target).submit(); $(event.target).submit();
} else { } else {
@ -24,17 +55,15 @@ document.addEventListener("alpine:init", () => {
} }
}, },
async handleAction(event) { async handleAction(event: SubmitEvent) {
const payload = $(event.target).serialize(); const payload = $(event.target).serialize();
// biome-ignore lint/correctness/noUndeclaredVariables: defined in counter_click.jinja const request = new Request(config.clickApiUrl, {
const request = new Request(clickApiUrl, {
method: "POST", method: "POST",
body: payload, body: payload,
headers: { headers: {
// biome-ignore lint/style/useNamingConvention: this goes into http headers // biome-ignore lint/style/useNamingConvention: this goes into http headers
Accept: "application/json", Accept: "application/json",
// biome-ignore lint/correctness/noUndeclaredVariables: defined in counter_click.jinja "X-CSRFToken": config.csrfToken,
"X-CSRFToken": csrfToken,
}, },
}); });
const response = await fetch(request); const response = await fetch(request);
@ -45,30 +74,44 @@ document.addEventListener("alpine:init", () => {
}, },
})); }));
}); });
});
interface Product {
value: string;
label: string;
tags: string;
}
declare global {
const productsAutocomplete: Product[];
}
$(() => { $(() => {
/* Autocompletion in the code field */ /* Autocompletion in the code field */
const codeField = $("#code_field"); // biome-ignore lint/suspicious/noExplicitAny: dealing with legacy jquery
const codeField: any = $("#code_field");
let quantity = ""; let quantity = "";
codeField.autocomplete({ codeField.autocomplete({
select: (event, ui) => { // biome-ignore lint/suspicious/noExplicitAny: dealing with legacy jquery
select: (event: any, ui: any) => {
event.preventDefault(); event.preventDefault();
codeField.val(quantity + ui.item.value); codeField.val(quantity + ui.item.value);
}, },
focus: (event, ui) => { // biome-ignore lint/suspicious/noExplicitAny: dealing with legacy jquery
focus: (event: any, ui: any) => {
event.preventDefault(); event.preventDefault();
codeField.val(quantity + ui.item.value); codeField.val(quantity + ui.item.value);
}, },
source: (request, response) => { // biome-ignore lint/suspicious/noExplicitAny: dealing with legacy jquery
source: (request: any, response: any) => {
// biome-ignore lint/performance/useTopLevelRegex: performance impact is minimal // biome-ignore lint/performance/useTopLevelRegex: performance impact is minimal
const res = /^(\d+x)?(.*)/i.exec(request.term); const res = /^(\d+x)?(.*)/i.exec(request.term);
quantity = res[1] || ""; quantity = res[1] || "";
const search = res[2]; const search = res[2];
const matcher = new RegExp($.ui.autocomplete.escapeRegex(search), "i"); // biome-ignore lint/suspicious/noExplicitAny: dealing with legacy jquery
const matcher = new RegExp(($ as any).ui.autocomplete.escapeRegex(search), "i");
response( response(
// biome-ignore lint/correctness/noUndeclaredVariables: defined in counter_click.jinja $.grep(productsAutocomplete, (value: Product) => {
$.grep(productsAutocomplete, (value) => {
return matcher.test(value.tags); return matcher.test(value.tags);
}), }),
); );
@ -76,11 +119,13 @@ $(() => {
}); });
/* Accordion UI between basket and refills */ /* Accordion UI between basket and refills */
$("#click_form").accordion({ // biome-ignore lint/suspicious/noExplicitAny: dealing with legacy jquery
($("#click_form") as any).accordion({
heightStyle: "content", heightStyle: "content",
activate: () => $(".focus").focus(), activate: () => $(".focus").focus(),
}); });
$("#products").tabs(); // biome-ignore lint/suspicious/noExplicitAny: dealing with legacy jquery
($("#products") as any).tabs();
codeField.focus(); codeField.focus();
}); });

105
counter/tests/test_api.py Normal file
View File

@ -0,0 +1,105 @@
import pytest
from django.contrib.auth.models import make_password
from django.test.client import Client
from django.urls import reverse
from model_bakery import baker
from core.baker_recipes import board_user, subscriber_user
from core.models import User
from counter.models import Counter
@pytest.fixture
def customer_user() -> User:
return subscriber_user.make()
@pytest.fixture
def counter_bar() -> Counter:
return baker.make(Counter, type="BAR")
@pytest.fixture
def barmen(counter_bar: Counter) -> User:
user = subscriber_user.make(password=make_password("plop"))
counter_bar.sellers.add(user)
return user
@pytest.fixture
def board_member() -> User:
return board_user.make()
@pytest.fixture
def root_user() -> User:
return baker.make(User, is_superuser=True)
@pytest.mark.django_db
@pytest.mark.parametrize(
("connected_user"),
[
None, # Anonymous user
"barmen",
"customer_user",
"board_member",
"root_user",
],
)
def test_get_customer_fail(
client: Client,
customer_user: User,
request: pytest.FixtureRequest,
connected_user: str | None,
):
if connected_user is not None:
client.force_login(request.getfixturevalue(connected_user))
assert (
client.get(
reverse("api:get_customer", kwargs={"customer_id": customer_user.id})
).status_code
== 403
)
@pytest.mark.django_db
def test_get_customer_from_bar_fail_wrong_referrer(
client: Client, customer_user: User, barmen: User, counter_bar: Counter
):
client.post(
reverse("counter:login", args=[counter_bar.pk]),
{"username": barmen.username, "password": "plop"},
)
assert (
client.get(
reverse("api:get_customer", kwargs={"customer_id": customer_user.id})
).status_code
== 403
)
@pytest.mark.django_db
def test_get_customer_from_bar_success(
client: Client, customer_user: User, barmen: User, counter_bar: Counter
):
client.post(
reverse("counter:login", args=[counter_bar.pk]),
{"username": barmen.username, "password": "plop"},
)
response = client.get(
reverse("api:get_customer", kwargs={"customer_id": customer_user.id}),
HTTP_REFERER=reverse(
"counter:click",
kwargs={"counter_id": counter_bar.id, "user_id": customer_user.id},
),
)
assert response.status_code == 200
assert response.json() == {
"user": customer_user.id,
"account_id": customer_user.customer.account_id,
"amount": f"{customer_user.customer.amount:.2f}",
"recorded_products": customer_user.customer.recorded_products,
}

View File

@ -137,7 +137,7 @@ class CounterClick(CounterTabsMixin, CanViewMixin, DetailView):
request.session["no_age"] = False request.session["no_age"] = False
if self.object.type != "BAR": if self.object.type != "BAR":
self.operator = request.user self.operator = request.user
elif self.customer_is_barman(): elif self.object.customer_is_barman(self.customer):
self.operator = self.customer.user self.operator = self.customer.user
else: else:
self.operator = self.object.get_random_barman() self.operator = self.object.get_random_barman()
@ -157,16 +157,12 @@ class CounterClick(CounterTabsMixin, CanViewMixin, DetailView):
context = self.get_context_data(object=self.object) context = self.get_context_data(object=self.object)
return self.render_to_response(context) return self.render_to_response(context)
def customer_is_barman(self) -> bool:
barmen = self.object.barmen_list
return self.object.type == "BAR" and self.customer.user in barmen
def get_product(self, pid): def get_product(self, pid):
return Product.objects.filter(pk=int(pid)).first() return Product.objects.filter(pk=int(pid)).first()
def get_price(self, pid): def get_price(self, pid):
p = self.get_product(pid) p = self.get_product(pid)
if self.customer_is_barman(): if self.object.customer_is_barman(self.customer):
price = p.special_selling_price price = p.special_selling_price
else: else:
price = p.selling_price price = p.selling_price
@ -331,7 +327,7 @@ class CounterClick(CounterTabsMixin, CanViewMixin, DetailView):
for pid, infos in request.session["basket"].items(): for pid, infos in request.session["basket"].items():
# This duplicates code for DB optimization (prevent to load many times the same object) # This duplicates code for DB optimization (prevent to load many times the same object)
p = Product.objects.filter(pk=pid).first() p = Product.objects.filter(pk=pid).first()
if self.customer_is_barman(): if self.object.customer_is_barman(self.customer):
uprice = p.special_selling_price uprice = p.special_selling_price
else: else:
uprice = p.selling_price uprice = p.selling_price
@ -385,7 +381,7 @@ class CounterClick(CounterTabsMixin, CanViewMixin, DetailView):
"""Add customer to the context.""" """Add customer to the context."""
kwargs = super().get_context_data(**kwargs) kwargs = super().get_context_data(**kwargs)
products = self.object.products.select_related("product_type") products = self.object.products.select_related("product_type")
if self.customer_is_barman(): if self.object.customer_is_barman(self.customer):
products = products.annotate(price=F("special_selling_price")) products = products.annotate(price=F("special_selling_price"))
else: else:
products = products.annotate(price=F("selling_price")) products = products.annotate(price=F("selling_price"))
@ -444,17 +440,13 @@ class RefillingCreateView(FormView):
if not self.counter.can_refill(): if not self.counter.can_refill():
raise PermissionDenied raise PermissionDenied
if self.customer_is_barman(): if self.counter.customer_is_barman(self.customer):
self.operator = self.customer.user self.operator = self.customer.user
else: else:
self.operator = self.counter.get_random_barman() self.operator = self.counter.get_random_barman()
return super().dispatch(request, *args, **kwargs) return super().dispatch(request, *args, **kwargs)
def customer_is_barman(self) -> bool:
barmen = self.counter.barmen_list
return self.counter.type == "BAR" and self.customer.user in barmen
def form_valid(self, form): def form_valid(self, form):
res = super().form_valid(form) res = super().form_valid(form)
form.clean() form.clean()