39 Commits
cgu ... price

Author SHA1 Message Date
imperosol
59d7fadf4f unify eboutic and regular counter price selection 2026-03-11 12:01:26 +01:00
imperosol
e188acc78b directly work on group ids
add tests
2026-03-11 12:01:26 +01:00
imperosol
654ba383b4 fix existing tests 2026-03-11 11:37:36 +01:00
imperosol
2ee0c091be add translations 2026-03-11 11:37:36 +01:00
imperosol
0f1660ad79 adapt formulas to new price system 2026-03-11 11:37:36 +01:00
imperosol
680dc44486 adapt products export to new price system 2026-03-11 11:37:36 +01:00
imperosol
5f7a9fc600 Price formset in the Product edit page 2026-03-11 11:37:36 +01:00
imperosol
5126dc2a82 use new price system in populate 2026-03-10 19:32:15 +01:00
imperosol
7322a0c1cb remove Product.selling_price and Product.special_selling_price 2026-03-10 19:32:15 +01:00
imperosol
c2dfbc8bec use new price system in counters 2026-03-10 19:32:15 +01:00
imperosol
a019707d4a use new price system in the eboutic 2026-03-10 19:32:15 +01:00
imperosol
85f1a0b9cb refactor InvoiceItem and BasketItem models 2026-03-10 19:32:15 +01:00
imperosol
0a3f8b8e6f Price model 2026-03-10 19:32:15 +01:00
thomas girod
1845a7cbcf Merge pull request #1312 from ae-utbm/dynamic-formset
Dynamic formset
2026-03-10 19:31:49 +01:00
imperosol
f17f17d8de use dynamic formset for product action formset 2026-03-10 19:26:30 +01:00
imperosol
7bb3d064ee add dynamic-formset-index.ts 2026-03-10 19:26:30 +01:00
thomas girod
296feb6e32 Merge pull request #1305 from ae-utbm/user-all-groups
User all groups
2026-03-10 19:08:24 +01:00
imperosol
30663d87a4 directly work on group ids 2026-03-09 19:36:15 +01:00
thomas girod
b5ff9b4c13 Merge pull request #1314 from ae-utbm/user-clubs
feat: API route to get user memberships
2026-03-09 19:06:30 +01:00
imperosol
e2f6671ad0 apply review comments 2026-03-09 18:59:41 +01:00
imperosol
9a67926a49 feat: API route to get user memberships 2026-03-09 18:11:23 +01:00
imperosol
09a98db786 refactor election views permission check 2026-03-09 16:04:19 +01:00
imperosol
84ed180c1e refactor sas moderation view permission 2026-03-09 16:04:19 +01:00
imperosol
52759764a1 feat: User.all_groups 2026-03-09 16:04:19 +01:00
Titouan
be1563f46f Merge pull request #1313 from ae-utbm/price_fix
modify price on discount
2026-03-08 15:37:26 +01:00
TitouanDor
5d3d44ec67 modify price on discount 2026-03-08 15:09:46 +01:00
thomas girod
25e19339ff Merge pull request #1310 from ae-utbm/product-input
exclude archived products from product autocompletion.
2026-03-07 16:40:41 +01:00
thomas girod
10c4a921db Merge pull request #1311 from ae-utbm/counter-products
Restrict products that non-admins can add to counter
2026-03-07 16:24:41 +01:00
imperosol
74bf462e90 restrict products that non-admins can add to counter 2026-03-07 16:08:14 +01:00
imperosol
8e4d367522 exclude archived products from product autocompletion.
Dans tous les contextes avec un champ Ajax sur les produits, on a besoin uniquement des produits non-archivés. C'est plus cohérent d'exclure les produits archivés de la recherche.
2026-03-06 18:40:21 +01:00
thomas girod
f713903589 Merge pull request #1265 from ae-utbm/product-formula
Product formula
2026-03-04 17:55:26 +01:00
imperosol
9506c8688f show more infos on the formulas list page 2026-02-17 22:08:01 +01:00
imperosol
f3f470ec6c make formula deletion page clearer 2026-02-17 22:08:01 +01:00
imperosol
ced524587f add tests 2026-02-17 22:08:01 +01:00
imperosol
5f01f973de add translations 2026-02-17 22:08:01 +01:00
imperosol
6a6a7e949f add checks on ProductForm 2026-02-17 22:08:01 +01:00
imperosol
4e73f103d8 automatically apply formulas on click 2026-02-17 22:08:01 +01:00
imperosol
b03346c733 product formulas management views 2026-02-17 22:08:01 +01:00
imperosol
7be1d1cc63 feat: ProductFormula model 2026-02-17 22:08:01 +01:00
59 changed files with 2214 additions and 1098 deletions

View File

@@ -6,9 +6,15 @@ from ninja_extra.pagination import PageNumberPaginationExtra
from ninja_extra.schemas import PaginatedResponseSchema from ninja_extra.schemas import PaginatedResponseSchema
from api.auth import ApiKeyAuth from api.auth import ApiKeyAuth
from api.permissions import CanAccessLookup, HasPerm from api.permissions import CanAccessLookup, CanView, HasPerm
from club.models import Club, Membership from club.models import Club, Membership
from club.schemas import ClubSchema, ClubSearchFilterSchema, SimpleClubSchema from club.schemas import (
ClubSchema,
ClubSearchFilterSchema,
SimpleClubSchema,
UserMembershipSchema,
)
from core.models import User
@api_controller("/club") @api_controller("/club")
@@ -38,3 +44,22 @@ class ClubController(ControllerBase):
return self.get_object_or_exception( return self.get_object_or_exception(
Club.objects.prefetch_related(prefetch), id=club_id Club.objects.prefetch_related(prefetch), id=club_id
) )
@api_controller("/user/{int:user_id}/club")
class UserClubController(ControllerBase):
@route.get(
"",
response=list[UserMembershipSchema],
auth=[ApiKeyAuth(), SessionAuth()],
permissions=[CanView],
url_name="fetch_user_clubs",
)
def fetch_user_clubs(self, user_id: int):
"""Get all the active memberships of the given user."""
user = self.get_object_or_exception(User, id=user_id)
return (
Membership.objects.ongoing()
.filter(user=user)
.select_related("club", "user")
)

View File

@@ -40,6 +40,8 @@ class ClubProfileSchema(ModelSchema):
class ClubMemberSchema(ModelSchema): class ClubMemberSchema(ModelSchema):
"""A schema to represent all memberships in a club."""
class Meta: class Meta:
model = Membership model = Membership
fields = ["start_date", "end_date", "role", "description"] fields = ["start_date", "end_date", "role", "description"]
@@ -53,3 +55,13 @@ class ClubSchema(ModelSchema):
fields = ["id", "name", "logo", "is_active", "short_description", "address"] fields = ["id", "name", "logo", "is_active", "short_description", "address"]
members: list[ClubMemberSchema] members: list[ClubMemberSchema]
class UserMembershipSchema(ModelSchema):
"""A schema to represent the active club memberships of a user."""
class Meta:
model = Membership
fields = ["id", "start_date", "role", "description"]
club: SimpleClubSchema

View File

@@ -0,0 +1,50 @@
from datetime import timedelta
from django.test import TestCase
from django.urls import reverse
from django.utils.timezone import localdate
from model_bakery import baker
from model_bakery.recipe import Recipe
from club.models import Club, Membership
from club.schemas import UserMembershipSchema
from core.baker_recipes import subscriber_user
from core.models import Page
class TestFetchClub(TestCase):
@classmethod
def setUpTestData(cls):
cls.user = subscriber_user.make()
pages = baker.make(Page, _quantity=3, _bulk_create=True)
clubs = baker.make(Club, page=iter(pages), _quantity=3, _bulk_create=True)
recipe = Recipe(
Membership, user=cls.user, start_date=localdate() - timedelta(days=2)
)
cls.members = Membership.objects.bulk_create(
[
recipe.prepare(club=clubs[0]),
recipe.prepare(club=clubs[1], end_date=localdate() - timedelta(days=1)),
recipe.prepare(club=clubs[1]),
]
)
def test_fetch_memberships(self):
self.client.force_login(subscriber_user.make())
res = self.client.get(
reverse("api:fetch_user_clubs", kwargs={"user_id": self.user.id})
)
assert res.status_code == 200
assert [UserMembershipSchema.model_validate(m) for m in res.json()] == [
UserMembershipSchema.from_orm(m) for m in (self.members[0], self.members[2])
]
def test_fetch_club_nb_queries(self):
self.client.force_login(subscriber_user.make())
with self.assertNumQueries(6):
# - 5 queries for authentication
# - 1 query for the actual data
res = self.client.get(
reverse("api:fetch_user_clubs", kwargs={"user_id": self.user.id})
)
assert res.status_code == 200

View File

@@ -123,7 +123,7 @@ class GroupController(ControllerBase):
) )
@paginate(PageNumberPaginationExtra, page_size=50) @paginate(PageNumberPaginationExtra, page_size=50)
def search_group(self, search: Annotated[str, MinLen(1)]): 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)] DepthValue = Annotated[int, Ge(0), Le(10)]

View File

@@ -307,6 +307,7 @@ class PermissionOrClubBoardRequiredMixin(PermissionRequiredMixin):
return False return False
if super().has_permission(): if super().has_permission():
return True return True
return self.club is not None and any( return (
g.id == self.club.board_group_id for g in self.request.user.cached_groups self.club is not None
and self.club.board_group_id in self.request.user.all_groups
) )

View File

@@ -41,7 +41,14 @@ from com.ics_calendar import IcsCalendar
from com.models import News, NewsDate, Sith, Weekmail from com.models import News, NewsDate, Sith, Weekmail
from core.models import BanGroup, Group, Page, PageRev, SithFile, User from core.models import BanGroup, Group, Page, PageRev, SithFile, User
from core.utils import resize_image 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 election.models import Candidature, Election, ElectionList, Role
from forum.models import Forum from forum.models import Forum
from pedagogy.models import UE from pedagogy.models import UE
@@ -368,125 +375,15 @@ class Command(BaseCommand):
end_date=localdate() - timedelta(days=100), end_date=localdate() - timedelta(days=100),
) )
p = ProductType.objects.create(name="Bières bouteilles") self._create_products(groups, main_club, refound)
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)
Counter.objects.create(name="Carte AE", club=refound, type="OFFICE") 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 # Add barman to counter
Counter.sellers.through.objects.bulk_create( Counter.sellers.through.objects.bulk_create(
[ [
Counter.sellers.through(counter_id=2, user=krophil), Counter.sellers.through(counter_id=1, user=skia), # MDE
Counter.sellers.through(counter=mde, user=skia), Counter.sellers.through(counter_id=2, user=krophil), # Foyer
] ]
) )
@@ -742,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): def _create_profile_pict(self, user: User):
path = self.SAS_FIXTURE_PATH / "Family" / f"{user.username}.jpg" path = self.SAS_FIXTURE_PATH / "Family" / f"{user.username}.jpg"
file = resize_image(Image.open(path), 400, "WEBP") file = resize_image(Image.open(path), 400, "WEBP")

View File

@@ -17,6 +17,7 @@ from counter.models import (
Counter, Counter,
Customer, Customer,
Permanency, Permanency,
Price,
Product, Product,
ProductType, ProductType,
Refilling, Refilling,
@@ -278,6 +279,7 @@ class Command(BaseCommand):
# 2/3 of the products are owned by AE # 2/3 of the products are owned by AE
clubs = [ae, ae, ae, ae, ae, ae, *other_clubs] clubs = [ae, ae, ae, ae, ae, ae, *other_clubs]
products = [] products = []
prices = []
buying_groups = [] buying_groups = []
selling_places = [] selling_places = []
for _ in range(200): for _ in range(200):
@@ -288,25 +290,28 @@ class Command(BaseCommand):
product_type=random.choice(categories), product_type=random.choice(categories),
code="".join(self.faker.random_letters(length=random.randint(4, 8))), code="".join(self.faker.random_letters(length=random.randint(4, 8))),
purchase_price=price, purchase_price=price,
selling_price=price,
special_selling_price=price - min(0.5, price),
club=random.choice(clubs), club=random.choice(clubs),
limit_age=0 if random.random() > 0.2 else 18, limit_age=0 if random.random() > 0.2 else 18,
archived=bool(random.random() > 0.7), archived=self.faker.boolean(60),
) )
products.append(product) products.append(product)
# there will be products without buying groups for i in range(random.randint(0, 3)):
# but there are also such products in the real database product_price = Price(
buying_groups.extend( amount=price, product=product, is_always_shown=self.faker.boolean()
Product.buying_groups.through(product=product, group=group) )
for group in random.sample(groups, k=random.randint(0, 3)) # 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( selling_places.extend(
Counter.products.through(counter=counter, product=product) Counter.products.through(counter=counter, product=product)
for counter in random.sample(counters, random.randint(0, 4)) for counter in random.sample(counters, random.randint(0, 4))
) )
Product.objects.bulk_create(products) 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) Counter.products.through.objects.bulk_create(selling_places)
def create_sales(self, sellers: list[User]): def create_sales(self, sellers: list[User]):
@@ -320,7 +325,7 @@ class Command(BaseCommand):
) )
) )
) )
products = list(Product.objects.all()) prices = list(Price.objects.select_related("product").all())
counters = list( counters = list(
Counter.objects.filter(name__in=["Foyer", "MDE", "La Gommette"]) Counter.objects.filter(name__in=["Foyer", "MDE", "La Gommette"])
) )
@@ -330,14 +335,14 @@ class Command(BaseCommand):
# the longer the customer has existed, the higher the mean of nb_products # the longer the customer has existed, the higher the mean of nb_products
mu = 5 + (now().year - customer.since.year) * 2 mu = 5 + (now().year - customer.since.year) * 2
nb_sales = max(0, int(random.normalvariate(mu=mu, sigma=mu * 5))) 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) favoured_counter = random.choice(counters)
this_customer_sales = [] this_customer_sales = []
for _ in range(nb_sales): for _ in range(nb_sales):
product = ( price = (
random.choice(favoured_products) random.choice(favoured_prices)
if random.random() > 0.7 if random.random() > 0.7
else random.choice(products) else random.choice(prices)
) )
counter = ( counter = (
favoured_counter favoured_counter
@@ -346,11 +351,11 @@ class Command(BaseCommand):
) )
this_customer_sales.append( this_customer_sales.append(
Selling( Selling(
product=product, product=price.product,
counter=counter, counter=counter,
club_id=product.club_id, club_id=price.product.club_id,
quantity=random.randint(1, 5), quantity=random.randint(1, 5),
unit_price=product.selling_price, unit_price=price.amount,
seller=random.choice(sellers), seller=random.choice(sellers),
customer=customer, customer=customer,
date=make_aware( date=make_aware(

View File

@@ -356,23 +356,27 @@ class User(AbstractUser):
) )
if group_id is None: if group_id is None:
return False return False
if group_id == settings.SITH_GROUP_SUBSCRIBERS_ID: return group_id in self.all_groups
return self.is_subscribed
if group_id == settings.SITH_GROUP_ROOT_ID:
return self.is_root
return any(g.id == group_id for g in self.cached_groups)
@cached_property @cached_property
def cached_groups(self) -> list[Group]: def all_groups(self) -> dict[int, Group]:
"""Get the list of groups this user is in.""" """Get the list of groups this user is in."""
return list(self.groups.all()) additional_groups = []
if self.is_subscribed:
additional_groups.append(settings.SITH_GROUP_SUBSCRIBERS_ID)
if self.is_superuser:
additional_groups.append(settings.SITH_GROUP_ROOT_ID)
qs = self.groups.all()
if additional_groups:
# This is somewhat counter-intuitive, but this query runs way faster with
# a UNION rather than a OR (in average, 0.25ms vs 14ms).
# For the why, cf. https://dba.stackexchange.com/questions/293836/why-is-an-or-statement-slower-than-union
qs = qs.union(Group.objects.filter(id__in=additional_groups))
return {g.id: g for g in qs}
@cached_property @cached_property
def is_root(self) -> bool: def is_root(self) -> bool:
if self.is_superuser: return self.is_superuser or settings.SITH_GROUP_ROOT_ID in self.all_groups
return True
root_id = settings.SITH_GROUP_ROOT_ID
return any(g.id == root_id for g in self.cached_groups)
@cached_property @cached_property
def is_board_member(self) -> bool: def is_board_member(self) -> bool:
@@ -1099,10 +1103,7 @@ class PageQuerySet(models.QuerySet):
return self.filter(view_groups=settings.SITH_GROUP_PUBLIC_ID) return self.filter(view_groups=settings.SITH_GROUP_PUBLIC_ID)
if user.has_perm("core.view_page"): if user.has_perm("core.view_page"):
return self.all() return self.all()
groups_ids = [g.id for g in user.cached_groups] return self.filter(view_groups__in=user.all_groups)
if user.is_subscribed:
groups_ids.append(settings.SITH_GROUP_SUBSCRIBERS_ID)
return self.filter(view_groups__in=groups_ids)
# This function prevents generating migration upon settings change # This function prevents generating migration upon settings change
@@ -1376,7 +1377,7 @@ class PageRev(models.Model):
return self.page.can_be_edited_by(user) return self.page.can_be_edited_by(user)
def is_owned_by(self, user: User) -> bool: def is_owned_by(self, user: User) -> bool:
return any(g.id == self.page.owner_group_id for g in user.cached_groups) return self.page.owner_group_id in user.all_groups
def similarity_ratio(self, text: str) -> float: def similarity_ratio(self, text: str) -> float:
"""Similarity ratio between this revision's content and the given text. """Similarity ratio between this revision's content and the given text.

View File

@@ -0,0 +1,77 @@
interface Config {
/**
* The prefix of the formset, in case it has been changed.
* See https://docs.djangoproject.com/fr/stable/topics/forms/formsets/#customizing-a-formset-s-prefix
*/
prefix?: string;
}
// biome-ignore lint/style/useNamingConvention: It's the DOM API naming
type HTMLFormInputElement = HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement;
document.addEventListener("alpine:init", () => {
/**
* Alpine data element to allow the dynamic addition of forms to a formset.
*
* To use this, you need :
* - an HTML element containing the existing forms, noted by `x-ref="formContainer"`
* - a template containing the empty form
* (that you can obtain jinja-side with `{{ formset.empty_form }}`),
* noted by `x-ref="formTemplate"`
* - a button with `@click="addForm"`
* - you may also have one or more buttons with `@click="removeForm(element)"`,
* where `element` is the HTML element containing the form.
*
* For an example of how this is used, you can have a look to
* `counter/templates/counter/product_form.jinja`
*/
Alpine.data("dynamicFormSet", (config?: Config) => ({
init() {
this.formContainer = this.$refs.formContainer as HTMLElement;
this.nbForms = this.formContainer.children.length as number;
this.template = this.$refs.formTemplate as HTMLTemplateElement;
const prefix = config?.prefix ?? "form";
this.$root
.querySelector(`#id_${prefix}-TOTAL_FORMS`)
.setAttribute(":value", "nbForms");
},
addForm() {
this.formContainer.appendChild(document.importNode(this.template.content, true));
const newForm = this.formContainer.lastElementChild;
const inputs: NodeListOf<HTMLFormInputElement> = newForm.querySelectorAll(
"input, select, textarea",
);
for (const el of inputs) {
el.name = el.name.replace("__prefix__", this.nbForms.toString());
el.id = el.id.replace("__prefix__", this.nbForms.toString());
}
const labels: NodeListOf<HTMLLabelElement> = newForm.querySelectorAll("label");
for (const el of labels) {
el.htmlFor = el.htmlFor.replace("__prefix__", this.nbForms.toString());
}
inputs[0].focus();
this.nbForms += 1;
},
removeForm(container: HTMLDivElement) {
container.remove();
this.nbForms -= 1;
// adjust the id of remaining forms
for (let i = 0; i < this.nbForms; i++) {
const form: HTMLDivElement = this.formContainer.children[i];
const inputs: NodeListOf<HTMLFormInputElement> = form.querySelectorAll(
"input, select, textarea",
);
for (const el of inputs) {
el.name = el.name.replace(/\d+/, i.toString());
el.id = el.id.replace(/\d+/, i.toString());
}
const labels: NodeListOf<HTMLLabelElement> = form.querySelectorAll("label");
for (const el of labels) {
el.htmlFor = el.htmlFor.replace(/\d+/, i.toString());
}
}
},
}));
});

View File

@@ -35,8 +35,8 @@
<noscript><link rel="stylesheet" href="{{ static('bundled/fontawesome-index.css') }}"></noscript> <noscript><link rel="stylesheet" href="{{ static('bundled/fontawesome-index.css') }}"></noscript>
<script src="{{ url('javascript-catalog') }}"></script> <script src="{{ url('javascript-catalog') }}"></script>
<script type="module" src={{ static("bundled/core/navbar-index.ts") }}></script> <script type="module" src="{{ static("bundled/core/navbar-index.ts") }}"></script>
<script type="module" src={{ static("bundled/core/components/include-index.ts") }}></script> <script type="module" src="{{ static("bundled/core/components/include-index.ts") }}"></script>
<script type="module" src="{{ static('bundled/alpine-index.js') }}"></script> <script type="module" src="{{ static('bundled/alpine-index.js') }}"></script>
<script type="module" src="{{ static('bundled/htmx-index.js') }}"></script> <script type="module" src="{{ static('bundled/htmx-index.js') }}"></script>
<script type="module" src="{{ static('bundled/country-flags-index.ts') }}"></script> <script type="module" src="{{ static('bundled/country-flags-index.ts') }}"></script>

View File

@@ -21,6 +21,8 @@
<h2>{% trans %}Delete confirmation{% endtrans %}</h2> <h2>{% trans %}Delete confirmation{% endtrans %}</h2>
<form action="" method="post">{% csrf_token %} <form action="" method="post">{% csrf_token %}
<p>{% trans name=object_name %}Are you sure you want to delete "{{ name }}"?{% endtrans %}</p> <p>{% trans name=object_name %}Are you sure you want to delete "{{ name }}"?{% endtrans %}</p>
{% if help_text %}<p><em>{{ help_text }}</em></p>{% endif %}
<br/>
<input type="submit" value="{% trans %}Confirm{% endtrans %}" /> <input type="submit" value="{% trans %}Confirm{% endtrans %}" />
</form> </form>
<form method="GET" action="javascript:history.back();"> <form method="GET" action="javascript:history.back();">

View File

@@ -418,16 +418,16 @@ class TestUserIsInGroup(TestCase):
group_in = baker.make(Group) group_in = baker.make(Group)
self.public_user.groups.add(group_in) self.public_user.groups.add(group_in)
# clear the cached property `User.cached_groups` # clear the cached property `User.all_groups`
self.public_user.__dict__.pop("cached_groups", None) self.public_user.__dict__.pop("all_groups", None)
# Test when the user is in the group # Test when the user is in the group
with self.assertNumQueries(1): with self.assertNumQueries(2):
self.public_user.is_in_group(pk=group_in.id) self.public_user.is_in_group(pk=group_in.id)
with self.assertNumQueries(0): with self.assertNumQueries(0):
self.public_user.is_in_group(pk=group_in.id) self.public_user.is_in_group(pk=group_in.id)
group_not_in = baker.make(Group) group_not_in = baker.make(Group)
self.public_user.__dict__.pop("cached_groups", None) self.public_user.__dict__.pop("all_groups", None)
# Test when the user is not in the group # Test when the user is not in the group
with self.assertNumQueries(1): with self.assertNumQueries(1):
self.public_user.is_in_group(pk=group_not_in.id) self.public_user.is_in_group(pk=group_not_in.id)

View File

@@ -213,9 +213,9 @@ def test_user_invoice_with_multiple_items():
"""Test that annotate_total() works when invoices contain multiple items.""" """Test that annotate_total() works when invoices contain multiple items."""
user: User = subscriber_user.make() user: User = subscriber_user.make()
item_recipe = Recipe(InvoiceItem, invoice=foreign_key(Recipe(Invoice, user=user))) 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=3, quantity=1, unit_price=5)
item_recipe.make(_quantity=1, quantity=1, product_unit_price=5) item_recipe.make(_quantity=1, quantity=1, unit_price=5)
item_recipe.make(_quantity=2, quantity=1, product_unit_price=iter([5, 8])) item_recipe.make(_quantity=2, quantity=1, unit_price=iter([5, 8]))
res = list( res = list(
Invoice.objects.filter(user=user) Invoice.objects.filter(user=user)
.annotate_total() .annotate_total()

View File

@@ -24,6 +24,7 @@ from counter.models import (
Eticket, Eticket,
InvoiceCall, InvoiceCall,
Permanency, Permanency,
Price,
Product, Product,
ProductType, ProductType,
Refilling, Refilling,
@@ -32,19 +33,24 @@ from counter.models import (
) )
class PriceInline(admin.TabularInline):
model = Price
autocomplete_fields = ("groups",)
@admin.register(Product) @admin.register(Product)
class ProductAdmin(SearchModelAdmin): class ProductAdmin(SearchModelAdmin):
list_display = ( list_display = (
"name", "name",
"code", "code",
"product_type", "product_type",
"selling_price",
"archived", "archived",
"created_at", "created_at",
"updated_at", "updated_at",
) )
list_select_related = ("product_type",) list_select_related = ("product_type",)
search_fields = ("name", "code") search_fields = ("name", "code")
inlines = [PriceInline]
@admin.register(ReturnableProduct) @admin.register(ReturnableProduct)

View File

@@ -101,13 +101,9 @@ class ProductController(ControllerBase):
"""Get the detailed information about the products.""" """Get the detailed information about the products."""
return filters.filter( return filters.filter(
Product.objects.select_related("club") Product.objects.select_related("club")
.prefetch_related("buying_groups") .prefetch_related("prices", "prices__groups")
.select_related("product_type") .select_related("product_type")
.order_by( .order_by(F("product_type__order").asc(nulls_last=True), "name")
F("product_type__order").asc(nulls_last=True),
"product_type",
"name",
)
) )

View File

@@ -2,10 +2,11 @@ from model_bakery.recipe import Recipe, foreign_key
from club.models import Club from club.models import Club
from core.models import User 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) counter_recipe = Recipe(Counter)
product_recipe = Recipe(Product, club=foreign_key(Recipe(Club))) product_recipe = Recipe(Product, club=foreign_key(Recipe(Club)))
price_recipe = Recipe(Price, product=foreign_key(product_recipe))
sale_recipe = Recipe( sale_recipe = Recipe(
Selling, Selling,
product=foreign_key(product_recipe), product=foreign_key(product_recipe),

View File

@@ -1,6 +1,7 @@
import json import json
import math import math
import uuid import uuid
from collections import defaultdict
from datetime import date, datetime, timezone from datetime import date, datetime, timezone
from dateutil.relativedelta import relativedelta from dateutil.relativedelta import relativedelta
@@ -23,6 +24,7 @@ from core.views.forms import (
) )
from core.views.widgets.ajax_select import ( from core.views.widgets.ajax_select import (
AutoCompleteSelect, AutoCompleteSelect,
AutoCompleteSelectMultiple,
AutoCompleteSelectMultipleGroup, AutoCompleteSelectMultipleGroup,
AutoCompleteSelectMultipleUser, AutoCompleteSelectMultipleUser,
AutoCompleteSelectUser, AutoCompleteSelectUser,
@@ -33,7 +35,9 @@ from counter.models import (
Customer, Customer,
Eticket, Eticket,
InvoiceCall, InvoiceCall,
Price,
Product, Product,
ProductFormula,
Refilling, Refilling,
ReturnableProduct, ReturnableProduct,
ScheduledProductAction, ScheduledProductAction,
@@ -168,11 +172,21 @@ class CounterEditForm(forms.ModelForm):
class Meta: class Meta:
model = Counter model = Counter
fields = ["sellers", "products"] fields = ["sellers", "products"]
widgets = {"sellers": AutoCompleteSelectMultipleUser}
widgets = { def __init__(self, *args, user: User, instance: Counter, **kwargs):
"sellers": AutoCompleteSelectMultipleUser, super().__init__(*args, instance=instance, **kwargs)
"products": AutoCompleteSelectMultipleProduct, if user.has_perm("counter.change_counter"):
} self.fields["products"].widget = AutoCompleteSelectMultipleProduct()
else:
self.fields["products"].widget = AutoCompleteSelectMultiple()
self.fields["products"].queryset = Product.objects.filter(
Q(club_id=instance.club_id) | Q(counters=instance), archived=False
).distinct()
self.fields["products"].help_text = _(
"If you want to add a product that is not owned by "
"your club to this counter, you should ask an admin."
)
class ScheduledProductActionForm(forms.ModelForm): class ScheduledProductActionForm(forms.ModelForm):
@@ -278,7 +292,22 @@ ScheduledProductActionFormSet = forms.modelformset_factory(
absolute_max=None, absolute_max=None,
can_delete=True, can_delete=True,
can_delete_extra=False, can_delete_extra=False,
extra=2, 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,
) )
@@ -293,10 +322,7 @@ class ProductForm(forms.ModelForm):
"description", "description",
"product_type", "product_type",
"code", "code",
"buying_groups",
"purchase_price", "purchase_price",
"selling_price",
"special_selling_price",
"icon", "icon",
"club", "club",
"limit_age", "limit_age",
@@ -311,51 +337,83 @@ class ProductForm(forms.ModelForm):
} }
widgets = { widgets = {
"product_type": AutoCompleteSelect, "product_type": AutoCompleteSelect,
"buying_groups": AutoCompleteSelectMultipleGroup,
"club": AutoCompleteSelectClub, "club": AutoCompleteSelectClub,
"tray": forms.CheckboxInput(attrs={"class": "switch"}),
} }
counters = forms.ModelMultipleChoiceField( counters = forms.ModelMultipleChoiceField(
help_text=None,
label=_("Counters"), label=_("Counters"),
required=False, required=False,
widget=AutoCompleteSelectMultipleCounter, widget=AutoCompleteSelectMultipleCounter,
queryset=Counter.objects.all(), queryset=Counter.objects.all(),
) )
def __init__(self, *args, instance=None, **kwargs): def __init__(self, *args, prefix: str | None = None, instance=None, **kwargs):
super().__init__(*args, instance=instance, **kwargs) super().__init__(*args, prefix=prefix, instance=instance, **kwargs)
self.fields["name"].widget.attrs["autofocus"] = "autofocus"
if self.instance.id: if self.instance.id:
self.fields["counters"].initial = self.instance.counters.all() 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( self.action_formset = ScheduledProductActionFormSet(
*args, product=self.instance, **kwargs *args, product=self.instance, prefix="action", **kwargs
) )
def is_valid(self): 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: def save(self, *args, **kwargs) -> Product:
product = super().save(*args, **kwargs) product = super().save(*args, **kwargs)
product.counters.set(self.cleaned_data["counters"]) 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: 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) form.set_product(product)
self.action_formset.save() self.action_formset.save()
self.price_formset.save()
return product return product
class ProductFormulaForm(forms.ModelForm):
class Meta:
model = ProductFormula
fields = ["products", "result"]
widgets = {
"products": AutoCompleteSelectMultipleProduct,
"result": AutoCompleteSelectProduct,
}
def clean(self):
cleaned_data = super().clean()
if cleaned_data["result"] in cleaned_data["products"]:
self.add_error(
None,
_(
"The same product cannot be at the same time "
"the result and a part of the formula."
),
)
return cleaned_data
class ReturnableProductForm(forms.ModelForm): class ReturnableProductForm(forms.ModelForm):
class Meta: class Meta:
model = ReturnableProduct model = ReturnableProduct
fields = ["product", "returned_product", "max_return"] fields = ["product", "returned_product", "max_return"]
widgets = { widgets = {
"product": AutoCompleteSelectProduct(), "product": AutoCompleteSelectProduct,
"returned_product": AutoCompleteSelectProduct(), "returned_product": AutoCompleteSelectProduct,
} }
def save(self, commit: bool = True) -> ReturnableProduct: # noqa FBT def save(self, commit: bool = True) -> ReturnableProduct: # noqa FBT
@@ -396,48 +454,47 @@ class CloseCustomerAccountForm(forms.Form):
) )
class BasketProductForm(forms.Form): class BasketItemForm(forms.Form):
quantity = forms.IntegerField(min_value=1, required=True) 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__( def __init__(
self, self,
customer: Customer, customer: Customer,
counter: Counter, counter: Counter,
allowed_products: dict[int, Product], allowed_prices: dict[int, Price],
*args, *args,
**kwargs, **kwargs,
): ):
self.customer = customer # Used by formset self.customer = customer # Used by formset
self.counter = counter # Used by formset self.counter = counter # Used by formset
self.allowed_products = allowed_products self.allowed_prices = allowed_prices
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
def clean_id(self): def clean_price_id(self):
data = self.cleaned_data["id"] 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 # And also in the global clean
self.product = self.allowed_products.get(data, None) self.price = self.allowed_prices.get(data, None)
if self.product is None: if self.price is None:
raise forms.ValidationError( raise forms.ValidationError(
_("The selected product isn't available for this user") _("The selected product isn't available for this user")
) )
return data return data
def clean(self): def clean(self):
cleaned_data = super().clean() cleaned_data = super().clean()
if len(self.errors) > 0: if len(self.errors) > 0:
return return cleaned_data
# Compute prices # Compute prices
cleaned_data["bonus_quantity"] = 0 cleaned_data["bonus_quantity"] = 0
if self.product.tray: if self.price.product.tray:
cleaned_data["bonus_quantity"] = math.floor( cleaned_data["bonus_quantity"] = math.floor(
cleaned_data["quantity"] / Product.QUANTITY_FOR_TRAY_PRICE 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"] cleaned_data["quantity"] - cleaned_data["bonus_quantity"]
) )
@@ -461,8 +518,8 @@ class BaseBasketForm(forms.BaseFormSet):
raise forms.ValidationError(_("Submitted basket is invalid")) raise forms.ValidationError(_("Submitted basket is invalid"))
def _check_product_are_unique(self): def _check_product_are_unique(self):
product_ids = {form.cleaned_data["id"] for form in self.forms} price_ids = {form.cleaned_data["price_id"] for form in self.forms}
if len(product_ids) != len(self.forms): if len(price_ids) != len(self.forms):
raise forms.ValidationError(_("Duplicated product entries.")) raise forms.ValidationError(_("Duplicated product entries."))
def _check_enough_money(self, counter: Counter, customer: Customer): def _check_enough_money(self, counter: Counter, customer: Customer):
@@ -472,10 +529,9 @@ class BaseBasketForm(forms.BaseFormSet):
def _check_recorded_products(self, customer: Customer): def _check_recorded_products(self, customer: Customer):
"""Check for, among other things, ecocups and pitchers""" """Check for, among other things, ecocups and pitchers"""
items = { items = defaultdict(int)
form.cleaned_data["id"]: form.cleaned_data["quantity"] for form in self.forms:
for form in self.forms items[form.price.product_id] += form.cleaned_data["quantity"]
}
ids = list(items.keys()) ids = list(items.keys())
returnables = list( returnables = list(
ReturnableProduct.objects.filter( ReturnableProduct.objects.filter(
@@ -501,7 +557,7 @@ class BaseBasketForm(forms.BaseFormSet):
BasketForm = forms.formset_factory( BasketForm = forms.formset_factory(
BasketProductForm, formset=BaseBasketForm, absolute_max=None, min_num=1 BasketItemForm, formset=BaseBasketForm, absolute_max=None, min_num=1
) )

View File

@@ -0,0 +1,43 @@
# Generated by Django 5.2.8 on 2025-11-26 11:34
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [("counter", "0036_product_created_at_product_updated_at")]
operations = [
migrations.CreateModel(
name="ProductFormula",
fields=[
(
"id",
models.AutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"products",
models.ManyToManyField(
help_text="The products that constitute this formula.",
related_name="formulas",
to="counter.product",
verbose_name="products",
),
),
(
"result",
models.OneToOneField(
help_text="The formula product.",
on_delete=django.db.models.deletion.CASCADE,
to="counter.product",
verbose_name="result product",
),
),
],
),
]

View 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",
),
),
]

View File

@@ -22,7 +22,7 @@ import string
from datetime import date, datetime, timedelta from datetime import date, datetime, timedelta
from datetime import timezone as tz from datetime import timezone as tz
from decimal import Decimal from decimal import Decimal
from typing import Literal, Self from typing import TYPE_CHECKING, Literal, Self
from dict2xml import dict2xml from dict2xml import dict2xml
from django.conf import settings from django.conf import settings
@@ -47,6 +47,9 @@ from core.utils import get_start_of_semester
from counter.fields import CurrencyField from counter.fields import CurrencyField
from subscription.models import Subscription from subscription.models import Subscription
if TYPE_CHECKING:
from collections.abc import Sequence
def get_eboutic() -> Counter: def get_eboutic() -> Counter:
return Counter.objects.filter(type="EBOUTIC").order_by("id").first() return Counter.objects.filter(type="EBOUTIC").order_by("id").first()
@@ -157,14 +160,7 @@ class Customer(models.Model):
@property @property
def can_buy(self) -> bool: def can_buy(self) -> bool:
"""Check if whether this customer has the right to purchase any item. """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.
"""
subscription = self.user.subscriptions.order_by("subscription_end").last() subscription = self.user.subscriptions.order_by("subscription_end").last()
if subscription is None: if subscription is None:
return False return False
@@ -363,13 +359,13 @@ class Product(models.Model):
QUANTITY_FOR_TRAY_PRICE = 6 QUANTITY_FOR_TRAY_PRICE = 6
name = models.CharField(_("name"), max_length=64) name = models.CharField(_("name"), max_length=64)
description = models.TextField(_("description"), default="") description = models.TextField(_("description"), blank=True, default="")
product_type = models.ForeignKey( product_type = models.ForeignKey(
ProductType, ProductType,
related_name="products", related_name="products",
verbose_name=_("product type"), verbose_name=_("product type"),
null=True, null=True,
blank=True, blank=False,
on_delete=models.SET_NULL, on_delete=models.SET_NULL,
) )
code = models.CharField(_("code"), max_length=16, blank=True) code = models.CharField(_("code"), max_length=16, blank=True)
@@ -377,11 +373,6 @@ class Product(models.Model):
_("purchase price"), _("purchase price"),
help_text=_("Initial cost of purchasing the product"), 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( icon = ResizedImageField(
height=70, height=70,
force_format="WEBP", force_format="WEBP",
@@ -394,7 +385,9 @@ class Product(models.Model):
Club, related_name="products", verbose_name=_("club"), on_delete=models.CASCADE Club, related_name="products", verbose_name=_("club"), on_delete=models.CASCADE
) )
limit_age = models.IntegerField(_("limit age"), default=0) 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( buying_groups = models.ManyToManyField(
Group, related_name="products", verbose_name=_("buying groups"), blank=True Group, related_name="products", verbose_name=_("buying groups"), blank=True
) )
@@ -419,41 +412,96 @@ class Product(models.Model):
pk=settings.SITH_GROUP_ACCOUNTING_ADMIN_ID pk=settings.SITH_GROUP_ACCOUNTING_ADMIN_ID
) or user.is_in_group(pk=settings.SITH_GROUP_COUNTER_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() class PriceQuerySet(models.QuerySet):
method as the present method returns an information def for_user(self, user: User) -> Self:
about the relation between a User and a Product, age = user.age
whereas the other tells something about a Customer if user.is_banned_alcohol:
(and not a user, they are not the same model). 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: class Price(models.Model):
This performs a db query, thus you can quickly have amount = CurrencyField(_("amount"))
a N+1 queries problem if you call it in a loop. product = models.ForeignKey(
Hopefully, you can avoid that if you prefetch the buying_groups : 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 objects = PriceQuerySet.as_manager()
user = User.objects.get(username="foobar")
products = [ class Meta:
p verbose_name = _("price")
for p in Product.objects.prefetch_related("buying_groups")
if p.can_be_sold_to(user) def __str__(self):
] if not self.label:
``` return f"{self.product.name} ({self.amount}€)"
""" return f"{self.product.name} {self.label} ({self.amount}€)"
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)
@property @property
def profit(self): def full_label(self):
return self.selling_price - self.purchase_price if not self.label:
return self.product.name
return f"{self.product.name} \u2013 {self.label}"
class ProductFormula(models.Model):
products = models.ManyToManyField(
Product,
related_name="formulas",
verbose_name=_("products"),
help_text=_("The products that constitute this formula."),
)
result = models.OneToOneField(
Product,
related_name="formula",
on_delete=models.CASCADE,
verbose_name=_("result product"),
help_text=_("The product got with the formula."),
)
def __str__(self):
return self.result.name
class CounterQuerySet(models.QuerySet): class CounterQuerySet(models.QuerySet):
@@ -681,35 +729,20 @@ class Counter(models.Model):
# but they share the same primary key # but they share the same primary key
return self.type == "BAR" and any(b.pk == customer.pk for b in self.barmen_list) 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]: def get_prices_for(
""" self, customer: Customer, *, order_by: Sequence[str] | None = None
Get all allowed products for the provided customer on this counter ) -> list[Price]:
Prices will be annotated qs = (
""" Price.objects.filter(
product__counters=self, product__product_type__isnull=False
products = ( )
self.products.filter(archived=False) .for_user(customer.user)
.select_related("product_type") .select_related("product", "product__product_type")
.prefetch_related("buying_groups") .prefetch_related("groups")
) )
if order_by:
# Only include age appropriate products qs = qs.order_by(*order_by)
age = customer.user.age return list(qs)
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)
]
class RefillingQuerySet(models.QuerySet): class RefillingQuerySet(models.QuerySet):
@@ -970,7 +1003,9 @@ class Selling(models.Model):
event = self.product.eticket.event_title or _("Unknown event") event = self.product.eticket.event_title or _("Unknown event")
subject = _("Eticket bought for the event %(event)s") % {"event": event} subject = _("Eticket bought for the event %(event)s") % {"event": event}
message_html = _( 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, "event": event,
"url": ( "url": (

View File

@@ -6,8 +6,8 @@ from ninja import FilterLookup, FilterSchema, ModelSchema, Schema
from pydantic import model_validator from pydantic import model_validator
from club.schemas import SimpleClubSchema from club.schemas import SimpleClubSchema
from core.schemas import GroupSchema, NonEmptyStr, SimpleUserSchema from core.schemas import NonEmptyStr, SimpleUserSchema
from counter.models import Counter, Product, ProductType from counter.models import Counter, Price, Product, ProductType
class CounterSchema(ModelSchema): class CounterSchema(ModelSchema):
@@ -66,6 +66,12 @@ class SimpleProductSchema(ModelSchema):
fields = ["id", "name", "code"] fields = ["id", "name", "code"]
class ProductPriceSchema(ModelSchema):
class Meta:
model = Price
fields = ["amount", "groups"]
class ProductSchema(ModelSchema): class ProductSchema(ModelSchema):
class Meta: class Meta:
model = Product model = Product
@@ -75,13 +81,12 @@ class ProductSchema(ModelSchema):
"code", "code",
"description", "description",
"purchase_price", "purchase_price",
"selling_price",
"icon", "icon",
"limit_age", "limit_age",
"archived", "archived",
] ]
buying_groups: list[GroupSchema] prices: list[ProductPriceSchema]
club: SimpleClubSchema club: SimpleClubSchema
product_type: SimpleProductTypeSchema | None product_type: SimpleProductTypeSchema | None
url: str url: str

View File

@@ -1,12 +1,11 @@
import type { Product } from "#counter:counter/types.ts"; import type { CounterItem } from "#counter:counter/types";
export class BasketItem { export class BasketItem {
quantity: number; quantity: number;
product: Product; product: CounterItem;
quantityForTrayPrice: number;
errors: string[]; errors: string[];
constructor(product: Product, quantity: number) { constructor(product: CounterItem, quantity: number) {
this.quantity = quantity; this.quantity = quantity;
this.product = product; this.product = product;
this.errors = []; this.errors = [];
@@ -20,6 +19,6 @@ export class BasketItem {
} }
sum(): number { sum(): number {
return (this.quantity - this.getBonusQuantity()) * this.product.price; return (this.quantity - this.getBonusQuantity()) * this.product.price.amount;
} }
} }

View File

@@ -18,7 +18,10 @@ export class ProductAjaxSelect extends AjaxSelect {
protected searchField = ["code", "name"]; protected searchField = ["code", "name"];
protected async search(query: string): Promise<TomOption[]> { protected async search(query: string): Promise<TomOption[]> {
const resp = await productSearchProducts({ query: { search: query } }); const resp = await productSearchProducts({
// biome-ignore lint/style/useNamingConvention: API is snake_case
query: { search: query, is_archived: false },
});
if (resp.data) { if (resp.data) {
return resp.data.results; return resp.data.results;
} }

View File

@@ -1,7 +1,12 @@
import { AlertMessage } from "#core:utils/alert-message.ts"; import { AlertMessage } from "#core:utils/alert-message";
import { BasketItem } from "#counter:counter/basket.ts"; import { BasketItem } from "#counter:counter/basket";
import type { CounterConfig, ErrorMessage } from "#counter:counter/types.ts"; import type {
import type { CounterProductSelect } from "./components/counter-product-select-index.ts"; CounterConfig,
CounterItem,
ErrorMessage,
ProductFormula,
} from "#counter:counter/types";
import type { CounterProductSelect } from "./components/counter-product-select-index";
document.addEventListener("alpine:init", () => { document.addEventListener("alpine:init", () => {
Alpine.data("counter", (config: CounterConfig) => ({ Alpine.data("counter", (config: CounterConfig) => ({
@@ -47,15 +52,52 @@ document.addEventListener("alpine:init", () => {
this.basket[id] = item; this.basket[id] = item;
this.checkFormulas();
if (this.sumBasket() > this.customerBalance) { if (this.sumBasket() > this.customerBalance) {
item.quantity = oldQty; item.quantity = oldQty;
if (item.quantity === 0) { if (item.quantity === 0) {
delete this.basket[id]; delete this.basket[id];
} }
return gettext("Not enough money"); this.alertMessage.display(gettext("Not enough money"), { success: false });
} }
},
return ""; checkFormulas() {
// Try to find a formula.
// A formula is found if all its elements are already in the basket
const products = new Set(
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));
});
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 = 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: result.name },
true,
),
{ success: true },
);
}, },
getBasketSize() { getBasketSize() {
@@ -70,14 +112,7 @@ document.addEventListener("alpine:init", () => {
(acc: number, cur: BasketItem) => acc + cur.sum(), (acc: number, cur: BasketItem) => acc + cur.sum(),
0, 0,
) as number; ) as number;
return total; return Math.round(total * 100) / 100;
},
addToBasketWithMessage(id: string, quantity: number) {
const message = this.addToBasket(id, quantity);
if (message.length > 0) {
this.alertMessage.display(message, { success: false });
}
}, },
onRefillingSuccess(event: CustomEvent) { onRefillingSuccess(event: CustomEvent) {
@@ -116,7 +151,7 @@ document.addEventListener("alpine:init", () => {
this.finish(); this.finish();
} }
} else { } else {
this.addToBasketWithMessage(code, quantity); this.addToBasket(code, quantity);
} }
this.codeField.widget.clear(); this.codeField.widget.clear();
this.codeField.widget.focus(); this.codeField.widget.focus();

View File

@@ -1,13 +1,9 @@
import { showSaveFilePicker } from "native-file-system-adapter"; import { showSaveFilePicker } from "native-file-system-adapter";
import type TomSelect from "tom-select"; import type TomSelect from "tom-select";
import { paginated } from "#core:utils/api.ts"; import { paginated } from "#core:utils/api";
import { csv } from "#core:utils/csv.ts"; import { csv } from "#core:utils/csv";
import { import { getCurrentUrlParams, History, updateQueryString } from "#core:utils/history";
getCurrentUrlParams, import type { NestedKeyOf } from "#core:utils/types";
History,
updateQueryString,
} from "#core:utils/history.ts";
import type { NestedKeyOf } from "#core:utils/types.ts";
import { import {
type ProductSchema, type ProductSchema,
type ProductSearchProductsDetailedData, type ProductSearchProductsDetailedData,
@@ -20,6 +16,9 @@ type GroupedProducts = Record<ProductType, ProductSchema[]>;
const defaultPageSize = 100; const defaultPageSize = 100;
const defaultPage = 1; 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. * Keys of the properties to include in the CSV.
*/ */
@@ -34,7 +33,7 @@ const csvColumns = [
"purchase_price", "purchase_price",
"selling_price", "selling_price",
"archived", "archived",
] as NestedKeyOf<ProductSchema>[]; ] as NestedKeyOf<ProductWithPriceSchema>[];
/** /**
* Title of the csv columns. * Title of the csv columns.
@@ -175,7 +174,16 @@ document.addEventListener("alpine:init", () => {
this.nbPages > 1 this.nbPages > 1
? await paginated(productSearchProductsDetailed, this.getQueryParams()) ? await paginated(productSearchProductsDetailed, this.getQueryParams())
: Object.values<ProductSchema[]>(this.products).flat(); : 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, columns: csvColumns,
titleRow: csvColumnTitles, titleRow: csvColumnTitles,
}); });

View File

@@ -2,24 +2,35 @@ export type ErrorMessage = string;
export interface InitialFormData { export interface InitialFormData {
/* Used to refill the form when the backend raises an error */ /* Used to refill the form when the backend raises an error */
id?: keyof Record<string, Product>; id?: keyof Record<string, CounterItem>;
quantity?: number; quantity?: number;
errors?: string[]; errors?: string[];
} }
export interface ProductFormula {
result: number;
products: number[];
}
export interface CounterConfig { export interface CounterConfig {
customerBalance: number; customerBalance: number;
customerId: number; customerId: number;
products: Record<string, Product>; products: Record<string, CounterItem>;
formulas: ProductFormula[];
formInitial: InitialFormData[]; formInitial: InitialFormData[];
cancelUrl: string; cancelUrl: string;
} }
export interface Product { interface Price {
id: string; id: number;
amount: number;
}
export interface CounterItem {
productId: number;
price: Price;
code: string; code: string;
name: string; name: string;
price: number;
hasTrayPrice: boolean; hasTrayPrice: boolean;
quantityForTrayPrice: number; quantityForTrayPrice: number;
} }

View File

@@ -10,12 +10,12 @@
float: right; float: right;
} }
.basket-error-container { .basket-message-container {
position: relative; position: relative;
display: block display: block
} }
.basket-error { .basket-message {
z-index: 10; // to get on top of tomselect z-index: 10; // to get on top of tomselect
text-align: center; text-align: center;
position: absolute; position: absolute;

View File

@@ -6,10 +6,10 @@
{% endblock %} {% endblock %}
{% block additional_css %} {% block additional_css %}
<link rel="stylesheet" type="text/css" href="{{ static('counter/css/counter-click.scss') }}" defer></link> <link rel="stylesheet" href="{{ static('counter/css/counter-click.scss') }}">
<link rel="stylesheet" type="text/css" href="{{ static('bundled/core/components/ajax-select-index.css') }}" defer></link> <link rel="stylesheet" href="{{ static('bundled/core/components/ajax-select-index.css') }}">
<link rel="stylesheet" type="text/css" href="{{ static('core/components/ajax-select.scss') }}" defer></link> <link rel="stylesheet" href="{{ static('core/components/ajax-select.scss') }}">
<link rel="stylesheet" type="text/css" href="{{ static('core/components/tabs.scss') }}" defer></link> <link rel="stylesheet" href="{{ static('core/components/tabs.scss') }}">
<link rel="stylesheet" href="{{ static("core/components/card.scss") }}"> <link rel="stylesheet" href="{{ static("core/components/card.scss") }}">
{% endblock %} {% endblock %}
@@ -32,13 +32,11 @@
<div id="bar-ui" x-data="counter({ <div id="bar-ui" x-data="counter({
customerBalance: {{ customer.amount }}, customerBalance: {{ customer.amount }},
products: products, products: products,
formulas: formulas,
customerId: {{ customer.pk }}, customerId: {{ customer.pk }},
formInitial: formInitial, formInitial: formInitial,
cancelUrl: '{{ cancel_url }}', cancelUrl: '{{ cancel_url }}',
})"> })">
<noscript>
<p class="important">Javascript is required for the counter UI.</p>
</noscript>
<div id="user_info"> <div id="user_info">
<h5>{% trans %}Customer{% endtrans %}</h5> <h5>{% trans %}Customer{% endtrans %}</h5>
@@ -67,10 +65,10 @@
<option value="FIN">{% trans %}Confirm (FIN){% endtrans %}</option> <option value="FIN">{% trans %}Confirm (FIN){% endtrans %}</option>
<option value="ANN">{% trans %}Cancel (ANN){% endtrans %}</option> <option value="ANN">{% trans %}Cancel (ANN){% endtrans %}</option>
</optgroup> </optgroup>
{%- for category in categories.keys() -%} {%- for category, prices in categories.items() -%}
<optgroup label="{{ category }}"> <optgroup label="{{ category }}">
{%- for product in categories[category] -%} {%- for price in prices -%}
<option value="{{ product.id }}">{{ product }}</option> <option value="{{ price.id }}">{{ price.full_label }}</option>
{%- endfor -%} {%- endfor -%}
</optgroup> </optgroup>
{%- endfor -%} {%- endfor -%}
@@ -88,11 +86,12 @@
<form x-cloak method="post" action="" x-ref="basketForm"> <form x-cloak method="post" action="" x-ref="basketForm">
<div class="basket-error-container"> <div class="basket-message-container">
<div <div
x-cloak x-cloak
class="alert alert-red basket-error" class="alert basket-message"
x-show="alertMessage.show" :class="alertMessage.success ? 'alert-green' : 'alert-red'"
x-show="alertMessage.open"
x-transition.duration.500ms x-transition.duration.500ms
x-text="alertMessage.content" x-text="alertMessage.content"
></div> ></div>
@@ -104,24 +103,25 @@
</div> </div>
<ul> <ul>
<li x-show="getBasketSize() === 0">{% trans %}This basket is empty{% endtrans %}</li> <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> <li>
<template x-for="error in item.errors"> <template x-for="error in item.errors">
<div class="alert alert-red" x-text="error"> <div class="alert alert-red" x-text="error">
</div> </div>
</template> </template>
<button @click.prevent="addToBasketWithMessage(item.product.id, -1)">-</button> <button @click.prevent="addToBasket(item.product.price.id, -1)">-</button>
<span class="quantity" x-text="item.quantity"></span> <span class="quantity" x-text="item.quantity"></span>
<button @click.prevent="addToBasketWithMessage(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.product.name"></span> :
<span x-text="item.sum().toLocaleString(undefined, { minimumFractionDigits: 2 })">€</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 <button
class="remove-item" 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> ><i class="fa fa-trash-can delete-action"></i></button>
<input <input
@@ -134,9 +134,9 @@
> >
<input <input
type="hidden" type="hidden"
:value="item.product.id" :value="item.product.price.id"
:id="`id_form-${index}-id`" :id="`id_form-${index}-price_id`"
:name="`form-${index}-id`" :name="`form-${index}-price_id`"
required required
readonly readonly
> >
@@ -202,30 +202,30 @@
</div> </div>
<div id="products"> <div id="products">
{% if not products %} {% if not prices %}
<div class="alert alert-red"> <div class="alert alert-red">
{% trans %}No products available on this counter for this user{% endtrans %} {% trans %}No products available on this counter for this user{% endtrans %}
</div> </div>
{% else %} {% else %}
<ui-tab-group> <ui-tab-group>
{% for category in categories.keys() -%} {% for category, prices in categories.items() -%}
<ui-tab title="{{ category }}" {% if loop.index == 1 -%}active{%- endif -%}> <ui-tab title="{{ category }}" {% if loop.index == 1 -%}active{%- endif -%}>
<h5 class="margin-bottom">{{ category }}</h5> <h5 class="margin-bottom">{{ category }}</h5>
<div class="row gap-2x"> <div class="row gap-2x">
{% for product in categories[category] -%} {% for price in prices -%}
<button class="card shadow" @click="addToBasketWithMessage('{{ product.id }}', 1)"> <button class="card shadow" @click="addToBasket('{{ price.id }}', 1)">
<img <img
class="card-image" class="card-image"
alt="image de {{ product.name }}" alt="image de {{ price.full_label }}"
{% if product.icon %} {% if price.product.icon %}
src="{{ product.icon.url }}" src="{{ price.product.icon.url }}"
{% else %} {% else %}
src="{{ static('core/img/na.gif') }}" src="{{ static('core/img/na.gif') }}"
{% endif %} {% endif %}
/> />
<span class="card-content"> <span class="card-content">
<strong class="card-title">{{ product.name }}</strong> <strong class="card-title">{{ price.full_label }}</strong>
<p>{{ product.price }} €<br>{{ product.code }}</p> <p>{{ price.amount }} €<br>{{ price.product.code }}</p>
</span> </span>
</button> </button>
{%- endfor %} {%- endfor %}
@@ -242,16 +242,29 @@
{{ super() }} {{ super() }}
<script> <script>
const products = { const products = {
{%- for product in products -%} {%- for price in prices -%}
{{ product.id }}: { {{ price.id }}: {
id: "{{ product.id }}", productId: {{ price.product_id }},
name: "{{ product.name }}", price: { id: "{{ price.id }}", amount: {{ price.amount }} },
price: {{ product.price }}, code: "{{ price.product.code }}",
hasTrayPrice: {{ product.tray | tojson }}, name: "{{ price.full_label }}",
quantityForTrayPrice: {{ product.QUANTITY_FOR_TRAY_PRICE }}, hasTrayPrice: {{ price.product.tray | tojson }},
quantityForTrayPrice: {{ price.product.QUANTITY_FOR_TRAY_PRICE }},
}, },
{%- endfor -%} {%- endfor -%}
}; };
const formulas = [
{%- for formula in formulas -%}
{
result: {{ formula.result_id }},
products: [
{%- for product in formula.products.all() -%}
{{ product.id }},
{%- endfor -%}
]
},
{%- endfor -%}
];
const formInitial = [ const formInitial = [
{%- for f in form -%} {%- for f in form -%}
{%- if f.cleaned_data -%} {%- if f.cleaned_data -%}

View File

@@ -0,0 +1,74 @@
{% extends "core/base.jinja" %}
{% block title %}
{% trans %}Product formulas{% endtrans %}
{% endblock %}
{% block additional_css %}
<link rel="stylesheet" href="{{ static("core/components/card.scss") }}">
<link rel="stylesheet" href="{{ static("counter/css/admin.scss") }}">
{% endblock %}
{% block content %}
<main>
<h3 class="margin-bottom">{% trans %}Product formulas{% endtrans %}</h3>
<p>
{%- trans trimmed -%}
Formulas allow you to associate a group of products
with a result product (the formula itself).
{%- endtrans -%}
</p>
<p>
{%- trans trimmed -%}
If the product of a formula is available on a counter,
it will be automatically applied if all the products that
make it up are added to the basket.
{%- endtrans -%}
</p>
<p>
{%- trans trimmed -%}
For example, if there is a formula that combines a "Sandwich Formula" product
with the "Sandwich" and "Soft Drink" products,
then, if a person orders a sandwich and a soft drink,
the formula will be applied and the basket will then contain a sandwich formula instead.
{%- endtrans -%}
</p>
<p class="margin-bottom">
<a href="{{ url('counter:product_formula_create') }}" class="btn btn-blue">
{% trans %}New formula{% endtrans %}
<i class="fa fa-plus"></i>
</a>
</p>
<div class="product-group">
{%- for formula in object_list -%}
<a
class="card card-row shadow clickable"
href="{{ url('counter:product_formula_edit', formula_id=formula.id) }}"
>
<div class="card-content">
<strong class="card-title">{{ formula.result.name }}</strong>
<p>
{% for p in formula.products.all() %}
<i>{{ p.name }} ({{ p.code }})</i>
{% if not loop.last %}+{% endif %}
{% endfor %}
</p>
</div>
{% if user.has_perm("counter.delete_productformula") %}
<button
x-data
class="btn btn-red btn-no-text card-top-left"
@click.prevent="document.location.href = '{{ url('counter:product_formula_delete', formula_id=formula.id) }}'"
>
{# The delete link is a button with a JS event listener
instead of a proper <a> element,
because the enclosing card is already a <a>,
and HTML forbids nested <a> #}
<i class="fa fa-trash"></i>
</button>
{% endif %}
</a>
{%- endfor -%}
</div>
</main>
{% endblock %}

View File

@@ -1,5 +1,87 @@
{% extends "core/base.jinja" %} {% extends "core/base.jinja" %}
{% block additional_js %}
<script type="module" src="{{ static("bundled/core/dynamic-formset-index.ts") }}"></script>
{% endblock %}
{% macro action_form(form) %}
<fieldset x-data="{action: '{{ form.task.initial }}'}">
{{ form.non_field_errors() }}
<div class="row gap-2x margin-bottom">
<div>
{{ form.task.errors }}
{{ form.task.label_tag() }}
{{ form.task|add_attr("x-model=action") }}
</div>
<div>{{ form.trigger_at.as_field_group() }}</div>
</div>
<div x-show="action==='counter.tasks.change_counters'" class="margin-bottom">
{{ form.counters.as_field_group() }}
</div>
{%- if form.DELETE -%}
<div class="row gap">
{{ form.DELETE.as_field_group() }}
</div>
{%- else -%}
<button
class="btn btn-grey"
@click.prevent="removeForm($event.target.closest('fieldset'))"
>
<i class="fa fa-minus"></i>{% trans %}Remove this action{% endtrans %}
</button>
{%- endif -%}
{%- for field in form.hidden_fields() -%}
{{ field }}
{%- endfor -%}
<hr />
</fieldset>
{% 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 %} {% block content %}
{% if object %} {% if object %}
<h2>{% trans name=object %}Edit product {{ name }}{% endtrans %}</h2> <h2>{% trans name=object %}Edit product {{ name }}{% endtrans %}</h2>
@@ -10,7 +92,54 @@
{% endif %} {% endif %}
<form method="post" enctype="multipart/form-data"> <form method="post" enctype="multipart/form-data">
{% csrf_token %} {% 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 /> <br />
@@ -25,34 +154,21 @@
</em> </em>
</p> </p>
{{ form.action_formset.management_form }} <div x-data="dynamicFormSet({ prefix: '{{ form.action_formset.prefix }}' })" class="margin-bottom">
{%- for action_form in form.action_formset.forms -%} {{ form.action_formset.management_form }}
<fieldset x-data="{action: '{{ action_form.task.initial }}'}"> <div x-ref="formContainer">
{{ action_form.non_field_errors() }} {%- for f in form.action_formset.forms -%}
<div class="row gap-2x margin-bottom"> {{ action_form(f) }}
<div>
{{ action_form.task.errors }}
{{ action_form.task.label_tag() }}
{{ action_form.task|add_attr("x-model=action") }}
</div>
<div>{{ action_form.trigger_at.as_field_group() }}</div>
</div>
<div x-show="action==='counter.tasks.change_counters'" class="margin-bottom">
{{ action_form.counters.as_field_group() }}
</div>
{%- if action_form.DELETE -%}
<div class="row gap">
{{ action_form.DELETE.as_field_group() }}
</div>
{%- endif -%}
{%- for field in action_form.hidden_fields() -%}
{{ field }}
{%- endfor -%} {%- endfor -%}
</fieldset> </div>
{%- if not loop.last -%} <template x-ref="formTemplate">
<hr class="margin-bottom"> {{ action_form(form.action_formset.empty_form) }}
{%- endif -%} </template>
{%- endfor -%} <button @click.prevent="addForm()" class="btn btn-grey">
<p><input type="submit" value="{% trans %}Save{% endtrans %}" /></p> <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> </form>
{% endblock %} {% endblock %}

View File

@@ -108,7 +108,7 @@
</template> </template>
<span class="card-content"> <span class="card-content">
<strong class="card-title" x-text="`${p.name} (${p.code})`"></strong> <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> </span>
</a> </a>
</template> </template>

View File

@@ -16,7 +16,7 @@ from counter.forms import (
ScheduledProductActionForm, ScheduledProductActionForm,
ScheduledProductActionFormSet, ScheduledProductActionFormSet,
) )
from counter.models import Product, ScheduledProductAction from counter.models import Product, ProductType, ScheduledProductAction
@pytest.mark.django_db @pytest.mark.django_db
@@ -47,20 +47,22 @@ def test_create_actions_alongside_product():
form = ProductForm( form = ProductForm(
data={ data={
"name": "foo", "name": "foo",
"description": "bar", "product_type": ProductType.objects.first(),
"product_type": product.product_type_id,
"club": product.club_id, "club": product.club_id,
"code": "FOO", "code": "FOO",
"purchase_price": 1.0, "purchase_price": 1.0,
"selling_price": 1.0, "selling_price": 1.0,
"special_selling_price": 1.0, "special_selling_price": 1.0,
"limit_age": 0, "limit_age": 0,
"form-TOTAL_FORMS": "2", "price-TOTAL_FORMS": "0",
"form-INITIAL_FORMS": "0", "price-INITIAL_FORMS": "0",
"form-0-task": "counter.tasks.archive_product", "action-TOTAL_FORMS": "1",
"form-0-trigger_at": trigger_at, "action-INITIAL_FORMS": "0",
"action-0-task": "counter.tasks.archive_product",
"action-0-trigger_at": trigger_at,
}, },
) )
form.is_valid()
assert form.is_valid() assert form.is_valid()
product = form.save() product = form.save()
action = ScheduledProductAction.objects.last() action = ScheduledProductAction.objects.last()

View File

@@ -20,7 +20,6 @@ import pytest
from dateutil.relativedelta import relativedelta from dateutil.relativedelta import relativedelta
from django.conf import settings from django.conf import settings
from django.contrib.auth.models import Permission, make_password from django.contrib.auth.models import Permission, make_password
from django.core.cache import cache
from django.http import HttpResponse from django.http import HttpResponse
from django.shortcuts import resolve_url from django.shortcuts import resolve_url
from django.test import Client, TestCase from django.test import Client, TestCase
@@ -34,13 +33,13 @@ from pytest_django.asserts import assertRedirects
from club.models import Membership from club.models import Membership
from core.baker_recipes import board_user, subscriber_user, very_old_subscriber_user from core.baker_recipes import board_user, subscriber_user, very_old_subscriber_user
from core.models import BanGroup, User from core.models import BanGroup, Group, User
from counter.baker_recipes import product_recipe, sale_recipe from counter.baker_recipes import price_recipe, product_recipe, sale_recipe
from counter.models import ( from counter.models import (
Counter, Counter,
Customer, Customer,
Permanency, Permanency,
Product, ProductType,
Refilling, Refilling,
ReturnableProduct, ReturnableProduct,
Selling, Selling,
@@ -204,7 +203,7 @@ class TestRefilling(TestFullClickBase):
@dataclass @dataclass
class BasketItem: class BasketItem:
id: int | None = None price_id: int | None = None
quantity: int | None = None quantity: int | None = None
def to_form(self, index: int) -> dict[str, str]: def to_form(self, index: int) -> dict[str, str]:
@@ -236,38 +235,59 @@ class TestCounterClick(TestFullClickBase):
cls.banned_counter_customer.ban_groups.add( cls.banned_counter_customer.ban_groups.add(
BanGroup.objects.get(pk=settings.SITH_GROUP_BANNED_COUNTER_ID) 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( cls.gift = price_recipe.make(
selling_price="-1.5", amount=-1.5, groups=[subscriber_group], product=_product_recipe.make()
special_selling_price="-1.5",
) )
cls.beer = product_recipe.make( cls.beer = price_recipe.make(
limit_age=18, selling_price=1.5, special_selling_price=1 groups=[subscriber_group],
amount=1.5,
product=_product_recipe.make(limit_age=18),
) )
cls.beer_tap = product_recipe.make( cls.beer_tap = price_recipe.make(
limit_age=18, tray=True, selling_price=1.5, special_selling_price=1 groups=[subscriber_group],
amount=1.5,
product=_product_recipe.make(limit_age=18, tray=True),
) )
cls.snack = product_recipe.make( cls.snack = price_recipe.make(
limit_age=0, selling_price=1.5, special_selling_price=1 groups=[subscriber_group, old_subscriber_group],
amount=1.5,
product=_product_recipe.make(limit_age=0),
) )
cls.stamps = product_recipe.make( cls.stamps = price_recipe.make(
limit_age=0, selling_price=1.5, special_selling_price=1 groups=[subscriber_group],
amount=1.5,
product=_product_recipe.make(limit_age=0),
) )
ReturnableProduct.objects.all().delete() ReturnableProduct.objects.all().delete()
cls.cons = baker.make(Product, selling_price=1) cls.cons = price_recipe.make(
cls.dcons = baker.make(Product, selling_price=-1) 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( baker.make(
ReturnableProduct, ReturnableProduct,
product=cls.cons, product=cls.cons.product,
returned_product=cls.dcons, returned_product=cls.dcons.product,
max_return=3, max_return=3,
) )
cls.counter.products.add( 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.other_counter.products.add(cls.snack.product)
cls.club_counter.products.add(cls.stamps) cls.club_counter.products.add(cls.stamps.product)
def login_in_bar(self, barmen: User | None = None): def login_in_bar(self, barmen: User | None = None):
used_barman = barmen if barmen is not None else self.barmen used_barman = barmen if barmen is not None else self.barmen
@@ -285,10 +305,7 @@ class TestCounterClick(TestFullClickBase):
) -> HttpResponse: ) -> HttpResponse:
used_counter = counter if counter is not None else self.counter used_counter = counter if counter is not None else self.counter
used_client = client if client is not None else self.client used_client = client if client is not None else self.client
data = { data = {"form-TOTAL_FORMS": str(len(basket)), "form-INITIAL_FORMS": "0"}
"form-TOTAL_FORMS": str(len(basket)),
"form-INITIAL_FORMS": "0",
}
for index, item in enumerate(basket): for index, item in enumerate(basket):
data.update(item.to_form(index)) data.update(item.to_form(index))
return used_client.post( return used_client.post(
@@ -331,32 +348,22 @@ class TestCounterClick(TestFullClickBase):
res = self.submit_basket( res = self.submit_basket(
self.customer, [BasketItem(self.beer.id, 2), BasketItem(self.snack.id, 1)] 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") 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): def test_click_tray_price(self):
force_refill_user(self.customer, 20) force_refill_user(self.customer, 20)
self.login_in_bar(self.barmen) self.login_in_bar(self.barmen)
# Not applying tray price # Not applying tray price
res = self.submit_basket(self.customer, [BasketItem(self.beer_tap.id, 2)]) 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) assert self.updated_amount(self.customer) == Decimal(17)
# Applying tray price # Applying tray price
res = self.submit_basket(self.customer, [BasketItem(self.beer_tap.id, 7)]) 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) assert self.updated_amount(self.customer) == Decimal(8)
def test_click_alcool_unauthorized(self): def test_click_alcool_unauthorized(self):
@@ -477,7 +484,8 @@ class TestCounterClick(TestFullClickBase):
BasketItem(None, 1), BasketItem(None, 1),
BasketItem(self.beer.id, None), 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) assert self.updated_amount(self.customer) == Decimal(10)
def test_click_not_enough_money(self): def test_click_not_enough_money(self):
@@ -506,29 +514,30 @@ class TestCounterClick(TestFullClickBase):
res = self.submit_basket( res = self.submit_basket(
self.customer, [BasketItem(self.beer.id, 1), BasketItem(self.gift.id, 1)] 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 assert self.updated_amount(self.customer) == 0
def test_recordings(self): 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) self.login_in_bar(self.barmen)
res = self.submit_basket(self.customer, [BasketItem(self.cons.id, 3)]) res = self.submit_basket(self.customer, [BasketItem(self.cons.id, 3)])
assert res.status_code == 302 assert res.status_code == 302
assert self.updated_amount(self.customer) == 0 assert self.updated_amount(self.customer) == 0
assert list( assert list(
self.customer.customer.return_balances.values("returnable", "balance") 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)]) res = self.submit_basket(self.customer, [BasketItem(self.dcons.id, 3)])
assert res.status_code == 302 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( 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 # 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 res.status_code == 302
assert self.updated_amount(self.customer) == expected_amount assert self.updated_amount(self.customer) == expected_amount
@@ -545,48 +554,57 @@ class TestCounterClick(TestFullClickBase):
def test_recordings_when_negative(self): def test_recordings_when_negative(self):
sale_recipe.make( sale_recipe.make(
customer=self.customer.customer, customer=self.customer.customer,
product=self.dcons, product=self.dcons.product,
unit_price=self.dcons.selling_price, unit_price=self.dcons.amount,
quantity=10, quantity=10,
) )
self.customer.customer.update_returnable_balance() self.customer.customer.update_returnable_balance()
self.login_in_bar(self.barmen) self.login_in_bar(self.barmen)
res = self.submit_basket(self.customer, [BasketItem(self.dcons.id, 1)]) res = self.submit_basket(self.customer, [BasketItem(self.dcons.id, 1)])
assert res.status_code == 200 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)]) res = self.submit_basket(self.customer, [BasketItem(self.cons.id, 3)])
assert res.status_code == 302 assert res.status_code == 302
assert ( assert (
self.updated_amount(self.customer) 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)]) res = self.submit_basket(self.customer, [BasketItem(self.beer.id, 1)])
assert res.status_code == 302 assert res.status_code == 302
assert ( assert (
self.updated_amount(self.customer) self.updated_amount(self.customer)
== self.dcons.selling_price * -10 == self.dcons.amount * -10 - self.cons.amount * 3 - self.beer.amount
- self.cons.selling_price * 3
- self.beer.selling_price
) )
def test_no_fetch_archived_product(self): def test_no_fetch_archived_product(self):
counter = baker.make(Counter) counter = baker.make(Counter)
group = baker.make(Group)
customer = baker.make(Customer) customer = baker.make(Customer)
product_recipe.make(archived=True, counters=[counter]) group.users.add(customer.user)
unarchived_products = product_recipe.make( _product_recipe = product_recipe.extend(
archived=False, counters=[counter], _quantity=3 counters=[counter], product_type=baker.make(ProductType)
) )
customer_products = counter.get_products_for(customer) price_recipe.make(
assert unarchived_products == customer_products _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): class TestCounterStats(TestCase):
@classmethod @classmethod
def setUpTestData(cls): def setUpTestData(cls):
cls.users = subscriber_user.make(_quantity=4) 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( cls.counter = baker.make(
Counter, type=["BAR"], sellers=cls.users[:4], products=[product] Counter, type=["BAR"], sellers=cls.users[:4], products=[product]
) )
@@ -785,9 +803,6 @@ class TestClubCounterClickAccess(TestCase):
cls.user = subscriber_user.make() cls.user = subscriber_user.make()
def setUp(self):
cache.clear()
def test_anonymous(self): def test_anonymous(self):
res = self.client.get(self.click_url) res = self.client.get(self.click_url)
assert res.status_code == 403 assert res.status_code == 403

View File

@@ -0,0 +1,62 @@
from django.contrib.auth.models import Permission
from django.test import TestCase
from model_bakery import baker
from club.models import Membership
from core.baker_recipes import subscriber_user
from core.models import User
from counter.baker_recipes import product_recipe
from counter.forms import CounterEditForm
from counter.models import Counter
class TestEditCounterProducts(TestCase):
@classmethod
def setUpTestData(cls):
cls.counter = baker.make(Counter)
cls.products = product_recipe.make(_quantity=5, _bulk_create=True)
cls.counter.products.add(*cls.products)
def test_admin(self):
"""Test that an admin can add and remove products"""
user = baker.make(
User, user_permissions=[Permission.objects.get(codename="change_counter")]
)
new_product = product_recipe.make()
form = CounterEditForm(
data={"sellers": [], "products": [*self.products[1:], new_product]},
user=user,
instance=self.counter,
)
assert form.is_valid()
form.save()
assert set(self.counter.products.all()) == {*self.products[1:], new_product}
def test_club_board_id(self):
"""Test that people from counter club board can only add their own products."""
club = self.counter.club
user = subscriber_user.make()
baker.make(Membership, user=user, club=club, end_date=None)
new_product = product_recipe.make(club=club)
form = CounterEditForm(
data={"sellers": [], "products": [*self.products[1:], new_product]},
user=user,
instance=self.counter,
)
assert form.is_valid()
form.save()
assert set(self.counter.products.all()) == {*self.products[1:], new_product}
new_product = product_recipe.make() # product not owned by the club
form = CounterEditForm(
data={"sellers": [], "products": [*self.products[1:], new_product]},
user=user,
instance=self.counter,
)
assert not form.is_valid()
assert form.errors == {
"products": [
"Sélectionnez un choix valide. "
f"{new_product.id} n\u2019en fait pas partie."
],
}

View File

@@ -341,7 +341,7 @@ def test_update_balance():
def test_update_returnable_balance(): def test_update_returnable_balance():
ReturnableProduct.objects.all().delete() ReturnableProduct.objects.all().delete()
customer = baker.make(Customer) 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 = [ returnables = [
baker.make( baker.make(
ReturnableProduct, product=products[0], returned_product=products[1] ReturnableProduct, product=products[0], returned_product=products[1]

View File

@@ -0,0 +1,37 @@
from django.test import TestCase
from counter.baker_recipes import product_recipe
from counter.forms import ProductFormulaForm
class TestFormulaForm(TestCase):
@classmethod
def setUpTestData(cls):
cls.products = product_recipe.make(_quantity=3, _bulk_create=True)
def test_ok(self):
form = ProductFormulaForm(
data={
"result": self.products[0].id,
"products": [self.products[1].id, self.products[2].id],
}
)
assert form.is_valid()
formula = form.save()
assert formula.result == self.products[0]
assert set(formula.products.all()) == set(self.products[1:])
def test_product_both_in_result_and_products(self):
form = ProductFormulaForm(
data={
"result": self.products[0].id,
"products": [self.products[0].id, self.products[1].id],
}
)
assert not form.is_valid()
assert form.errors == {
"__all__": [
"Un même produit ne peut pas être à la fois "
"le résultat et un élément de la formule."
]
}

View File

@@ -9,14 +9,16 @@ from django.core.files.uploadedfile import SimpleUploadedFile
from django.test import Client, TestCase from django.test import Client, TestCase
from django.urls import reverse from django.urls import reverse
from model_bakery import baker from model_bakery import baker
from model_bakery.recipe import Recipe
from PIL import Image from PIL import Image
from pytest_django.asserts import assertNumQueries, assertRedirects from pytest_django.asserts import assertNumQueries, assertRedirects
from club.models import Club from club.models import Club
from core.baker_recipes import board_user, subscriber_user from core.baker_recipes import board_user, subscriber_user
from core.models import Group, User from core.models import Group, User
from counter.forms import ProductForm from counter.baker_recipes import product_recipe
from counter.models import Product, ProductType from counter.forms import ProductForm, ProductPriceFormSet
from counter.models import Price, Product, ProductType
@pytest.mark.django_db @pytest.mark.django_db
@@ -80,11 +82,11 @@ def test_fetch_product_access(
def test_fetch_product_nb_queries(client: Client): def test_fetch_product_nb_queries(client: Client):
client.force_login(baker.make(User, is_superuser=True)) client.force_login(baker.make(User, is_superuser=True))
cache.clear() cache.clear()
with assertNumQueries(5): with assertNumQueries(6):
# - 2 for authentication # - 2 for authentication
# - 1 for pagination # - 1 for pagination
# - 1 for the actual request # - 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")) client.get(reverse("api:search_products_detailed"))
@@ -93,6 +95,9 @@ class TestCreateProduct(TestCase):
def setUpTestData(cls): def setUpTestData(cls):
cls.product_type = baker.make(ProductType) cls.product_type = baker.make(ProductType)
cls.club = baker.make(Club) cls.club = baker.make(Club)
cls.counter_admin = baker.make(
User, groups=[Group.objects.get(id=settings.SITH_GROUP_COUNTER_ADMIN_ID)]
)
cls.data = { cls.data = {
"name": "foo", "name": "foo",
"description": "bar", "description": "bar",
@@ -103,26 +108,22 @@ class TestCreateProduct(TestCase):
"selling_price": 1.0, "selling_price": 1.0,
"special_selling_price": 1.0, "special_selling_price": 1.0,
"limit_age": 0, "limit_age": 0,
"form-TOTAL_FORMS": 0, "price-TOTAL_FORMS": 0,
"form-INITIAL_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) form = ProductForm(data=self.data)
assert form.is_valid() assert form.is_valid()
instance = form.save() instance = form.save()
assert instance.club == self.club assert instance.club == self.club
assert instance.product_type == self.product_type assert instance.product_type == self.product_type
assert instance.name == "foo" assert instance.name == "foo"
assert instance.selling_price == 1.0
def test_view(self): def test_view_simple(self):
self.client.force_login( self.client.force_login(self.counter_admin)
baker.make(
User,
groups=[Group.objects.get(id=settings.SITH_GROUP_COUNTER_ADMIN_ID)],
)
)
url = reverse("counter:new_product") url = reverse("counter:new_product")
response = self.client.get(url) response = self.client.get(url)
assert response.status_code == 200 assert response.status_code == 200
@@ -132,3 +133,92 @@ class TestCreateProduct(TestCase):
assert product.name == "foo" assert product.name == "foo"
assert product.club == self.club assert product.club == self.club
assert product.product_type == self.product_type 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]}

View File

@@ -25,6 +25,10 @@ from counter.views.admin import (
CounterStatView, CounterStatView,
ProductCreateView, ProductCreateView,
ProductEditView, ProductEditView,
ProductFormulaCreateView,
ProductFormulaDeleteView,
ProductFormulaEditView,
ProductFormulaListView,
ProductListView, ProductListView,
ProductTypeCreateView, ProductTypeCreateView,
ProductTypeEditView, ProductTypeEditView,
@@ -116,6 +120,24 @@ urlpatterns = [
ProductEditView.as_view(), ProductEditView.as_view(),
name="product_edit", name="product_edit",
), ),
path(
"admin/formula/", ProductFormulaListView.as_view(), name="product_formula_list"
),
path(
"admin/formula/new/",
ProductFormulaCreateView.as_view(),
name="product_formula_create",
),
path(
"admin/formula/<int:formula_id>/edit",
ProductFormulaEditView.as_view(),
name="product_formula_edit",
),
path(
"admin/formula/<int:formula_id>/delete",
ProductFormulaDeleteView.as_view(),
name="product_formula_delete",
),
path( path(
"admin/product-type/list/", "admin/product-type/list/",
ProductTypeListView.as_view(), ProductTypeListView.as_view(),

View File

@@ -34,11 +34,13 @@ from counter.forms import (
CloseCustomerAccountForm, CloseCustomerAccountForm,
CounterEditForm, CounterEditForm,
ProductForm, ProductForm,
ProductFormulaForm,
ReturnableProductForm, ReturnableProductForm,
) )
from counter.models import ( from counter.models import (
Counter, Counter,
Product, Product,
ProductFormula,
ProductType, ProductType,
Refilling, Refilling,
ReturnableProduct, ReturnableProduct,
@@ -56,7 +58,7 @@ class CounterListView(CounterAdminTabsMixin, CanViewMixin, ListView):
current_tab = "counters" current_tab = "counters"
class CounterEditView(CounterAdminTabsMixin, CounterAdminMixin, UpdateView): class CounterEditView(CounterAdminTabsMixin, UserPassesTestMixin, UpdateView):
"""Edit a counter's main informations (for the counter's manager).""" """Edit a counter's main informations (for the counter's manager)."""
model = Counter model = Counter
@@ -65,10 +67,14 @@ class CounterEditView(CounterAdminTabsMixin, CounterAdminMixin, UpdateView):
template_name = "core/edit.jinja" template_name = "core/edit.jinja"
current_tab = "counters" current_tab = "counters"
def dispatch(self, request, *args, **kwargs): def test_func(self):
obj = self.get_object() if self.request.user.has_perm("counter.change_counter"):
self.edit_club.append(obj.club) return True
return super().dispatch(request, *args, **kwargs) obj = self.get_object(queryset=self.get_queryset().select_related("club"))
return obj.club.has_rights_in_club(self.request.user)
def get_form_kwargs(self):
return super().get_form_kwargs() | {"user": self.request.user}
def get_success_url(self): def get_success_url(self):
return reverse_lazy("counter:admin", kwargs={"counter_id": self.object.id}) return reverse_lazy("counter:admin", kwargs={"counter_id": self.object.id})
@@ -162,6 +168,62 @@ class ProductEditView(CounterAdminTabsMixin, CounterAdminMixin, UpdateView):
current_tab = "products" current_tab = "products"
class ProductFormulaListView(CounterAdminTabsMixin, PermissionRequiredMixin, ListView):
model = ProductFormula
queryset = ProductFormula.objects.select_related("result").prefetch_related(
"products"
)
template_name = "counter/formula_list.jinja"
current_tab = "formulas"
permission_required = "counter.view_productformula"
class ProductFormulaCreateView(
CounterAdminTabsMixin, PermissionRequiredMixin, CreateView
):
model = ProductFormula
form_class = ProductFormulaForm
pk_url_kwarg = "formula_id"
template_name = "core/create.jinja"
current_tab = "formulas"
success_url = reverse_lazy("counter:product_formula_list")
permission_required = "counter.add_productformula"
class ProductFormulaEditView(
CounterAdminTabsMixin, PermissionRequiredMixin, UpdateView
):
model = ProductFormula
form_class = ProductFormulaForm
pk_url_kwarg = "formula_id"
template_name = "core/edit.jinja"
current_tab = "formulas"
success_url = reverse_lazy("counter:product_formula_list")
permission_required = "counter.change_productformula"
class ProductFormulaDeleteView(
CounterAdminTabsMixin, PermissionRequiredMixin, DeleteView
):
model = ProductFormula
pk_url_kwarg = "formula_id"
template_name = "core/delete_confirm.jinja"
current_tab = "formulas"
success_url = reverse_lazy("counter:product_formula_list")
permission_required = "counter.delete_productformula"
def get_context_data(self, **kwargs):
obj_name = self.object.result.name
return super().get_context_data(**kwargs) | {
"object_name": _("%(formula)s (formula)") % {"formula": obj_name},
"help_text": _(
"This action will only delete the formula, "
"but not the %(product)s product itself."
)
% {"product": obj_name},
}
class ReturnableProductListView( class ReturnableProductListView(
CounterAdminTabsMixin, PermissionRequiredMixin, ListView CounterAdminTabsMixin, PermissionRequiredMixin, ListView
): ):

View File

@@ -12,6 +12,7 @@
# OR WITHIN THE LOCAL FILE "LICENSE" # OR WITHIN THE LOCAL FILE "LICENSE"
# #
# #
from collections import defaultdict
from django.core.exceptions import PermissionDenied from django.core.exceptions import PermissionDenied
from django.db import transaction from django.db import transaction
@@ -31,6 +32,7 @@ from counter.forms import BasketForm, RefillForm
from counter.models import ( from counter.models import (
Counter, Counter,
Customer, Customer,
ProductFormula,
ReturnableProduct, ReturnableProduct,
Selling, Selling,
) )
@@ -71,7 +73,7 @@ class CounterClick(
kwargs["form_kwargs"] = { kwargs["form_kwargs"] = {
"customer": self.customer, "customer": self.customer,
"counter": self.object, "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 return kwargs
@@ -101,7 +103,7 @@ class CounterClick(
): ):
return redirect(obj) # Redirect to counter 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) return super().dispatch(request, *args, **kwargs)
@@ -119,32 +121,31 @@ class CounterClick(
# This is important because some items have a negative price # This is important because some items have a negative price
# Negative priced items gives money to the customer and should # Negative priced items gives money to the customer and should
# be processed first so that we don't throw a not enough money error # 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( 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( Selling(
label=form.product.name, **common_kwargs,
product=form.product, label=form.price.full_label,
club=form.product.club, unit_price=form.price.amount,
counter=self.object,
unit_price=form.product.price,
quantity=form.cleaned_data["quantity"] quantity=form.cleaned_data["quantity"]
- form.cleaned_data["bonus_quantity"], - form.cleaned_data["bonus_quantity"],
seller=operator,
customer=self.customer,
).save() ).save()
if form.cleaned_data["bonus_quantity"] > 0: if form.cleaned_data["bonus_quantity"] > 0:
Selling( Selling(
label=f"{form.product.name} (Plateau)", **common_kwargs,
product=form.product, label=f"{form.price.full_label} (Plateau)",
club=form.product.club,
counter=self.object,
unit_price=0, unit_price=0,
quantity=form.cleaned_data["bonus_quantity"], quantity=form.cleaned_data["bonus_quantity"],
seller=operator,
customer=self.customer,
).save() ).save()
self.customer.update_returnable_balance() self.customer.update_returnable_balance()
@@ -205,13 +206,13 @@ class CounterClick(
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
"""Add customer to the context.""" """Add customer to the context."""
kwargs = super().get_context_data(**kwargs) kwargs = super().get_context_data(**kwargs)
kwargs["products"] = self.products kwargs["prices"] = self.prices
kwargs["categories"] = {} kwargs["formulas"] = ProductFormula.objects.filter(
for product in kwargs["products"]: result__in=[p.product_id for p in self.prices]
if product.product_type: ).prefetch_related("products")
kwargs["categories"].setdefault(product.product_type, []).append( kwargs["categories"] = defaultdict(list)
product for price in self.prices:
) kwargs["categories"][price.product.product_type].append(price)
kwargs["customer"] = self.customer kwargs["customer"] = self.customer
kwargs["cancel_url"] = self.get_success_url() kwargs["cancel_url"] = self.get_success_url()

View File

@@ -100,6 +100,11 @@ class CounterAdminTabsMixin(TabedViewMixin):
"slug": "products", "slug": "products",
"name": _("Products"), "name": _("Products"),
}, },
{
"url": reverse_lazy("counter:product_formula_list"),
"slug": "formulas",
"name": _("Formulas"),
},
{ {
"url": reverse_lazy("counter:product_type_list"), "url": reverse_lazy("counter:product_type_list"),
"slug": "product_types", "slug": "product_types",

View File

@@ -22,23 +22,22 @@ from eboutic.models import Basket, BasketItem, Invoice, InvoiceItem
class BasketAdmin(admin.ModelAdmin): class BasketAdmin(admin.ModelAdmin):
list_display = ("user", "date", "total") list_display = ("user", "date", "total")
autocomplete_fields = ("user",) autocomplete_fields = ("user",)
date_hierarchy = "date"
def get_queryset(self, request): def get_queryset(self, request):
return ( return (
super() super()
.get_queryset(request) .get_queryset(request)
.annotate( .annotate(
total=Sum( total=Sum(F("items__quantity") * F("items__unit_price"), default=0)
F("items__quantity") * F("items__product_unit_price"), default=0
)
) )
) )
@admin.register(BasketItem) @admin.register(BasketItem)
class BasketItemAdmin(admin.ModelAdmin): class BasketItemAdmin(admin.ModelAdmin):
list_display = ("basket", "product_name", "product_unit_price", "quantity") list_display = ("label", "unit_price", "quantity")
search_fields = ("product_name",) search_fields = ("label",)
@admin.register(Invoice) @admin.register(Invoice)
@@ -50,5 +49,6 @@ class InvoiceAdmin(admin.ModelAdmin):
@admin.register(InvoiceItem) @admin.register(InvoiceItem)
class InvoiceItemAdmin(admin.ModelAdmin): class InvoiceItemAdmin(admin.ModelAdmin):
list_display = ("invoice", "product_name", "product_unit_price", "quantity") list_display = ("label", "unit_price", "quantity")
search_fields = ("product_name",) search_fields = ("label",)
list_select_related = ("price",)

View File

@@ -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",
),
),
]

View File

@@ -17,7 +17,7 @@ from __future__ import annotations
import hmac import hmac
from datetime import datetime from datetime import datetime
from enum import Enum from enum import Enum
from typing import Any, Self from typing import Self
from dict2xml import dict2xml from dict2xml import dict2xml
from django.conf import settings from django.conf import settings
@@ -30,8 +30,8 @@ from core.models import User
from counter.fields import CurrencyField from counter.fields import CurrencyField
from counter.models import ( from counter.models import (
BillingInfo, BillingInfo,
Counter,
Customer, Customer,
Price,
Product, Product,
Refilling, Refilling,
Selling, 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): class BillingInfoState(Enum):
VALID = 1 VALID = 1
EMPTY = 2 EMPTY = 2
@@ -94,21 +78,21 @@ class Basket(models.Model):
def __str__(self): def __str__(self):
return f"{self.user}'s basket ({self.items.all().count()} items)" 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 return self.user == user
@cached_property @cached_property
def contains_refilling_item(self) -> bool: def contains_refilling_item(self) -> bool:
return self.items.filter( return self.items.filter(
type_id=settings.SITH_COUNTER_PRODUCTTYPE_REFILLING product__product_type_id=settings.SITH_COUNTER_PRODUCTTYPE_REFILLING
).exists() ).exists()
@cached_property @cached_property
def total(self) -> float: def total(self) -> float:
return float( return float(
self.items.aggregate( self.items.aggregate(total=Sum(F("quantity") * F("unit_price"), default=0))[
total=Sum(F("quantity") * F("product_unit_price"), default=0) "total"
)["total"] ]
) )
def generate_sales( def generate_sales(
@@ -120,7 +104,8 @@ class Basket(models.Model):
Example: Example:
```python ```python
counter = Counter.objects.get(name="Eboutic") 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 # here the basket is in the same state as before the method call
with transaction.atomic(): with transaction.atomic():
@@ -131,31 +116,23 @@ class Basket(models.Model):
# thus only the sales remain # thus only the sales remain
``` ```
""" """
# I must proceed with two distinct requests instead of customer = Customer.get_or_create(self.user)[0]
# only one with a join because the AbstractBaseItem model has been return [
# poorly designed. If you refactor the model, please refactor this too. Selling(
items = self.items.order_by("product_id") label=item.label,
ids = [item.product_id for item in items] counter=counter,
products = Product.objects.filter(id__in=ids).order_by("id") club_id=item.product.club_id,
# items and products are sorted in the same order product=item.product,
sales = [] seller=seller,
for item, product in zip(items, products, strict=False): customer=customer,
sales.append( unit_price=item.unit_price,
Selling( quantity=item.quantity,
label=product.name, payment_method=payment_method,
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,
)
) )
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 user = self.user
if not hasattr(user, "customer"): if not hasattr(user, "customer"):
raise Customer.DoesNotExist raise Customer.DoesNotExist
@@ -201,7 +178,7 @@ class InvoiceQueryset(models.QuerySet):
def annotate_total(self) -> Self: def annotate_total(self) -> Self:
"""Annotate the queryset with the total amount of each invoice. """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. for all items related to the invoice.
""" """
# aggregates within subqueries require a little bit of black magic, # aggregates within subqueries require a little bit of black magic,
@@ -211,7 +188,7 @@ class InvoiceQueryset(models.QuerySet):
total=Subquery( total=Subquery(
InvoiceItem.objects.filter(invoice_id=OuterRef("pk")) InvoiceItem.objects.filter(invoice_id=OuterRef("pk"))
.values("invoice_id") .values("invoice_id")
.annotate(total=Sum(F("product_unit_price") * F("quantity"))) .annotate(total=Sum(F("unit_price") * F("quantity")))
.values("total") .values("total")
) )
) )
@@ -221,11 +198,7 @@ class Invoice(models.Model):
"""Invoices are generated once the payment has been validated.""" """Invoices are generated once the payment has been validated."""
user = models.ForeignKey( user = models.ForeignKey(
User, User, related_name="invoices", verbose_name=_("user"), on_delete=models.CASCADE
related_name="invoices",
verbose_name=_("user"),
blank=False,
on_delete=models.CASCADE,
) )
date = models.DateTimeField(_("date"), auto_now=True) date = models.DateTimeField(_("date"), auto_now=True)
validated = models.BooleanField(_("validated"), default=False) validated = models.BooleanField(_("validated"), default=False)
@@ -246,53 +219,44 @@ class Invoice(models.Model):
if self.validated: if self.validated:
raise DataError(_("Invoice already validated")) raise DataError(_("Invoice already validated"))
customer, _created = Customer.get_or_create(user=self.user) customer, _created = Customer.get_or_create(user=self.user)
eboutic = Counter.objects.filter(type="EBOUTIC").first() kwargs = {
for i in self.items.all(): "counter": get_eboutic(),
if i.type_id == settings.SITH_COUNTER_PRODUCTTYPE_REFILLING: "customer": customer,
new = Refilling( "date": self.date,
counter=eboutic, "payment_method": Selling.PaymentMethod.CARD,
customer=customer, }
operator=self.user, for i in self.items.select_related("product"):
amount=i.product_unit_price * i.quantity, if i.product.product_type_id == settings.SITH_COUNTER_PRODUCTTYPE_REFILLING:
payment_method=Refilling.PaymentMethod.CARD, Refilling.objects.create(
date=self.date, **kwargs, operator=self.user, amount=i.unit_price * i.quantity
) )
new.save()
else: else:
product = Product.objects.filter(id=i.product_id).first() Selling.objects.create(
new = Selling( **kwargs,
label=i.product_name, label=i.label,
counter=eboutic, club_id=i.product.club_id,
club=product.club, product=i.product,
product=product,
seller=self.user, seller=self.user,
customer=customer, unit_price=i.unit_price,
unit_price=i.product_unit_price,
quantity=i.quantity, quantity=i.quantity,
payment_method=Selling.PaymentMethod.CARD,
date=self.date,
) )
new.save()
self.validated = True self.validated = True
self.save() self.save()
class AbstractBaseItem(models.Model): class AbstractBaseItem(models.Model):
product_id = models.IntegerField(_("product id")) product = models.ForeignKey(
product_name = models.CharField(_("product name"), max_length=255) Product, verbose_name=_("product"), on_delete=models.PROTECT
type_id = models.IntegerField(_("product type id")) )
product_unit_price = CurrencyField(_("unit price")) label = models.CharField(_("product name"), max_length=255)
unit_price = CurrencyField(_("unit price"))
quantity = models.PositiveIntegerField(_("quantity")) quantity = models.PositiveIntegerField(_("quantity"))
class Meta: class Meta:
abstract = True abstract = True
def __str__(self): def __str__(self):
return "Item: %s (%s) x%d" % ( return "Item: %s (%s) x%d" % (self.product.name, self.unit_price, self.quantity)
self.product_name,
self.product_unit_price,
self.quantity,
)
class BasketItem(AbstractBaseItem): class BasketItem(AbstractBaseItem):
@@ -301,21 +265,16 @@ class BasketItem(AbstractBaseItem):
) )
@classmethod @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 """Create a BasketItem with the same characteristics as the
product passed in parameters, with the specified quantity. product price passed in parameters, with the specified quantity.
Warning:
the basket field is not filled, so you must set
it yourself before saving the model.
""" """
return cls( return cls(
basket=basket, basket=basket,
product_id=product.id, label=price.full_label,
product_name=product.name, product_id=price.product_id,
type_id=product.product_type_id,
quantity=quantity, quantity=quantity,
product_unit_price=product.selling_price, unit_price=price.amount,
) )

View File

@@ -1,13 +1,15 @@
export {}; export {};
interface BasketItem { interface BasketItem {
id: number; priceId: number;
name: string; name: string;
quantity: number; quantity: number;
// biome-ignore lint/style/useNamingConvention: the python code is snake_case unitPrice: number;
unit_price: number;
} }
// increment the key number if the data schema of the cached basket changes
const BASKET_CACHE_KEY = "basket1";
document.addEventListener("alpine:init", () => { document.addEventListener("alpine:init", () => {
Alpine.data("basket", (lastPurchaseTime?: number) => ({ Alpine.data("basket", (lastPurchaseTime?: number) => ({
basket: [] as BasketItem[], basket: [] as BasketItem[],
@@ -30,24 +32,24 @@ document.addEventListener("alpine:init", () => {
// It's quite tricky to manually apply attributes to the management part // It's quite tricky to manually apply attributes to the management part
// of a formset so we dynamically apply it here // of a formset so we dynamically apply it here
this.$refs.basketManagementForm this.$refs.basketManagementForm
.querySelector("#id_form-TOTAL_FORMS") .getElementById("#id_form-TOTAL_FORMS")
.setAttribute(":value", "basket.length"); .setAttribute(":value", "basket.length");
}, },
loadBasket(): BasketItem[] { loadBasket(): BasketItem[] {
if (localStorage.basket === undefined) { if (localStorage.getItem(BASKET_CACHE_KEY) === null) {
return []; return [];
} }
try { try {
return JSON.parse(localStorage.basket); return JSON.parse(localStorage.getItem(BASKET_CACHE_KEY));
} catch (_err) { } catch (_err) {
return []; return [];
} }
}, },
saveBasket() { saveBasket() {
localStorage.basket = JSON.stringify(this.basket); localStorage.setItem(BASKET_CACHE_KEY, JSON.stringify(this.basket));
localStorage.basketTimestamp = Date.now(); localStorage.setItem("basketTimestamp", Date.now().toString());
}, },
/** /**
@@ -56,7 +58,7 @@ document.addEventListener("alpine:init", () => {
*/ */
getTotal() { getTotal() {
return this.basket.reduce( return this.basket.reduce(
(acc: number, item: BasketItem) => acc + item.quantity * item.unit_price, (acc: number, item: BasketItem) => acc + item.quantity * item.unitPrice,
0, 0,
); );
}, },
@@ -74,7 +76,7 @@ document.addEventListener("alpine:init", () => {
* @param itemId the id of the item to remove * @param itemId the id of the item to remove
*/ */
remove(itemId: number) { 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) { if (index < 0) {
return; return;
@@ -83,7 +85,7 @@ document.addEventListener("alpine:init", () => {
if (this.basket[index].quantity === 0) { if (this.basket[index].quantity === 0) {
this.basket = this.basket.filter( 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 { createItem(id: number, name: string, price: number): BasketItem {
const newItem = { const newItem = {
id, priceId: id,
name, name,
quantity: 0, quantity: 0,
// biome-ignore lint/style/useNamingConvention: the python code is snake_case unitPrice: price,
unit_price: price,
} as BasketItem; } as BasketItem;
this.basket.push(newItem); this.basket.push(newItem);
@@ -125,7 +126,7 @@ document.addEventListener("alpine:init", () => {
* @param price The unit price of the product * @param price The unit price of the product
*/ */
addFromCatalog(id: number, name: string, price: number) { 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 // if the item is not in the basket, we create it
// else we add + 1 to it // else we add + 1 to it

View File

@@ -32,9 +32,9 @@
<tbody> <tbody>
{% for item in basket.items.all() %} {% for item in basket.items.all() %}
<tr> <tr>
<td>{{ item.product_name }}</td> <td>{{ item.label }}</td>
<td>{{ item.quantity }}</td> <td>{{ item.quantity }}</td>
<td>{{ item.product_unit_price }} €</td> <td>{{ item.unit_price }} €</td>
</tr> </tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
@@ -68,12 +68,6 @@
<template x-for="[key, value] in Object.entries(data)" :key="key"> <template x-for="[key, value] in Object.entries(data)" :key="key">
<input type="hidden" :name="key" :value="value"> <input type="hidden" :name="key" :value="value">
</template> </template>
<input type="checkbox" value="cgv" required>
<label>
{% trans trimmed %}I have read and accept {% endtrans %}
<a href="">{% trans %}the general terms and conditions{% endtrans%}</a>
{%trans%}of the student assosiation of UTBM{% endtrans %}</label>
<br>
<input <input
x-cloak x-cloak
type="submit" type="submit"
@@ -99,12 +93,6 @@
{% else %} {% else %}
<form method="post" action="{{ url('eboutic:pay_with_sith', basket_id=basket.id) }}" name="sith-pay-form"> <form method="post" action="{{ url('eboutic:pay_with_sith', basket_id=basket.id) }}" name="sith-pay-form">
{% csrf_token %} {% csrf_token %}
<input type="checkbox" value="cgv" required>
<label>
{% trans trimmed %}I have read and accept {% endtrans %}
<a href="">{% trans %}the general terms and conditions{% endtrans%}</a>
{%trans%}of the student assosiation of UTBM{% endtrans %}</label>
<br>
<input class="btn btn-blue" type="submit" value="{% trans %}Pay with Sith account{% endtrans %}"/> <input class="btn btn-blue" type="submit" value="{% trans %}Pay with Sith account{% endtrans %}"/>
</form> </form>
{% endif %} {% endif %}

View File

@@ -41,7 +41,7 @@
</div> </div>
{% endif %} {% endif %}
<ul class="item-list"> <ul class="item-list">
{# Starting money #} {# Starting money #}
<li> <li>
<span class="item-name"> <span class="item-name">
<strong>{% trans %}Current account amount: {% endtrans %}</strong> <strong>{% trans %}Current account amount: {% endtrans %}</strong>
@@ -51,15 +51,15 @@
</span> </span>
</li> </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"> <li class="item-row" x-show="item.quantity > 0">
<div class="item-quantity"> <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> <span x-text="item.quantity"></span>
<i class="fa fa-plus" @click="add(item)"></i> <i class="fa fa-plus" @click="add(item)"></i>
</div> </div>
<span class="item-name" x-text="item.name"></span> <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 <input
type="hidden" type="hidden"
@@ -71,16 +71,16 @@
> >
<input <input
type="hidden" type="hidden"
:value="item.id" :value="item.priceId"
:id="`id_form-${index}-id`" :id="`id_form-${index}-price_id`"
:name="`form-${index}-id`" :name="`form-${index}-price_id`"
required required
readonly readonly
> >
</li> </li>
</template> </template>
{# Total price #} {# Total price #}
<li style="margin-top: 20px"> <li style="margin-top: 20px">
<span class="item-name"><strong>{% trans %}Basket amount: {% endtrans %}</strong></span> <span class="item-name"><strong>{% trans %}Basket amount: {% endtrans %}</strong></span>
<span x-text="getTotal().toFixed(2) + ' €'" class="item-price"></span> <span x-text="getTotal().toFixed(2) + ' €'" class="item-price"></span>
@@ -116,45 +116,40 @@
</span> </span>
</div> </div>
{% endif %} {% endif %}
{% for priority_groups in products|groupby('order') %} {% for prices in categories %}
{% for category, items in priority_groups.list|groupby('category') %} {% set category = prices[0].product.product_type %}
{% if items|count > 0 %} <section>
<section> <div class="category-header">
{# I would have wholeheartedly directly used the header element instead <h3>{{ category.name }}</h3>
but it has already been made messy in core/style.scss #} {% if category.comment %}
<div class="category-header"> <p><i>{{ category.comment }}</i></p>
<h3>{{ category }}</h3> {% endif %}
{% if items[0].category_comment %} </div>
<p><i>{{ items[0].category_comment }}</i></p> <div class="product-group">
{% endif %} {% for price in prices %}
</div> <button
<div class="product-group"> id="{{ price.id }}"
{% for p in items %} class="card product-button clickable shadow"
<button :class="{selected: basket.some((i) => i.priceId === {{ price.id }})}"
id="{{ p.id }}" @click='addFromCatalog({{ price.id }}, {{ price.full_label|tojson }}, {{ price.amount }})'
class="card product-button clickable shadow" >
:class="{selected: basket.some((i) => i.id === {{ p.id }})}" {% if price.product.icon %}
@click='addFromCatalog({{ p.id }}, {{ p.name|tojson }}, {{ p.selling_price }})' <img
class="card-image"
src="{{ price.product.icon.url }}"
alt="image de {{ price.full_label }}"
> >
{% if p.icon %} {% else %}
<img <i class="fa-regular fa-image fa-2x card-image"></i>
class="card-image" {% endif %}
src="{{ p.icon.url }}" <div class="card-content">
alt="image de {{ p.name }}" <h4 class="card-title">{{ price.full_label }}</h4>
> <p>{{ price.amount }} €</p>
{% else %} </div>
<i class="fa-regular fa-image fa-2x card-image"></i> </button>
{% endif %} {% endfor %}
<div class="card-content"> </div>
<h4 class="card-title">{{ p.name }}</h4> </section>
<p>{{ p.selling_price }} €</p>
</div>
</button>
{% endfor %}
</div>
</section>
{% endif %}
{% endfor %}
{% else %} {% else %}
<p>{% trans %}There are no items available for sale{% endtrans %}</p> <p>{% trans %}There are no items available for sale{% endtrans %}</p>
{% endfor %} {% endfor %}

View File

@@ -11,7 +11,12 @@ from pytest_django.asserts import assertRedirects
from core.baker_recipes import subscriber_user from core.baker_recipes import subscriber_user
from core.models import Group, 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 ( from counter.models import (
Counter, Counter,
Customer, Customer,
@@ -147,29 +152,29 @@ class TestEboutic(TestCase):
product_type = baker.make(ProductType) product_type = baker.make(ProductType)
cls.snack = product_recipe.make( cls.snack = price_recipe.make(
selling_price=1.5, special_selling_price=1, product_type=product_type amount=1.5, product=product_recipe.make(product_type=product_type)
) )
cls.beer = product_recipe.make( cls.beer = price_recipe.make(
limit_age=18, product=product_recipe.make(limit_age=18, product_type=product_type),
selling_price=2.5, amount=2.5,
special_selling_price=1,
product_type=product_type,
) )
cls.not_in_counter = product_recipe.make( cls.not_in_counter = price_recipe.make(
selling_price=3.5, product_type=product_type 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_public.prices.add(cls.snack, cls.beer, cls.not_in_counter)
cls.group_cotiz.products.add(cls.cotiz) cls.group_cotiz.prices.add(cls.cotiz)
cls.subscriber.groups.add(cls.group_cotiz, cls.group_public) cls.subscriber.groups.add(cls.group_cotiz, cls.group_public)
cls.new_customer.groups.add(cls.group_public) cls.new_customer.groups.add(cls.group_public)
cls.new_customer_adult.groups.add(cls.group_public) cls.new_customer_adult.groups.add(cls.group_public)
cls.eboutic = get_eboutic() 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 @classmethod
def set_age(cls, user: User, age: int): def set_age(cls, user: User, age: int):
@@ -253,7 +258,7 @@ class TestEboutic(TestCase):
self.submit_basket([BasketItem(self.snack.id, 2)]), self.submit_basket([BasketItem(self.snack.id, 2)]),
reverse("eboutic:checkout", kwargs={"basket_id": 1}), 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) self.client.force_login(self.new_customer_adult)
assertRedirects( assertRedirects(
@@ -263,8 +268,7 @@ class TestEboutic(TestCase):
reverse("eboutic:checkout", kwargs={"basket_id": 2}), reverse("eboutic:checkout", kwargs={"basket_id": 2}),
) )
assert ( assert (
Basket.objects.get(id=2).total Basket.objects.get(id=2).total == self.snack.amount * 2 + self.beer.amount
== self.snack.selling_price * 2 + self.beer.selling_price
) )
self.client.force_login(self.subscriber) self.client.force_login(self.subscriber)
@@ -280,7 +284,5 @@ class TestEboutic(TestCase):
) )
assert ( assert (
Basket.objects.get(id=3).total Basket.objects.get(id=3).total
== self.snack.selling_price * 2 == self.snack.amount * 2 + self.beer.amount + self.cotiz.amount
+ self.beer.selling_price
+ self.cotiz.selling_price
) )

View File

@@ -16,7 +16,7 @@ from model_bakery import baker
from pytest_django.asserts import assertRedirects from pytest_django.asserts import assertRedirects
from core.baker_recipes import old_subscriber_user, subscriber_user 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.models import Product, ProductType, Selling
from counter.tests.test_counter import force_refill_user from counter.tests.test_counter import force_refill_user
from eboutic.models import Basket, BasketItem from eboutic.models import Basket, BasketItem
@@ -32,23 +32,22 @@ class TestPaymentBase(TestCase):
cls.basket = baker.make(Basket, user=cls.customer) cls.basket = baker.make(Basket, user=cls.customer)
cls.refilling = product_recipe.make( cls.refilling = product_recipe.make(
product_type_id=settings.SITH_COUNTER_PRODUCTTYPE_REFILLING, product_type_id=settings.SITH_COUNTER_PRODUCTTYPE_REFILLING,
selling_price=15, prices=[price_recipe.make(amount=15)],
) )
product_type = baker.make(ProductType) product_type = baker.make(ProductType)
cls.snack = product_recipe.make( 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( cls.beer = product_recipe.make(
limit_age=18, limit_age=18,
selling_price=2.5,
special_selling_price=1,
product_type=product_type, product_type=product_type,
prices=[price_recipe.make(amount=2.5)],
) )
BasketItem.from_product(cls.snack, 1, cls.basket).save() BasketItem.from_price(cls.snack.prices.first(), 1, cls.basket).save()
BasketItem.from_product(cls.beer, 2, cls.basket).save() BasketItem.from_price(cls.beer.prices.first(), 2, cls.basket).save()
class TestPaymentSith(TestPaymentBase): class TestPaymentSith(TestPaymentBase):
@@ -116,13 +115,13 @@ class TestPaymentSith(TestPaymentBase):
assert len(sellings) == 2 assert len(sellings) == 2
assert sellings[0].payment_method == Selling.PaymentMethod.SITH_ACCOUNT assert sellings[0].payment_method == Selling.PaymentMethod.SITH_ACCOUNT
assert sellings[0].quantity == 1 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].counter.type == "EBOUTIC"
assert sellings[0].product == self.snack assert sellings[0].product == self.snack
assert sellings[1].payment_method == Selling.PaymentMethod.SITH_ACCOUNT assert sellings[1].payment_method == Selling.PaymentMethod.SITH_ACCOUNT
assert sellings[1].quantity == 2 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].counter.type == "EBOUTIC"
assert sellings[1].product == self.beer assert sellings[1].product == self.beer
@@ -146,7 +145,7 @@ class TestPaymentSith(TestPaymentBase):
) )
def test_refilling_in_basket(self): 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) self.client.force_login(self.customer)
force_refill_user(self.customer, self.basket.total + 1) force_refill_user(self.customer, self.basket.total + 1)
self.customer.customer.refresh_from_db() self.customer.customer.refresh_from_db()
@@ -191,8 +190,8 @@ class TestPaymentCard(TestPaymentBase):
def test_buy_success(self): def test_buy_success(self):
response = self.client.get(self.generate_bank_valid_answer(self.basket)) response = self.client.get(self.generate_bank_valid_answer(self.basket))
assert response.status_code == 200 assert response.status_code == 200
assert response.content.decode("utf-8") == "Payment successful" assert response.content.decode() == "Payment successful"
assert Basket.objects.filter(id=self.basket.id).first() is None assert not Basket.objects.filter(id=self.basket.id).exists()
sellings = Selling.objects.filter(customer=self.customer.customer).order_by( sellings = Selling.objects.filter(customer=self.customer.customer).order_by(
"quantity" "quantity"
@@ -200,13 +199,13 @@ class TestPaymentCard(TestPaymentBase):
assert len(sellings) == 2 assert len(sellings) == 2
assert sellings[0].payment_method == Selling.PaymentMethod.CARD assert sellings[0].payment_method == Selling.PaymentMethod.CARD
assert sellings[0].quantity == 1 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].counter.type == "EBOUTIC"
assert sellings[0].product == self.snack assert sellings[0].product == self.snack
assert sellings[1].payment_method == Selling.PaymentMethod.CARD assert sellings[1].payment_method == Selling.PaymentMethod.CARD
assert sellings[1].quantity == 2 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].counter.type == "EBOUTIC"
assert sellings[1].product == self.beer assert sellings[1].product == self.beer
@@ -216,7 +215,9 @@ class TestPaymentCard(TestPaymentBase):
assert not customer.subscriptions.first().is_valid_now() assert not customer.subscriptions.first().is_valid_now()
basket = baker.make(Basket, user=customer) 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)) response = self.client.get(self.generate_bank_valid_answer(basket))
assert response.status_code == 200 assert response.status_code == 200
@@ -228,12 +229,13 @@ class TestPaymentCard(TestPaymentBase):
assert subscription.location == "EBOUTIC" assert subscription.location == "EBOUTIC"
def test_buy_refilling(self): 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)) response = self.client.get(self.generate_bank_valid_answer(self.basket))
assert response.status_code == 200 assert response.status_code == 200
self.customer.customer.refresh_from_db() 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): def test_multiple_responses(self):
bank_response = self.generate_bank_valid_answer(self.basket) bank_response = self.generate_bank_valid_answer(self.basket)
@@ -253,17 +255,17 @@ class TestPaymentCard(TestPaymentBase):
self.basket.delete() self.basket.delete()
response = self.client.get(bank_response) response = self.client.get(bank_response)
assert response.status_code == 500 assert response.status_code == 500
assert ( assert response.text == (
response.text "Basket processing failed with error: "
== "Basket processing failed with error: SuspiciousOperation('Basket does not exists')" "SuspiciousOperation('Basket does not exists')"
) )
def test_altered_basket(self): def test_altered_basket(self):
bank_response = self.generate_bank_valid_answer(self.basket) 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) response = self.client.get(bank_response)
assert response.status_code == 500 assert response.status_code == 500
assert ( assert response.text == (
response.text == "Basket processing failed with error: " "Basket processing failed with error: "
"SuspiciousOperation('Basket total and amount do not match')" "SuspiciousOperation('Basket total and amount do not match')"
) )

View File

@@ -17,6 +17,7 @@ from __future__ import annotations
import base64 import base64
import contextlib import contextlib
import itertools
import json import json
from typing import TYPE_CHECKING 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.conf import settings
from django.contrib import messages from django.contrib import messages
from django.contrib.auth.decorators import login_required from django.contrib.auth.decorators import login_required
from django.contrib.auth.mixins import ( from django.contrib.auth.mixins import LoginRequiredMixin
LoginRequiredMixin,
)
from django.contrib.messages.views import SuccessMessageMixin from django.contrib.messages.views import SuccessMessageMixin
from django.core.exceptions import SuspiciousOperation, ValidationError from django.core.exceptions import SuspiciousOperation, ValidationError
from django.db import DatabaseError, transaction from django.db import DatabaseError, transaction
@@ -48,23 +47,16 @@ from django_countries.fields import Country
from core.auth.mixins import CanViewMixin from core.auth.mixins import CanViewMixin
from core.views.mixins import FragmentMixin, UseFragmentsMixin 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 ( from counter.models import (
BillingInfo, BillingInfo,
Customer, Customer,
Product, Price,
Refilling, Refilling,
Selling, Selling,
get_eboutic, get_eboutic,
) )
from eboutic.models import ( from eboutic.models import Basket, BasketItem, BillingInfoState, Invoice, InvoiceItem
Basket,
BasketItem,
BillingInfoState,
Invoice,
InvoiceItem,
get_eboutic_products,
)
if TYPE_CHECKING: if TYPE_CHECKING:
from cryptography.hazmat.primitives.asymmetric.rsa import RSAPublicKey from cryptography.hazmat.primitives.asymmetric.rsa import RSAPublicKey
@@ -78,7 +70,7 @@ class BaseEbouticBasketForm(BaseBasketForm):
EbouticBasketForm = forms.formset_factory( 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 The purchasable products are those of the eboutic which
belong to a category of products of a product category belong to a category of products of a product category
(orphan products are inaccessible). (orphan products are inaccessible).
""" """
template_name = "eboutic/eboutic_main.jinja" template_name = "eboutic/eboutic_main.jinja"
@@ -99,7 +90,7 @@ class EbouticMainView(LoginRequiredMixin, FormView):
kwargs["form_kwargs"] = { kwargs["form_kwargs"] = {
"customer": self.customer, "customer": self.customer,
"counter": get_eboutic(), "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 return kwargs
@@ -110,19 +101,25 @@ class EbouticMainView(LoginRequiredMixin, FormView):
with transaction.atomic(): with transaction.atomic():
self.basket = Basket.objects.create(user=self.request.user) self.basket = Basket.objects.create(user=self.request.user)
for form in formset: BasketItem.objects.bulk_create(
BasketItem.from_product( [
form.product, form.cleaned_data["quantity"], self.basket BasketItem.from_price(
).save() form.price, form.cleaned_data["quantity"], self.basket
self.basket.save() )
for form in formset
]
)
return super().form_valid(formset) return super().form_valid(formset)
def get_success_url(self): def get_success_url(self):
return reverse("eboutic:checkout", kwargs={"basket_id": self.basket.id}) return reverse("eboutic:checkout", kwargs={"basket_id": self.basket.id})
@cached_property @cached_property
def products(self) -> list[Product]: def prices(self) -> list[Price]:
return get_eboutic_products(self.request.user) return get_eboutic().get_prices_for(
self.customer,
order_by=["product__product_type__order", "product_id", "amount"],
)
@cached_property @cached_property
def customer(self) -> Customer: def customer(self) -> Customer:
@@ -130,7 +127,12 @@ class EbouticMainView(LoginRequiredMixin, FormView):
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
context = super().get_context_data(**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 context["customer_amount"] = self.request.user.account_balance
purchases = ( purchases = (
@@ -267,11 +269,8 @@ class EbouticPayWithSith(CanViewMixin, SingleObjectMixin, View):
def post(self, request, *args, **kwargs): def post(self, request, *args, **kwargs):
basket = self.get_object() basket = self.get_object()
refilling = settings.SITH_COUNTER_PRODUCTTYPE_REFILLING refilling = settings.SITH_COUNTER_PRODUCTTYPE_REFILLING
if basket.items.filter(type_id=refilling).exists(): if basket.items.filter(product__product_type_id=refilling).exists():
messages.error( messages.error(self.request, _("You can't buy a refilling with sith money"))
self.request,
_("You can't buy a refilling with sith money"),
)
return redirect("eboutic:payment_result", "failure") return redirect("eboutic:payment_result", "failure")
eboutic = get_eboutic() eboutic = get_eboutic()
@@ -326,22 +325,23 @@ class EtransactionAutoAnswer(View):
raise SuspiciousOperation( raise SuspiciousOperation(
"Basket total and amount do not match" "Basket total and amount do not match"
) )
i = Invoice() i = Invoice.objects.create(user=b.user)
i.user = b.user InvoiceItem.objects.bulk_create(
i.payment_method = "CARD" [
i.save() InvoiceItem(
for it in b.items.all(): invoice=i,
InvoiceItem( product_id=item.product_id,
invoice=i, label=item.label,
product_id=it.product_id, unit_price=item.unit_price,
product_name=it.product_name, quantity=item.quantity,
type_id=it.type_id, )
product_unit_price=it.product_unit_price, for item in b.items.all()
quantity=it.quantity, ]
).save() )
i.validate() i.validate()
b.delete() b.delete()
except Exception as e: except Exception as e:
sentry_sdk.capture_exception(e)
return HttpResponse( return HttpResponse(
"Basket processing failed with error: " + repr(e), status=500 "Basket processing failed with error: " + repr(e), status=500
) )

View File

@@ -1,7 +1,6 @@
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from cryptography.utils import cached_property from cryptography.utils import cached_property
from django.conf import settings
from django.contrib import messages from django.contrib import messages
from django.contrib.auth.mixins import ( from django.contrib.auth.mixins import (
LoginRequiredMixin, LoginRequiredMixin,
@@ -115,16 +114,9 @@ class VoteFormView(LoginRequiredMixin, UserPassesTestMixin, FormView):
def test_func(self): def test_func(self):
if not self.election.can_vote(self.request.user): if not self.election.can_vote(self.request.user):
return False return False
return self.election.vote_groups.filter(
groups = set(self.election.vote_groups.values_list("id", flat=True)) id__in=self.request.user.all_groups
if ( ).exists()
settings.SITH_GROUP_SUBSCRIBERS_ID in groups
and self.request.user.is_subscribed
):
# the subscriber group isn't truly attached to users,
# so it must be dealt with separately
return True
return self.request.user.groups.filter(id__in=groups).exists()
def vote(self, election_data): def vote(self, election_data):
with transaction.atomic(): with transaction.atomic():
@@ -238,15 +230,9 @@ class RoleCreateView(LoginRequiredMixin, UserPassesTestMixin, CreateView):
return False return False
if self.request.user.has_perm("election.add_role"): if self.request.user.has_perm("election.add_role"):
return True return True
groups = set(self.election.edit_groups.values_list("id", flat=True)) return self.election.edit_groups.filter(
if ( id__in=self.request.user.all_groups
settings.SITH_GROUP_SUBSCRIBERS_ID in groups ).exists()
and self.request.user.is_subscribed
):
# the subscriber group isn't truly attached to users,
# so it must be dealt with separately
return True
return self.request.user.groups.filter(id__in=groups).exists()
def get_initial(self): def get_initial(self):
return {"election": self.election} return {"election": self.election}
@@ -279,14 +265,7 @@ class ElectionListCreateView(LoginRequiredMixin, UserPassesTestMixin, CreateView
.union(self.election.edit_groups.values("id")) .union(self.election.edit_groups.values("id"))
.values_list("id", flat=True) .values_list("id", flat=True)
) )
if ( return not groups.isdisjoint(self.request.user.all_groups.keys())
settings.SITH_GROUP_SUBSCRIBERS_ID in groups
and self.request.user.is_subscribed
):
# the subscriber group isn't truly attached to users,
# so it must be dealt with separately
return True
return self.request.user.groups.filter(id__in=groups).exists()
def get_initial(self): def get_initial(self):
return {"election": self.election} return {"election": self.election}

View File

@@ -6,7 +6,7 @@
msgid "" msgid ""
msgstr "" msgstr ""
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-02-14 15:21+0100\n" "POT-Creation-Date: 2026-03-07 15:47+0100\n"
"PO-Revision-Date: 2016-07-18\n" "PO-Revision-Date: 2016-07-18\n"
"Last-Translator: Maréchal <thomas.girod@utbm.fr\n" "Last-Translator: Maréchal <thomas.girod@utbm.fr\n"
"Language-Team: AE info <ae.info@utbm.fr>\n" "Language-Team: AE info <ae.info@utbm.fr>\n"
@@ -56,7 +56,7 @@ msgstr "nom"
msgid "owner" msgid "owner"
msgstr "propriétaire" msgstr "propriétaire"
#: api/models.py core/models.py #: api/models.py core/models.py counter/models.py
msgid "groups" msgid "groups"
msgstr "groupes" msgstr "groupes"
@@ -2937,6 +2937,14 @@ msgstr "Cet UID est invalide"
msgid "User not found" msgid "User not found"
msgstr "Utilisateur non trouvé" msgstr "Utilisateur non trouvé"
#: counter/forms.py
msgid ""
"If you want to add a product that is not owned by your club to this counter, "
"you should ask an admin."
msgstr ""
"Si vous souhaitez ajouter sur ce comptoir un produit qui n'appartient pas à "
"votre club, vous devriez demander à un admin."
#: counter/forms.py #: counter/forms.py
msgid "Date and time of action" msgid "Date and time of action"
msgstr "Date et heure de l'action" msgstr "Date et heure de l'action"
@@ -2957,13 +2965,21 @@ msgstr ""
"Décrivez le produit. Si c'est un click pour un évènement, donnez quelques " "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)." "détails dessus, comme la date (en incluant l'année)."
#: counter/forms.py
msgid ""
"The same product cannot be at the same time the result and a part of the "
"formula."
msgstr ""
"Un même produit ne peut pas être à la fois le résultat et un élément de la "
"formule."
#: counter/forms.py #: counter/forms.py
msgid "Refound this account" msgid "Refound this account"
msgstr "Rembourser ce compte" msgstr "Rembourser ce compte"
#: counter/forms.py #: counter/forms.py
msgid "The selected product isn't available for this user" 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 #: counter/forms.py
msgid "Submitted basket is invalid" msgid "Submitted basket is invalid"
@@ -3077,18 +3093,6 @@ msgstr "prix d'achat"
msgid "Initial cost of purchasing the product" msgid "Initial cost of purchasing the product"
msgstr "Coût initial d'achat du produit" 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 #: counter/models.py
msgid "icon" msgid "icon"
msgstr "icône" msgstr "icône"
@@ -3101,6 +3105,10 @@ msgstr "âge limite"
msgid "tray price" msgid "tray price"
msgstr "prix plateau" 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 #: counter/models.py
msgid "buying groups" msgid "buying groups"
msgstr "groupe d'achat" msgstr "groupe d'achat"
@@ -3113,14 +3121,51 @@ msgstr "archivé"
msgid "updated at" msgid "updated at"
msgstr "mis à jour le" msgstr "mis à jour le"
#: counter/models.py #: counter/models.py eboutic/models.py
msgid "product" msgid "product"
msgstr "produit" 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 #: counter/models.py
msgid "products" msgid "products"
msgstr "produits" msgstr "produits"
#: counter/models.py
msgid "The products that constitute this formula."
msgstr "Les produits qui constituent cette formule."
#: counter/models.py
msgid "result product"
msgstr "produit résultat"
#: counter/models.py
msgid "The product got with the formula."
msgstr "Le produit obtenu par la formule."
#: counter/models.py #: counter/models.py
msgid "counter type" msgid "counter type"
msgstr "type de comptoir" msgstr "type de comptoir"
@@ -3541,6 +3586,44 @@ msgstr "Nouveau eticket"
msgid "There is no eticket in this website." msgid "There is no eticket in this website."
msgstr "Il n'y a pas de eticket sur ce site web." msgstr "Il n'y a pas de eticket sur ce site web."
#: counter/templates/counter/formula_list.jinja
msgid "Product formulas"
msgstr "Formules de produits"
#: counter/templates/counter/formula_list.jinja
msgid ""
"Formulas allow you to associate a group of products with a result product "
"(the formula itself)."
msgstr ""
"Les formules permettent d'associer un groupe de produits à un produit "
"résultat (la formule en elle-même)."
#: counter/templates/counter/formula_list.jinja
msgid ""
"If the product of a formula is available on a counter, it will be "
"automatically applied if all the products that make it up are added to the "
"basket."
msgstr ""
"Si le produit d'une formule est disponible sur un comptoir, celle-ci sera "
"automatiquement appliquée si tous les produits qui la constituent sont "
"ajoutés au panier."
#: counter/templates/counter/formula_list.jinja
msgid ""
"For example, if there is a formula that combines a \"Sandwich Formula\" "
"product with the \"Sandwich\" and \"Soft Drink\" products, then, if a person "
"orders a sandwich and a soft drink, the formula will be applied and the "
"basket will then contain a sandwich formula instead."
msgstr ""
"Par exemple s'il existe une formule associant un produit « Formule "
"sandwich » aux produits « Sandwich » et « Soft », alors, si une personne "
"commande un sandwich et un soft, la formule sera appliquée et le panier "
"contiendra alors une formule sandwich à la place."
#: counter/templates/counter/formula_list.jinja
msgid "New formula"
msgstr "Nouvelle formule"
#: counter/templates/counter/fragments/create_student_card.jinja #: counter/templates/counter/fragments/create_student_card.jinja
msgid "No student card registered." msgid "No student card registered."
msgstr "Aucune carte étudiante enregistrée." msgstr "Aucune carte étudiante enregistrée."
@@ -3663,6 +3746,14 @@ msgstr ""
"votre cotisation. Si vous ne renouvelez pas votre cotisation, il n'y aura " "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." "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"
#: counter/templates/counter/product_form.jinja #: counter/templates/counter/product_form.jinja
#, python-format #, python-format
msgid "Edit product %(name)s" msgid "Edit product %(name)s"
@@ -3680,6 +3771,14 @@ msgstr "Dernière mise à jour"
msgid "Product creation" msgid "Product creation"
msgstr "Création de produit" 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 #: counter/templates/counter/product_form.jinja
msgid "Automatic actions" msgid "Automatic actions"
msgstr "Actions automatiques" msgstr "Actions automatiques"
@@ -3690,6 +3789,10 @@ msgstr ""
"Les actions automatiques vous permettent de planifier des modifications du " "Les actions automatiques vous permettent de planifier des modifications du "
"produit à l'avance." "produit à l'avance."
#: counter/templates/counter/product_form.jinja
msgid "Add action"
msgstr "Ajouter une action"
#: counter/templates/counter/product_list.jinja #: counter/templates/counter/product_list.jinja
msgid "Product list" msgid "Product list"
msgstr "Liste des produits" msgstr "Liste des produits"
@@ -3803,6 +3906,20 @@ msgstr "Temps"
msgid "Top 100 barman %(counter_name)s (all semesters)" msgid "Top 100 barman %(counter_name)s (all semesters)"
msgstr "Top 100 barman %(counter_name)s (tous les semestres)" msgstr "Top 100 barman %(counter_name)s (tous les semestres)"
#: counter/views/admin.py
#, python-format
msgid "%(formula)s (formula)"
msgstr "%(formula)s (formule)"
#: counter/views/admin.py
#, python-format
msgid ""
"This action will only delete the formula, but not the %(product)s product "
"itself."
msgstr ""
"Cette action supprimera seulement la formule, mais pas le produit "
"%(product)s en lui-même."
#: counter/views/admin.py #: counter/views/admin.py
#, python-format #, python-format
msgid "returnable product : %(returnable)s -> %(returned)s" msgid "returnable product : %(returnable)s -> %(returned)s"
@@ -3888,6 +4005,10 @@ msgstr "Dernières opérations"
msgid "Counter administration" msgid "Counter administration"
msgstr "Administration des comptoirs" msgstr "Administration des comptoirs"
#: counter/views/mixins.py
msgid "Formulas"
msgstr "Formules"
#: counter/views/mixins.py #: counter/views/mixins.py
msgid "Product types" msgid "Product types"
msgstr "Types de produit" msgstr "Types de produit"
@@ -3905,18 +4026,10 @@ msgstr "validé"
msgid "Invoice already validated" msgid "Invoice already validated"
msgstr "Facture déjà validée" msgstr "Facture déjà validée"
#: eboutic/models.py
msgid "product id"
msgstr "ID du produit"
#: eboutic/models.py #: eboutic/models.py
msgid "product name" msgid "product name"
msgstr "nom du produit" msgstr "nom du produit"
#: eboutic/models.py
msgid "product type id"
msgstr "id du type du produit"
#: eboutic/models.py #: eboutic/models.py
msgid "basket" msgid "basket"
msgstr "panier" msgstr "panier"
@@ -4053,18 +4166,6 @@ msgstr ""
msgid "You can't buy a refilling with sith money" msgid "You can't buy a refilling with sith money"
msgstr "Vous ne pouvez pas acheter un rechargement avec de l'argent du sith" msgstr "Vous ne pouvez pas acheter un rechargement avec de l'argent du sith"
#: eboutic/views.py
msgid "I have read and accept"
msgstr "J'ai lu et j'accepte"
#: eboutic/views.py
msgid "the general terms and conditions"
msgstr "les conditions générales de vente"
#: eboutic/views.py
msgid "of the student assosiation of UTBM"
msgstr "de l'Association des étudiants de l'UTBM"
#: election/forms.py #: election/forms.py
msgid "You have selected too many candidates." msgid "You have selected too many candidates."
msgstr "Vous avez sélectionné trop de candidats." msgstr "Vous avez sélectionné trop de candidats."

View File

@@ -7,7 +7,7 @@
msgid "" msgid ""
msgstr "" msgstr ""
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-08-23 15:30+0200\n" "POT-Creation-Date: 2025-11-26 15:45+0100\n"
"PO-Revision-Date: 2024-09-17 11:54+0200\n" "PO-Revision-Date: 2024-09-17 11:54+0200\n"
"Last-Translator: Sli <antoine@bartuccio.fr>\n" "Last-Translator: Sli <antoine@bartuccio.fr>\n"
"Language-Team: AE info <ae.info@utbm.fr>\n" "Language-Team: AE info <ae.info@utbm.fr>\n"
@@ -206,6 +206,10 @@ msgstr "capture.%s"
msgid "Not enough money" msgid "Not enough money"
msgstr "Pas assez d'argent" msgstr "Pas assez d'argent"
#: counter/static/bundled/counter/counter-click-index.ts
msgid "Formula %(formula)s applied"
msgstr "Formule %(formula)s appliquée"
#: counter/static/bundled/counter/counter-click-index.ts #: counter/static/bundled/counter/counter-click-index.ts
msgid "You can't send an empty basket." msgid "You can't send an empty basket."
msgstr "Vous ne pouvez pas envoyer un panier vide." msgstr "Vous ne pouvez pas envoyer un panier vide."
@@ -262,3 +266,9 @@ msgstr "Il n'a pas été possible de modérer l'image"
#: sas/static/bundled/sas/viewer-index.ts #: sas/static/bundled/sas/viewer-index.ts
msgid "Couldn't delete picture" msgid "Couldn't delete picture"
msgstr "Il n'a pas été possible de supprimer l'image" msgstr "Il n'a pas été possible de supprimer l'image"
#: timetable/static/bundled/timetable/generator-index.ts
msgid ""
"Wrong timetable format. Make sure you copied if from your student folder."
msgstr ""
"Mauvais format d'emploi du temps. Assurez-vous que vous l'avez copié depuis votre dossier étudiants."

View File

@@ -33,8 +33,6 @@ class TestMergeUser(TestCase):
cls.club = baker.make(Club) cls.club = baker.make(Club)
cls.eboutic = Counter.objects.get(name="Eboutic") cls.eboutic = Counter.objects.get(name="Eboutic")
cls.barbar = Product.objects.get(code="BARB") cls.barbar = Product.objects.get(code="BARB")
cls.barbar.selling_price = 2
cls.barbar.save()
cls.root = User.objects.get(username="root") cls.root = User.objects.get(username="root")
cls.to_keep = User.objects.create( cls.to_keep = User.objects.create(
username="to_keep", password="plop", email="u.1@utbm.fr" username="to_keep", password="plop", email="u.1@utbm.fr"

View File

@@ -109,232 +109,225 @@ interface ViewerConfig {
/** id of the first picture to load on the page */ /** id of the first picture to load on the page */
firstPictureId: number; firstPictureId: number;
/** if the user is sas admin */ /** if the user is sas admin */
userIsSasAdmin: boolean; userCanModerate: boolean;
} }
/** /**
* Load user picture page with a nice download bar * Load user picture page with a nice download bar
**/ **/
exportToHtml("loadViewer", (config: ViewerConfig) => { document.addEventListener("alpine:init", () => {
document.addEventListener("alpine:init", () => { Alpine.data("picture_viewer", (config: ViewerConfig) => ({
Alpine.data("picture_viewer", () => ({ /**
/** * All the pictures that can be displayed on this picture viewer
* All the pictures that can be displayed on this picture viewer **/
**/ pictures: [] as PictureWithIdentifications[],
pictures: [] as PictureWithIdentifications[], /**
/** * The currently displayed picture
* The currently displayed picture * Default dummy data are pre-loaded to avoid javascript error
* Default dummy data are pre-loaded to avoid javascript error * when loading the page at the beginning
* when loading the page at the beginning * @type PictureWithIdentifications
* @type PictureWithIdentifications **/
**/ currentPicture: {
currentPicture: { // biome-ignore lint/style/useNamingConvention: api is in snake_case
// biome-ignore lint/style/useNamingConvention: api is in snake_case is_moderated: true,
is_moderated: true, id: null as number,
id: null as number, name: "",
name: "", // biome-ignore lint/style/useNamingConvention: api is in snake_case
// biome-ignore lint/style/useNamingConvention: api is in snake_case display_name: "",
display_name: "", // biome-ignore lint/style/useNamingConvention: api is in snake_case
// biome-ignore lint/style/useNamingConvention: api is in snake_case compressed_url: "",
compressed_url: "", // biome-ignore lint/style/useNamingConvention: api is in snake_case
// biome-ignore lint/style/useNamingConvention: api is in snake_case profile_url: "",
profile_url: "", // biome-ignore lint/style/useNamingConvention: api is in snake_case
// biome-ignore lint/style/useNamingConvention: api is in snake_case full_size_url: "",
full_size_url: "", owner: "",
owner: "", date: new Date(),
date: new Date(), identifications: [] as IdentifiedUserSchema[],
identifications: [] as IdentifiedUserSchema[], },
}, /**
/** * The picture which will be displayed next if the user press the "next" button
* The picture which will be displayed next if the user press the "next" button **/
**/ nextPicture: null as PictureWithIdentifications,
nextPicture: null as PictureWithIdentifications, /**
/** * The picture which will be displayed next if the user press the "previous" button
* The picture which will be displayed next if the user press the "previous" button **/
**/ previousPicture: null as PictureWithIdentifications,
previousPicture: null as PictureWithIdentifications, /**
/** * The select2 component used to identify users
* The select2 component used to identify users **/
**/ selector: undefined as UserAjaxSelect,
selector: undefined as UserAjaxSelect, /**
/** * Error message when a moderation operation fails
* Error message when a moderation operation fails **/
**/ moderationError: "",
moderationError: "", /**
/** * Method of pushing new url to the browser history
* Method of pushing new url to the browser history * Used by popstate event and always reset to it's default value when used
* Used by popstate event and always reset to it's default value when used **/
**/ pushstate: History.Push,
pushstate: History.Push,
async init() { async init() {
this.pictures = ( this.pictures = (
await paginated(picturesFetchPictures, { await paginated(picturesFetchPictures, {
// biome-ignore lint/style/useNamingConvention: api is in snake_case // biome-ignore lint/style/useNamingConvention: api is in snake_case
query: { album_id: config.albumId }, query: { album_id: config.albumId },
} as PicturesFetchPicturesData) } as PicturesFetchPicturesData)
).map(PictureWithIdentifications.fromPicture); ).map(PictureWithIdentifications.fromPicture);
this.selector = this.$refs.search; this.selector = this.$refs.search;
this.selector.setFilter((users: UserProfileSchema[]) => { this.selector.setFilter((users: UserProfileSchema[]) => {
const resp: UserProfileSchema[] = []; const resp: UserProfileSchema[] = [];
const ids = [ const ids = [
...(this.currentPicture.identifications || []).map( ...(this.currentPicture.identifications || []).map(
(i: IdentifiedUserSchema) => i.user.id, (i: IdentifiedUserSchema) => i.user.id,
), ),
]; ];
for (const user of users) { for (const user of users) {
if (!ids.includes(user.id)) { if (!ids.includes(user.id)) {
resp.push(user); resp.push(user);
}
} }
return resp; }
}); return resp;
this.currentPicture = this.pictures.find( });
(i: PictureSchema) => i.id === config.firstPictureId, this.currentPicture = this.pictures.find(
); (i: PictureSchema) => i.id === config.firstPictureId,
this.$watch( );
"currentPicture", this.$watch(
(current: PictureSchema, previous: PictureSchema) => { "currentPicture",
if (current === previous) { (current: PictureSchema, previous: PictureSchema) => {
/* Avoid recursive updates */ if (current === previous) {
return; /* Avoid recursive updates */
}
this.updatePicture();
},
);
window.addEventListener("popstate", async (event) => {
if (!event.state || event.state.sasPictureId === undefined) {
return; return;
} }
this.pushstate = History.Replace; this.updatePicture();
this.currentPicture = this.pictures.find( },
(i: PictureSchema) => );
i.id === Number.parseInt(event.state.sasPictureId, 10), window.addEventListener("popstate", async (event) => {
); if (!event.state || event.state.sasPictureId === undefined) {
});
this.pushstate = History.Replace; /* Avoid first url push */
await this.updatePicture();
},
/**
* Update the page.
* Called when the `currentPicture` property changes.
*
* The url is modified without reloading the page,
* and the previous picture, the next picture and
* the list of identified users are updated.
*/
async updatePicture(): Promise<void> {
const updateArgs = {
data: { sasPictureId: this.currentPicture.id },
unused: "",
url: this.currentPicture.sas_url,
};
if (this.pushstate === History.Replace) {
window.history.replaceState(
updateArgs.data,
updateArgs.unused,
updateArgs.url,
);
this.pushstate = History.Push;
} else {
window.history.pushState(updateArgs.data, updateArgs.unused, updateArgs.url);
}
this.moderationError = "";
const index: number = this.pictures.indexOf(this.currentPicture);
this.previousPicture = this.pictures[index - 1] || null;
this.nextPicture = this.pictures[index + 1] || null;
this.$refs.mainPicture?.addEventListener("load", () => {
// once the current picture is loaded,
// start preloading the next and previous pictures
this.nextPicture?.preload();
this.previousPicture?.preload();
});
if (this.currentPicture.asked_for_removal && config.userIsSasAdmin) {
await Promise.all([
this.currentPicture.loadIdentifications(),
this.currentPicture.loadModeration(),
]);
} else {
await this.currentPicture.loadIdentifications();
}
},
async moderatePicture() {
const res = await picturesModeratePicture({
// biome-ignore lint/style/useNamingConvention: api is in snake_case
path: { picture_id: this.currentPicture.id },
});
if (res.error) {
this.moderationError = `${gettext("Couldn't moderate picture")} : ${(res.error as { detail: string }).detail}`;
return; return;
} }
this.currentPicture.is_moderated = true; this.pushstate = History.Replace;
this.currentPicture.asked_for_removal = false; this.currentPicture = this.pictures.find(
}, (i: PictureSchema) => i.id === Number.parseInt(event.state.sasPictureId, 10),
);
});
this.pushstate = History.Replace; /* Avoid first url push */
await this.updatePicture();
},
async deletePicture() { /**
const res = await picturesDeletePicture({ * Update the page.
* Called when the `currentPicture` property changes.
*
* The url is modified without reloading the page,
* and the previous picture, the next picture and
* the list of identified users are updated.
*/
async updatePicture(): Promise<void> {
const updateArgs = {
data: { sasPictureId: this.currentPicture.id },
unused: "",
url: this.currentPicture.sas_url,
};
if (this.pushstate === History.Replace) {
window.history.replaceState(updateArgs.data, updateArgs.unused, updateArgs.url);
this.pushstate = History.Push;
} else {
window.history.pushState(updateArgs.data, updateArgs.unused, updateArgs.url);
}
this.moderationError = "";
const index: number = this.pictures.indexOf(this.currentPicture);
this.previousPicture = this.pictures[index - 1] || null;
this.nextPicture = this.pictures[index + 1] || null;
this.$refs.mainPicture?.addEventListener("load", () => {
// once the current picture is loaded,
// start preloading the next and previous pictures
this.nextPicture?.preload();
this.previousPicture?.preload();
});
if (this.currentPicture.asked_for_removal && config.userCanModerate) {
await Promise.all([
this.currentPicture.loadIdentifications(),
this.currentPicture.loadModeration(),
]);
} else {
await this.currentPicture.loadIdentifications();
}
},
async moderatePicture() {
const res = await picturesModeratePicture({
// biome-ignore lint/style/useNamingConvention: api is in snake_case
path: { picture_id: this.currentPicture.id },
});
if (res.error) {
this.moderationError = `${gettext("Couldn't moderate picture")} : ${(res.error as { detail: string }).detail}`;
return;
}
this.currentPicture.is_moderated = true;
this.currentPicture.asked_for_removal = false;
},
async deletePicture() {
const res = await picturesDeletePicture({
// biome-ignore lint/style/useNamingConvention: api is in snake_case
path: { picture_id: this.currentPicture.id },
});
if (res.error) {
this.moderationError = `${gettext("Couldn't delete picture")} : ${(res.error as { detail: string }).detail}`;
return;
}
this.pictures.splice(this.pictures.indexOf(this.currentPicture), 1);
if (this.pictures.length === 0) {
// The deleted picture was the only one in the list.
// As the album is now empty, go back to the parent page
document.location.href = config.albumUrl;
}
this.currentPicture = this.nextPicture || this.previousPicture;
},
/**
* Send the identification request and update the list of identified users.
*/
async submitIdentification(): Promise<void> {
const widget: TomSelect = this.selector.widget;
await picturesIdentifyUsers({
path: {
// biome-ignore lint/style/useNamingConvention: api is in snake_case // biome-ignore lint/style/useNamingConvention: api is in snake_case
path: { picture_id: this.currentPicture.id }, picture_id: this.currentPicture.id,
}); },
if (res.error) { body: widget.items.map((i: string) => Number.parseInt(i, 10)),
this.moderationError = `${gettext("Couldn't delete picture")} : ${(res.error as { detail: string }).detail}`; });
return; // refresh the identified users list
} await this.currentPicture.loadIdentifications({ forceReload: true });
this.pictures.splice(this.pictures.indexOf(this.currentPicture), 1);
if (this.pictures.length === 0) {
// The deleted picture was the only one in the list.
// As the album is now empty, go back to the parent page
document.location.href = config.albumUrl;
}
this.currentPicture = this.nextPicture || this.previousPicture;
},
/** // Clear selection and cache of retrieved user so they can be filtered again
* Send the identification request and update the list of identified users. widget.clear(false);
*/ widget.clearOptions();
async submitIdentification(): Promise<void> { widget.setTextboxValue("");
const widget: TomSelect = this.selector.widget; },
await picturesIdentifyUsers({
path: {
// biome-ignore lint/style/useNamingConvention: api is in snake_case
picture_id: this.currentPicture.id,
},
body: widget.items.map((i: string) => Number.parseInt(i, 10)),
});
// refresh the identified users list
await this.currentPicture.loadIdentifications({ forceReload: true });
// Clear selection and cache of retrieved user so they can be filtered again /**
widget.clear(false); * Check if an identification can be removed by the currently logged user
widget.clearOptions(); */
widget.setTextboxValue(""); canBeRemoved(identification: IdentifiedUserSchema): boolean {
}, return config.userCanModerate || identification.user.id === config.userId;
},
/** /**
* Check if an identification can be removed by the currently logged user * Untag a user from the current picture
*/ */
canBeRemoved(identification: IdentifiedUserSchema): boolean { async removeIdentification(identification: IdentifiedUserSchema): Promise<void> {
return config.userIsSasAdmin || identification.user.id === config.userId; const res = await usersidentifiedDeleteRelation({
}, // biome-ignore lint/style/useNamingConvention: api is in snake_case
path: { relation_id: identification.id },
/** });
* Untag a user from the current picture if (!res.error && Array.isArray(this.currentPicture.identifications)) {
*/ this.currentPicture.identifications =
async removeIdentification(identification: IdentifiedUserSchema): Promise<void> { this.currentPicture.identifications.filter(
const res = await usersidentifiedDeleteRelation({ (i: IdentifiedUserSchema) => i.id !== identification.id,
// biome-ignore lint/style/useNamingConvention: api is in snake_case );
path: { relation_id: identification.id }, }
}); },
if (!res.error && Array.isArray(this.currentPicture.identifications)) { }));
this.currentPicture.identifications =
this.currentPicture.identifications.filter(
(i: IdentifiedUserSchema) => i.id !== identification.id,
);
}
},
}));
});
}); });

View File

@@ -17,10 +17,8 @@
{% from "sas/macros.jinja" import print_path %} {% from "sas/macros.jinja" import print_path %}
{% set user_is_sas_admin = user.is_root or user.is_in_group(pk = settings.SITH_GROUP_SAS_ADMIN_ID) %}
{% block content %} {% block content %}
<main x-data="picture_viewer"> <main x-data="picture_viewer(config)">
<code> <code>
<a href="{{ url('sas:main') }}">SAS</a> / {{ print_path(album) }} <span x-text="currentPicture.name"></span> <a href="{{ url('sas:main') }}">SAS</a> / {{ print_path(album) }} <span x-text="currentPicture.name"></span>
</code> </code>
@@ -50,15 +48,13 @@
It will be hidden to other users until it has been moderated. It will be hidden to other users until it has been moderated.
{% endtrans %} {% endtrans %}
</p> </p>
{% if user_is_sas_admin %} {% if user.has_perm("sas.moderate_sasfile") %}
<template x-if="currentPicture.asked_for_removal"> <template x-if="currentPicture.asked_for_removal">
<div> <div>
<h5>{% trans %}The following issues have been raised:{% endtrans %}</h5> <h5>{% trans %}The following issues have been raised:{% endtrans %}</h5>
<template x-for="req in (currentPicture.moderationRequests ?? [])" :key="req.id"> <template x-for="req in (currentPicture.moderationRequests ?? [])" :key="req.id">
<div> <div>
<h6 <h6 x-text="`${req.author.first_name} ${req.author.last_name}`"></h6>
x-text="`${req.author.first_name} ${req.author.last_name}`"
></h6>
<i x-text="Intl.DateTimeFormat( <i x-text="Intl.DateTimeFormat(
'{{ LANGUAGE_CODE }}', '{{ LANGUAGE_CODE }}',
{dateStyle: 'long', timeStyle: 'short'} {dateStyle: 'long', timeStyle: 'short'}
@@ -70,7 +66,7 @@
</template> </template>
{% endif %} {% endif %}
</div> </div>
{% if user_is_sas_admin %} {% if user.has_perm("sas.moderate_sasfile") %}
<div class="alert-aside"> <div class="alert-aside">
<button class="btn btn-blue" @click="moderatePicture()"> <button class="btn btn-blue" @click="moderatePicture()">
{% trans %}Moderate{% endtrans %} {% trans %}Moderate{% endtrans %}
@@ -204,16 +200,13 @@
{% endblock %} {% endblock %}
{% block script %} {% block script %}
{{ super() }}
<script> <script>
window.addEventListener("DOMContentLoaded", () => { const config = {
loadViewer({ albumId: {{ album.id }},
albumId: {{ album.id }} , albumUrl: "{{ album.get_absolute_url() }}",
albumUrl: "{{ album.get_absolute_url() }}", firstPictureId: {{ picture.id }}, {# id of the first picture to show after page load #}
firstPictureId: {{ picture.id }}, {# id of the first picture to show after page load #} userId: {{ user.id }},
userId: {{ user.id }}, userCanModerate: {{ user.has_perm("sas.moderate_sasfile")|tojson }}
userIsSasAdmin: {{ user_is_sas_admin|tojson }} }
});
})
</script> </script>
{% endblock %} {% endblock %}

View File

@@ -161,16 +161,22 @@ class TestSasModeration(TestCase):
assert len(res.context_data["pictures"]) == 1 assert len(res.context_data["pictures"]) == 1
assert res.context_data["pictures"][0] == self.to_moderate assert res.context_data["pictures"][0] == self.to_moderate
res = self.client.post(
reverse("sas:moderation"),
data={"album_id": self.to_moderate.id, "picture_id": self.to_moderate.id},
)
def test_moderation_page_forbidden(self): def test_moderation_page_forbidden(self):
self.client.force_login(self.simple_user) self.client.force_login(self.simple_user)
res = self.client.get(reverse("sas:moderation")) res = self.client.get(reverse("sas:moderation"))
assert res.status_code == 403 assert res.status_code == 403
def test_moderate_album(self):
self.client.force_login(self.moderator)
url = reverse("sas:moderation")
album = baker.make(
Album, is_moderated=False, parent_id=settings.SITH_SAS_ROOT_DIR_ID
)
res = self.client.post(url, data={"album_id": album.id, "moderate": ""})
assertRedirects(res, url)
album.refresh_from_db()
assert album.is_moderated
def test_moderate_picture(self): def test_moderate_picture(self):
self.client.force_login(self.moderator) self.client.force_login(self.moderator)
res = self.client.get( res = self.client.get(

View File

@@ -15,10 +15,10 @@
from typing import Any from typing import Any
from django.conf import settings from django.conf import settings
from django.core.exceptions import PermissionDenied from django.contrib.auth.mixins import PermissionRequiredMixin
from django.db.models import Count, OuterRef, Subquery from django.db.models import Count, OuterRef, Subquery
from django.http import Http404, HttpResponseRedirect from django.http import Http404, HttpResponseRedirect
from django.shortcuts import get_object_or_404 from django.shortcuts import get_object_or_404, redirect
from django.urls import reverse from django.urls import reverse
from django.utils.safestring import SafeString from django.utils.safestring import SafeString
from django.views.generic import CreateView, DetailView, TemplateView from django.views.generic import CreateView, DetailView, TemplateView
@@ -191,26 +191,21 @@ class UserPicturesView(UserTabsMixin, CanViewMixin, DetailView):
# Admin views # Admin views
class ModerationView(TemplateView): class ModerationView(PermissionRequiredMixin, TemplateView):
template_name = "sas/moderation.jinja" template_name = "sas/moderation.jinja"
permission_required = "sas.moderate_sasfile"
def get(self, request, *args, **kwargs):
if request.user.is_in_group(pk=settings.SITH_GROUP_SAS_ADMIN_ID):
return super().get(request, *args, **kwargs)
raise PermissionDenied
def post(self, request, *args, **kwargs): def post(self, request, *args, **kwargs):
if "album_id" not in request.POST: if "album_id" not in request.POST:
raise Http404 raise Http404
if request.user.is_in_group(pk=settings.SITH_GROUP_SAS_ADMIN_ID): album = get_object_or_404(Album, pk=request.POST["album_id"])
album = get_object_or_404(Album, pk=request.POST["album_id"]) if "moderate" in request.POST:
if "moderate" in request.POST: album.moderator = request.user
album.moderator = request.user album.is_moderated = True
album.is_moderated = True album.save()
album.save() elif "delete" in request.POST:
elif "delete" in request.POST: album.delete()
album.delete() return redirect(self.request.path)
return super().get(request, *args, **kwargs)
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
kwargs = super().get_context_data(**kwargs) kwargs = super().get_context_data(**kwargs)

View File

@@ -551,27 +551,27 @@ SITH_SUBSCRIPTIONS = {
# Discount subscriptions # Discount subscriptions
"un-semestre-reduction": { "un-semestre-reduction": {
"name": _("One semester (-20%)"), "name": _("One semester (-20%)"),
"price": 12, "price": 16,
"duration": 1, "duration": 1,
}, },
"deux-semestres-reduction": { "deux-semestres-reduction": {
"name": _("Two semesters (-20%)"), "name": _("Two semesters (-20%)"),
"price": 22, "price": 28,
"duration": 2, "duration": 2,
}, },
"cursus-tronc-commun-reduction": { "cursus-tronc-commun-reduction": {
"name": _("Common core cursus (-20%)"), "name": _("Common core cursus (-20%)"),
"price": 36, "price": 48,
"duration": 4, "duration": 4,
}, },
"cursus-branche-reduction": { "cursus-branche-reduction": {
"name": _("Branch cursus (-20%)"), "name": _("Branch cursus (-20%)"),
"price": 36, "price": 48,
"duration": 6, "duration": 6,
}, },
"cursus-alternant-reduction": { "cursus-alternant-reduction": {
"name": _("Alternating cursus (-20%)"), "name": _("Alternating cursus (-20%)"),
"price": 24, "price": 28,
"duration": 6, "duration": 6,
}, },
# CA special offer # CA special offer