mirror of
https://github.com/ae-utbm/sith.git
synced 2024-12-22 07:41:14 +00:00
Apply review comments
This commit is contained in:
parent
e9361697f7
commit
cde864fdc7
@ -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)
|
||||||
|
|
||||||
|
|
||||||
|
@ -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:
|
||||||
|
@ -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):
|
||||||
|
@ -1,74 +1,117 @@
|
|||||||
document.addEventListener("alpine:init", () => {
|
import { exportToHtml } from "#core:utils/globals";
|
||||||
Alpine.data("counter", () => ({
|
import { customerGetCustomer } from "#openapi";
|
||||||
// biome-ignore lint/correctness/noUndeclaredVariables: defined in counter_click.jinja
|
|
||||||
basket: sessionBasket,
|
|
||||||
errors: [],
|
|
||||||
|
|
||||||
sumBasket() {
|
interface CounterConfig {
|
||||||
if (!this.basket || Object.keys(this.basket).length === 0) {
|
csrfToken: string;
|
||||||
return 0;
|
clickApiUrl: string;
|
||||||
}
|
sessionBasket: Record<number, BasketItem>;
|
||||||
const total = Object.values(this.basket).reduce(
|
customerBalance: number;
|
||||||
(acc, cur) => acc + cur.qty * cur.price,
|
customerId: number;
|
||||||
0,
|
}
|
||||||
);
|
interface BasketItem {
|
||||||
return total / 100;
|
// biome-ignore lint/style/useNamingConvention: talking with python
|
||||||
},
|
bonus_qty: number;
|
||||||
|
price: number;
|
||||||
|
qty: number;
|
||||||
|
}
|
||||||
|
|
||||||
async handleCode(event) {
|
exportToHtml("loadCounter", (config: CounterConfig) => {
|
||||||
const code = $(event.target).find("#code_field").val().toUpperCase();
|
document.addEventListener("alpine:init", () => {
|
||||||
if (["FIN", "ANN"].includes(code)) {
|
Alpine.data("counter", () => ({
|
||||||
$(event.target).submit();
|
basket: config.sessionBasket,
|
||||||
} else {
|
errors: [],
|
||||||
await this.handleAction(event);
|
customerBalance: config.customerBalance,
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
async handleAction(event) {
|
sumBasket() {
|
||||||
const payload = $(event.target).serialize();
|
if (!this.basket || Object.keys(this.basket).length === 0) {
|
||||||
// biome-ignore lint/correctness/noUndeclaredVariables: defined in counter_click.jinja
|
return 0;
|
||||||
const request = new Request(clickApiUrl, {
|
}
|
||||||
method: "POST",
|
const total = Object.values(this.basket).reduce(
|
||||||
body: payload,
|
(acc: number, cur: BasketItem) => acc + cur.qty * cur.price,
|
||||||
headers: {
|
0,
|
||||||
// biome-ignore lint/style/useNamingConvention: this goes into http headers
|
) as number;
|
||||||
Accept: "application/json",
|
return total / 100;
|
||||||
// biome-ignore lint/correctness/noUndeclaredVariables: defined in counter_click.jinja
|
},
|
||||||
"X-CSRFToken": csrfToken,
|
|
||||||
},
|
async updateBalance() {
|
||||||
});
|
this.customerBalance = (
|
||||||
const response = await fetch(request);
|
await customerGetCustomer({
|
||||||
const json = await response.json();
|
path: {
|
||||||
this.basket = json.basket;
|
// biome-ignore lint/style/useNamingConvention: api is in snake_case
|
||||||
this.errors = json.errors;
|
customer_id: config.customerId,
|
||||||
$("form.code_form #code_field").val("").focus();
|
},
|
||||||
},
|
})
|
||||||
}));
|
).data.amount;
|
||||||
|
},
|
||||||
|
|
||||||
|
async handleCode(event: SubmitEvent) {
|
||||||
|
const code = (
|
||||||
|
$(event.target).find("#code_field").val() as string
|
||||||
|
).toUpperCase();
|
||||||
|
if (["FIN", "ANN"].includes(code)) {
|
||||||
|
$(event.target).submit();
|
||||||
|
} else {
|
||||||
|
await this.handleAction(event);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async handleAction(event: SubmitEvent) {
|
||||||
|
const payload = $(event.target).serialize();
|
||||||
|
const request = new Request(config.clickApiUrl, {
|
||||||
|
method: "POST",
|
||||||
|
body: payload,
|
||||||
|
headers: {
|
||||||
|
// biome-ignore lint/style/useNamingConvention: this goes into http headers
|
||||||
|
Accept: "application/json",
|
||||||
|
"X-CSRFToken": config.csrfToken,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const response = await fetch(request);
|
||||||
|
const json = await response.json();
|
||||||
|
this.basket = json.basket;
|
||||||
|
this.errors = json.errors;
|
||||||
|
$("form.code_form #code_field").val("").focus();
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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
105
counter/tests/test_api.py
Normal 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,
|
||||||
|
}
|
@ -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()
|
||||||
|
Loading…
Reference in New Issue
Block a user