5 Commits

Author SHA1 Message Date
imperosol
2922725fe4 sqsdqd 2026-03-09 09:25:13 +01:00
imperosol
2796f9b213 WIP 2026-03-09 09:25:13 +01:00
imperosol
f26a47c48d get_list_exact_or_404 util function 2026-03-09 09:25:13 +01:00
imperosol
3cf843a197 fix some tests 2026-03-09 09:25:13 +01:00
imperosol
228902e5e0 Migrate albums and pictures to their own tables 2026-03-09 09:25:13 +01:00
73 changed files with 3121 additions and 2025 deletions

View File

@@ -6,15 +6,9 @@ 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, CanView, HasPerm from api.permissions import CanAccessLookup, HasPerm
from club.models import Club, Membership from club.models import Club, Membership
from club.schemas import ( from club.schemas import ClubSchema, ClubSearchFilterSchema, SimpleClubSchema
ClubSchema,
ClubSearchFilterSchema,
SimpleClubSchema,
UserMembershipSchema,
)
from core.models import User
@api_controller("/club") @api_controller("/club")
@@ -44,22 +38,3 @@ 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,8 +40,6 @@ 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"]
@@ -55,13 +53,3 @@ 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

@@ -1,50 +0,0 @@
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

@@ -98,9 +98,9 @@ class PageAdmin(admin.ModelAdmin):
@admin.register(SithFile) @admin.register(SithFile)
class SithFileAdmin(admin.ModelAdmin): class SithFileAdmin(admin.ModelAdmin):
list_display = ("name", "owner", "size", "date", "is_in_sas") list_display = ("name", "owner", "size", "date")
autocomplete_fields = ("parent", "owner", "moderator") autocomplete_fields = ("parent", "owner", "moderator")
search_fields = ("name", "parent__name") search_fields = ("name",)
@admin.register(OperationLog) @admin.register(OperationLog)

View File

@@ -110,7 +110,7 @@ class SithFileController(ControllerBase):
) )
@paginate(PageNumberPaginationExtra, page_size=50) @paginate(PageNumberPaginationExtra, page_size=50)
def search_files(self, search: Annotated[str, MinLen(1)]): def search_files(self, search: Annotated[str, MinLen(1)]):
return SithFile.objects.filter(is_in_sas=False).filter(name__icontains=search) return SithFile.objects.filter(name__icontains=search)
@api_controller("/group") @api_controller("/group")
@@ -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).order_by("name").values() return Group.objects.filter(name__icontains=search).values()
DepthValue = Annotated[int, Ge(0), Le(10)] DepthValue = Annotated[int, Ge(0), Le(10)]

View File

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

View File

@@ -41,14 +41,7 @@ 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 ( from counter.models import Counter, Product, ProductType, ReturnableProduct, StudentCard
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
@@ -117,7 +110,6 @@ class Command(BaseCommand):
p.save(force_lock=True) p.save(force_lock=True)
club_root = SithFile.objects.create(name="clubs", owner=root) club_root = SithFile.objects.create(name="clubs", owner=root)
sas = SithFile.objects.create(name="SAS", owner=root)
main_club = Club.objects.create( main_club = Club.objects.create(
id=1, name="AE", address="6 Boulevard Anatole France, 90000 Belfort" id=1, name="AE", address="6 Boulevard Anatole France, 90000 Belfort"
) )
@@ -375,15 +367,125 @@ class Command(BaseCommand):
end_date=localdate() - timedelta(days=100), end_date=localdate() - timedelta(days=100),
) )
self._create_products(groups, main_club, refound) p = ProductType.objects.create(name="Bières bouteilles")
c = ProductType.objects.create(name="Cotisations")
r = ProductType.objects.create(name="Rechargements")
verre = ProductType.objects.create(name="Verre")
cotis = Product.objects.create(
name="Cotis 1 semestre",
code="1SCOTIZ",
product_type=c,
purchase_price="15",
selling_price="15",
special_selling_price="15",
club=main_club,
)
cotis2 = Product.objects.create(
name="Cotis 2 semestres",
code="2SCOTIZ",
product_type=c,
purchase_price="28",
selling_price="28",
special_selling_price="28",
club=main_club,
)
refill = Product.objects.create(
name="Rechargement 15 €",
code="15REFILL",
product_type=r,
purchase_price="15",
selling_price="15",
special_selling_price="15",
club=main_club,
)
barb = Product.objects.create(
name="Barbar",
code="BARB",
product_type=p,
purchase_price="1.50",
selling_price="1.7",
special_selling_price="1.6",
club=main_club,
limit_age=18,
)
cble = Product.objects.create(
name="Chimay Bleue",
code="CBLE",
product_type=p,
purchase_price="1.50",
selling_price="1.7",
special_selling_price="1.6",
club=main_club,
limit_age=18,
)
cons = Product.objects.create(
name="Consigne Eco-cup",
code="CONS",
product_type=verre,
purchase_price="1",
selling_price="1",
special_selling_price="1",
club=main_club,
)
dcons = Product.objects.create(
name="Déconsigne Eco-cup",
code="DECO",
product_type=verre,
purchase_price="-1",
selling_price="-1",
special_selling_price="-1",
club=main_club,
)
cors = Product.objects.create(
name="Corsendonk",
code="CORS",
product_type=p,
purchase_price="1.50",
selling_price="1.7",
special_selling_price="1.6",
club=main_club,
limit_age=18,
)
carolus = Product.objects.create(
name="Carolus",
code="CARO",
product_type=p,
purchase_price="1.50",
selling_price="1.7",
special_selling_price="1.6",
club=main_club,
limit_age=18,
)
Product.objects.create(
name="remboursement",
code="REMBOURS",
purchase_price="0",
selling_price="0",
special_selling_price="0",
club=refound,
)
groups.subscribers.products.add(
cotis, cotis2, refill, barb, cble, cors, carolus
)
groups.old_subscribers.products.add(cotis, cotis2)
mde = Counter.objects.get(name="MDE")
mde.products.add(barb, cble, cons, dcons)
eboutic = Counter.objects.get(name="Eboutic")
eboutic.products.add(barb, cotis, cotis2, refill)
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=1, user=skia), # MDE Counter.sellers.through(counter_id=2, user=krophil),
Counter.sellers.through(counter_id=2, user=krophil), # Foyer Counter.sellers.through(counter=mde, user=skia),
] ]
) )
@@ -591,33 +693,21 @@ class Command(BaseCommand):
# SAS # SAS
for f in self.SAS_FIXTURE_PATH.glob("*"): for f in self.SAS_FIXTURE_PATH.glob("*"):
if f.is_dir(): if f.is_dir():
album = Album( album = Album.objects.create(name=f.name, is_moderated=True)
parent=sas,
name=f.name,
owner=root,
is_folder=True,
is_in_sas=True,
is_moderated=True,
)
album.clean()
album.save()
for p in f.iterdir(): for p in f.iterdir():
file = resize_image(Image.open(p), 1000, "WEBP") file = resize_image(Image.open(p), 1000, "WEBP")
pict = Picture( pict = Picture(
parent=album, parent=album,
name=p.name, name=p.name,
file=file, original=file,
owner=root, owner=root,
is_folder=False,
is_in_sas=True,
is_moderated=True, is_moderated=True,
mime_type="image/webp",
size=file.size,
) )
pict.file.name = p.name pict.original.name = pict.name
pict.full_clean()
pict.generate_thumbnails() pict.generate_thumbnails()
pict.full_clean()
pict.save() pict.save()
album.generate_thumbnail()
img_skia = Picture.objects.get(name="skia.jpg") img_skia = Picture.objects.get(name="skia.jpg")
img_sli = Picture.objects.get(name="sli.jpg") img_sli = Picture.objects.get(name="sli.jpg")
@@ -639,131 +729,6 @@ 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,7 +17,6 @@ from counter.models import (
Counter, Counter,
Customer, Customer,
Permanency, Permanency,
Price,
Product, Product,
ProductType, ProductType,
Refilling, Refilling,
@@ -279,7 +278,6 @@ 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):
@@ -290,28 +288,25 @@ 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=self.faker.boolean(60), archived=bool(random.random() > 0.7),
) )
products.append(product) products.append(product)
for i in range(random.randint(0, 3)): # there will be products without buying groups
product_price = Price( # but there are also such products in the real database
amount=price, product=product, is_always_shown=self.faker.boolean() buying_groups.extend(
) Product.buying_groups.through(product=product, group=group)
# prices for non-subscribers will be higher than for subscribers for group in random.sample(groups, k=random.randint(0, 3))
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)
Price.objects.bulk_create(prices) Product.buying_groups.through.objects.bulk_create(buying_groups)
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]):
@@ -325,7 +320,7 @@ class Command(BaseCommand):
) )
) )
) )
prices = list(Price.objects.select_related("product").all()) products = list(Product.objects.all())
counters = list( counters = list(
Counter.objects.filter(name__in=["Foyer", "MDE", "La Gommette"]) Counter.objects.filter(name__in=["Foyer", "MDE", "La Gommette"])
) )
@@ -335,14 +330,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_prices = random.sample(prices, k=(random.randint(1, 5))) favoured_products = random.sample(products, 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):
price = ( product = (
random.choice(favoured_prices) random.choice(favoured_products)
if random.random() > 0.7 if random.random() > 0.7
else random.choice(prices) else random.choice(products)
) )
counter = ( counter = (
favoured_counter favoured_counter
@@ -351,11 +346,11 @@ class Command(BaseCommand):
) )
this_customer_sales.append( this_customer_sales.append(
Selling( Selling(
product=price.product, product=product,
counter=counter, counter=counter,
club_id=price.product.club_id, club_id=product.club_id,
quantity=random.randint(1, 5), quantity=random.randint(1, 5),
unit_price=price.amount, unit_price=product.selling_price,
seller=random.choice(sellers), seller=random.choice(sellers),
customer=customer, customer=customer,
date=make_aware( date=make_aware(

View File

@@ -0,0 +1,27 @@
# Generated by Django 4.2.17 on 2025-01-26 15:01
from typing import TYPE_CHECKING
from django.db import migrations
from django.db.migrations.state import StateApps
if TYPE_CHECKING:
import core.models
def remove_sas_sithfiles(apps: StateApps, schema_editor):
SithFile: type[core.models.SithFile] = apps.get_model("core", "SithFile")
SithFile.objects.filter(is_in_sas=True).delete()
class Migration(migrations.Migration):
dependencies = [
("core", "0048_alter_user_options"),
("sas", "0007_alter_peoplepicturerelation_picture_and_more"),
]
operations = [
migrations.RunPython(
remove_sas_sithfiles, reverse_code=migrations.RunPython.noop, elidable=True
)
]

View File

@@ -0,0 +1,9 @@
# Generated by Django 4.2.17 on 2025-02-14 11:58
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [("core", "0049_remove_sithfiles")]
operations = [migrations.RemoveField(model_name="sithfile", name="is_in_sas")]

View File

@@ -356,27 +356,23 @@ class User(AbstractUser):
) )
if group_id is None: if group_id is None:
return False return False
return group_id in self.all_groups if group_id == settings.SITH_GROUP_SUBSCRIBERS_ID:
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 all_groups(self) -> dict[int, Group]: def cached_groups(self) -> list[Group]:
"""Get the list of groups this user is in.""" """Get the list of groups this user is in."""
additional_groups = [] return list(self.groups.all())
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:
return self.is_superuser or settings.SITH_GROUP_ROOT_ID in self.all_groups if self.is_superuser:
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:
@@ -837,9 +833,6 @@ class SithFile(models.Model):
on_delete=models.CASCADE, on_delete=models.CASCADE,
) )
asked_for_removal = models.BooleanField(_("asked for removal"), default=False) asked_for_removal = models.BooleanField(_("asked for removal"), default=False)
is_in_sas = models.BooleanField(
_("is in the SAS"), default=False, db_index=True
) # Allows to query this flag, updated at each call to save()
class Meta: class Meta:
verbose_name = _("file") verbose_name = _("file")
@@ -848,22 +841,10 @@ class SithFile(models.Model):
return self.get_parent_path() + "/" + self.name return self.get_parent_path() + "/" + self.name
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
sas = SithFile.objects.filter(id=settings.SITH_SAS_ROOT_DIR_ID).first()
self.is_in_sas = sas in self.get_parent_list() or self == sas
adding = self._state.adding adding = self._state.adding
super().save(*args, **kwargs) super().save(*args, **kwargs)
if adding: if adding:
self.copy_rights() self.copy_rights()
if self.is_in_sas:
for user in User.objects.filter(
groups__id__in=[settings.SITH_GROUP_SAS_ADMIN_ID]
):
Notification(
user=user,
url=reverse("sas:moderation"),
type="SAS_MODERATION",
param="1",
).save()
def is_owned_by(self, user: User) -> bool: def is_owned_by(self, user: User) -> bool:
if user.is_anonymous: if user.is_anonymous:
@@ -876,8 +857,6 @@ class SithFile(models.Model):
return user.is_board_member return user.is_board_member
if user.is_com_admin: if user.is_com_admin:
return True return True
if self.is_in_sas and user.is_in_group(pk=settings.SITH_GROUP_SAS_ADMIN_ID):
return True
return user.id == self.owner_id return user.id == self.owner_id
def can_be_viewed_by(self, user: User) -> bool: def can_be_viewed_by(self, user: User) -> bool:
@@ -904,8 +883,6 @@ class SithFile(models.Model):
super().clean() super().clean()
if "/" in self.name: if "/" in self.name:
raise ValidationError(_("Character '/' not authorized in name")) raise ValidationError(_("Character '/' not authorized in name"))
if self == self.parent:
raise ValidationError(_("Loop in folder tree"), code="loop")
if self == self.parent or ( if self == self.parent or (
self.parent is not None and self in self.get_parent_list() self.parent is not None and self in self.get_parent_list()
): ):
@@ -986,18 +963,6 @@ class SithFile(models.Model):
def is_file(self): def is_file(self):
return not self.is_folder return not self.is_folder
@cached_property
def as_picture(self):
from sas.models import Picture
return Picture.objects.filter(id=self.id).first()
@cached_property
def as_album(self):
from sas.models import Album
return Album.objects.filter(id=self.id).first()
def get_parent_list(self): def get_parent_list(self):
parents = [] parents = []
current = self.parent current = self.parent
@@ -1103,7 +1068,10 @@ 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()
return self.filter(view_groups__in=user.all_groups) groups_ids = [g.id for g in user.cached_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
@@ -1377,7 +1345,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 self.page.owner_group_id in user.all_groups return any(g.id == self.page.owner_group_id for g in user.cached_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

@@ -1,77 +0,0 @@
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

@@ -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.all_groups` # clear the cached property `User.cached_groups`
self.public_user.__dict__.pop("all_groups", None) self.public_user.__dict__.pop("cached_groups", None)
# Test when the user is in the group # Test when the user is in the group
with self.assertNumQueries(2): with self.assertNumQueries(1):
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("all_groups", None) self.public_user.__dict__.pop("cached_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

@@ -5,6 +5,7 @@ from typing import Callable
from uuid import uuid4 from uuid import uuid4
import pytest import pytest
from django.conf import settings
from django.core.cache import cache from django.core.cache import cache
from django.core.files.uploadedfile import SimpleUploadedFile, UploadedFile from django.core.files.uploadedfile import SimpleUploadedFile, UploadedFile
from django.test import Client, TestCase from django.test import Client, TestCase
@@ -17,8 +18,8 @@ from pytest_django.asserts import assertNumQueries
from core.baker_recipes import board_user, old_subscriber_user, subscriber_user from core.baker_recipes import board_user, old_subscriber_user, subscriber_user
from core.models import Group, QuickUploadImage, SithFile, User from core.models import Group, QuickUploadImage, SithFile, User
from core.utils import RED_PIXEL_PNG from core.utils import RED_PIXEL_PNG
from sas.baker_recipes import picture_recipe
from sas.models import Picture from sas.models import Picture
from sith import settings
@pytest.mark.django_db @pytest.mark.django_db
@@ -30,24 +31,19 @@ class TestImageAccess:
lambda: baker.make( lambda: baker.make(
User, groups=[Group.objects.get(pk=settings.SITH_GROUP_SAS_ADMIN_ID)] User, groups=[Group.objects.get(pk=settings.SITH_GROUP_SAS_ADMIN_ID)]
), ),
lambda: baker.make(
User, groups=[Group.objects.get(pk=settings.SITH_GROUP_COM_ADMIN_ID)]
),
], ],
) )
def test_sas_image_access(self, user_factory: Callable[[], User]): def test_sas_image_access(self, user_factory: Callable[[], User]):
"""Test that only authorized users can access the sas image.""" """Test that only authorized users can access the sas image."""
user = user_factory() user = user_factory()
picture: SithFile = baker.make( picture = picture_recipe.make()
Picture, parent=SithFile.objects.get(pk=settings.SITH_SAS_ROOT_DIR_ID) assert user.can_edit(picture)
)
assert picture.is_owned_by(user)
def test_sas_image_access_owner(self): def test_sas_image_access_owner(self):
"""Test that the owner of the image can access it.""" """Test that the owner of the image can access it."""
user = baker.make(User) user = baker.make(User)
picture: Picture = baker.make(Picture, owner=user) picture = picture_recipe.make(owner=user)
assert picture.is_owned_by(user) assert user.can_edit(picture)
@pytest.mark.parametrize( @pytest.mark.parametrize(
"user_factory", "user_factory",
@@ -63,7 +59,41 @@ class TestImageAccess:
user = user_factory() user = user_factory()
owner = baker.make(User) owner = baker.make(User)
picture: Picture = baker.make(Picture, owner=owner) picture: Picture = baker.make(Picture, owner=owner)
assert not picture.is_owned_by(user) assert not user.can_edit(picture)
@pytest.mark.django_db
class TestUserPicture:
def test_anonymous_user_unauthorized(self, client):
"""An anonymous user shouldn't have access to an user's photo page."""
response = client.get(
reverse(
"sas:user_pictures",
kwargs={"user_id": User.objects.get(username="sli").pk},
)
)
assert response.status_code == 403
@pytest.mark.parametrize(
("username", "status"),
[
("guy", 403),
("root", 200),
("skia", 200),
("sli", 200),
],
)
def test_page_is_working(self, client, username, status):
"""Only user that subscribed (or admins) should be able to see the page."""
# Test for simple user
client.force_login(User.objects.get(username=username))
response = client.get(
reverse(
"sas:user_pictures",
kwargs={"user_id": User.objects.get(username="sli").pk},
)
)
assert response.status_code == status
# TODO: many tests on the pages: # TODO: many tests on the pages:

View File

@@ -27,6 +27,7 @@ from counter.baker_recipes import sale_recipe
from counter.models import Counter, Customer, Permanency, Refilling, Selling from counter.models import Counter, Customer, Permanency, Refilling, Selling
from counter.utils import is_logged_in_counter from counter.utils import is_logged_in_counter
from eboutic.models import Invoice, InvoiceItem from eboutic.models import Invoice, InvoiceItem
from sas.models import Picture
class TestSearchUsers(TestCase): class TestSearchUsers(TestCase):
@@ -34,6 +35,7 @@ class TestSearchUsers(TestCase):
def setUpTestData(cls): def setUpTestData(cls):
# News.author has on_delete=PROTECT, so news must be deleted beforehand # News.author has on_delete=PROTECT, so news must be deleted beforehand
News.objects.all().delete() News.objects.all().delete()
Picture.objects.all().delete() # same for pictures
User.objects.all().delete() User.objects.all().delete()
user_recipe = Recipe( user_recipe = Recipe(
User, User,
@@ -213,9 +215,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, unit_price=5) item_recipe.make(_quantity=3, quantity=1, product_unit_price=5)
item_recipe.make(_quantity=1, quantity=1, unit_price=5) item_recipe.make(_quantity=1, quantity=1, product_unit_price=5)
item_recipe.make(_quantity=2, quantity=1, unit_price=iter([5, 8])) item_recipe.make(_quantity=2, quantity=1, product_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

@@ -12,18 +12,23 @@
# OR WITHIN THE LOCAL FILE "LICENSE" # OR WITHIN THE LOCAL FILE "LICENSE"
# #
# #
from dataclasses import dataclass
from datetime import date, timedelta from datetime import date, timedelta
# Image utils # Image utils
from io import BytesIO from io import BytesIO
from typing import Final from typing import Any, Final, Unpack
import PIL import PIL
from django.conf import settings from django.conf import settings
from django.core.files.base import ContentFile from django.core.files.base import ContentFile
from django.core.files.uploadedfile import UploadedFile from django.core.files.uploadedfile import UploadedFile
from django.http import HttpRequest from django.db import models
from django.forms import BaseForm
from django.http import Http404, HttpRequest
from django.shortcuts import get_list_or_404
from django.template.loader import render_to_string
from django.utils.safestring import SafeString
from django.utils.timezone import localdate from django.utils.timezone import localdate
from PIL import ExifTags from PIL import ExifTags
from PIL.Image import Image, Resampling from PIL.Image import Image, Resampling
@@ -42,6 +47,21 @@ to generate a dummy image that is considered valid nonetheless
""" """
@dataclass
class FormFragmentTemplateData[T: BaseForm]:
"""Dataclass used to pre-render form fragments"""
form: T
template: str
context: dict[str, Any]
def render(self, request: HttpRequest) -> SafeString:
# Request is needed for csrf_tokens
return render_to_string(
self.template, context={"form": self.form, **self.context}, request=request
)
def get_start_of_semester(today: date | None = None) -> date: def get_start_of_semester(today: date | None = None) -> date:
"""Return the date of the start of the semester of the given date. """Return the date of the start of the semester of the given date.
If no date is given, return the start date of the current semester. If no date is given, return the start date of the current semester.
@@ -205,3 +225,56 @@ def get_client_ip(request: HttpRequest) -> str | None:
return ip return ip
return None return None
Filterable = type[models.Model] | models.QuerySet | models.Manager
ListFilter = dict[str, list | tuple | set]
def get_list_exact_or_404(klass: Filterable, **kwargs: Unpack[ListFilter]) -> list:
"""Use filter() to return a list of objects from a list of unique keys (like ids)
or raises Http404 if the list has not the same length as the given one.
Work like `get_object_or_404()` but for lists of objects, with some caveats :
- The filter must be a list, a tuple or a set.
- There can't be more than exactly one filter.
- There must be no duplicate in the filter.
- The filter should consist in unique keys (like ids), or it could fail randomly.
klass may be a Model, Manager, or QuerySet object. All other passed
arguments and keyword arguments are used in the filter() query.
Raises:
Http404: If the list is empty or doesn't have as many elements as the keys list.
ValueError: If the first argument is not a Model, Manager, or QuerySet object.
ValueError: If more than one filter is passed.
TypeError: If the given filter is not a list, a tuple or a set.
Examples:
Get all the products with ids 1, 2, 3: ::
products = get_list_exact_or_404(Product, id__in=[1, 2, 3])
Don't work with duplicate ids: ::
products = get_list_exact_or_404(Product, id__in=[1, 2, 3, 3])
# Raises Http404: "The list of keys must contain no duplicates."
"""
if len(kwargs) > 1:
raise ValueError("get_list_exact_or_404() only accepts one filter.")
key, list_filter = next(iter(kwargs.items()))
if not isinstance(list_filter, (list, tuple, set)):
raise TypeError(
f"The given filter must be a list, a tuple or a set, not {type(list_filter)}"
)
if len(list_filter) != len(set(list_filter)):
raise ValueError("The list of keys must contain no duplicates.")
kwargs = {key: list_filter}
obj_list = get_list_or_404(klass, **kwargs)
if len(obj_list) != len(list_filter):
raise Http404(
"The given list of keys doesn't match the number of objects found."
f"Expected {len(list_filter)} items, got {len(obj_list)}."
)
return obj_list

View File

@@ -374,7 +374,7 @@ class FileDeleteView(AllowFragment, CanEditPropMixin, DeleteView):
class FileModerationView(AllowFragment, ListView): class FileModerationView(AllowFragment, ListView):
model = SithFile model = SithFile
template_name = "core/file_moderation.jinja" template_name = "core/file_moderation.jinja"
queryset = SithFile.objects.filter(is_moderated=False, is_in_sas=False) queryset = SithFile.objects.filter(is_moderated=False)
ordering = "id" ordering = "id"
paginate_by = 100 paginate_by = 100

View File

@@ -24,7 +24,6 @@ from counter.models import (
Eticket, Eticket,
InvoiceCall, InvoiceCall,
Permanency, Permanency,
Price,
Product, Product,
ProductType, ProductType,
Refilling, Refilling,
@@ -33,24 +32,19 @@ 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,9 +101,13 @@ 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("prices", "prices__groups") .prefetch_related("buying_groups")
.select_related("product_type") .select_related("product_type")
.order_by(F("product_type__order").asc(nulls_last=True), "name") .order_by(
F("product_type__order").asc(nulls_last=True),
"product_type",
"name",
)
) )

View File

@@ -2,11 +2,10 @@ 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, Price, Product, Refilling, Selling from counter.models import Counter, 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,11 +1,11 @@
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
from django import forms from django import forms
from django.core.validators import MaxValueValidator
from django.db.models import Exists, OuterRef, Q from django.db.models import Exists, OuterRef, Q
from django.forms import BaseModelFormSet from django.forms import BaseModelFormSet
from django.utils.timezone import now from django.utils.timezone import now
@@ -35,7 +35,6 @@ from counter.models import (
Customer, Customer,
Eticket, Eticket,
InvoiceCall, InvoiceCall,
Price,
Product, Product,
ProductFormula, ProductFormula,
Refilling, Refilling,
@@ -292,22 +291,7 @@ 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=0, extra=2,
)
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,
) )
@@ -322,7 +306,10 @@ 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",
@@ -337,8 +324,8 @@ 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(
@@ -348,40 +335,50 @@ class ProductForm(forms.ModelForm):
queryset=Counter.objects.all(), queryset=Counter.objects.all(),
) )
def __init__(self, *args, prefix: str | None = None, instance=None, **kwargs): def __init__(self, *args, instance=None, **kwargs):
super().__init__(*args, prefix=prefix, instance=instance, **kwargs) super().__init__(*args, 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"): if hasattr(self.instance, "formula"):
self.formula_init(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, prefix="action", **kwargs *args, product=self.instance, **kwargs
) )
def formula_init(self, formula: ProductFormula):
"""Part of the form initialisation specific to formula products."""
self.fields["selling_price"].help_text = _(
"This product is a formula. "
"Its price cannot be greater than the price "
"of the products constituting it, which is %(price)s"
) % {"price": formula.max_selling_price}
self.fields["special_selling_price"].help_text = _(
"This product is a formula. "
"Its special price cannot be greater than the price "
"of the products constituting it, which is %(price)s"
) % {"price": formula.max_special_selling_price}
for key, price in (
("selling_price", formula.max_selling_price),
("special_selling_price", formula.max_special_selling_price),
):
self.fields[key].widget.attrs["max"] = price
self.fields[key].validators.append(MaxValueValidator(price))
def is_valid(self): def is_valid(self):
return ( return super().is_valid() and self.action_formset.is_valid()
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
@@ -404,6 +401,18 @@ class ProductFormulaForm(forms.ModelForm):
"the result and a part of the formula." "the result and a part of the formula."
), ),
) )
prices = [p.selling_price for p in cleaned_data["products"]]
special_prices = [p.special_selling_price for p in cleaned_data["products"]]
selling_price = cleaned_data["result"].selling_price
special_selling_price = cleaned_data["result"].special_selling_price
if selling_price > sum(prices) or special_selling_price > sum(special_prices):
self.add_error(
"result",
_(
"The result cannot be more expensive "
"than the total of the other products."
),
)
return cleaned_data return cleaned_data
@@ -454,47 +463,48 @@ class CloseCustomerAccountForm(forms.Form):
) )
class BasketItemForm(forms.Form): class BasketProductForm(forms.Form):
quantity = forms.IntegerField(min_value=1, required=True) quantity = forms.IntegerField(min_value=1, required=True)
price_id = forms.IntegerField(min_value=0, required=True) id = forms.IntegerField(min_value=0, required=True)
def __init__( def __init__(
self, self,
customer: Customer, customer: Customer,
counter: Counter, counter: Counter,
allowed_prices: dict[int, Price], allowed_products: dict[int, Product],
*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_prices = allowed_prices self.allowed_products = allowed_products
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
def clean_price_id(self): def clean_id(self):
data = self.cleaned_data["price_id"] data = self.cleaned_data["id"]
# We store self.price so we can use it later on the formset validation # We store self.product so we can use it later on the formset validation
# And also in the global clean # And also in the global clean
self.price = self.allowed_prices.get(data, None) self.product = self.allowed_products.get(data, None)
if self.price is None: if self.product 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 cleaned_data return
# Compute prices # Compute prices
cleaned_data["bonus_quantity"] = 0 cleaned_data["bonus_quantity"] = 0
if self.price.product.tray: if self.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.price.amount * ( cleaned_data["total_price"] = self.product.price * (
cleaned_data["quantity"] - cleaned_data["bonus_quantity"] cleaned_data["quantity"] - cleaned_data["bonus_quantity"]
) )
@@ -518,8 +528,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):
price_ids = {form.cleaned_data["price_id"] for form in self.forms} product_ids = {form.cleaned_data["id"] for form in self.forms}
if len(price_ids) != len(self.forms): if len(product_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):
@@ -529,9 +539,10 @@ 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 = defaultdict(int) items = {
for form in self.forms: form.cleaned_data["id"]: form.cleaned_data["quantity"]
items[form.price.product_id] += form.cleaned_data["quantity"] for form in self.forms
}
ids = list(items.keys()) ids = list(items.keys())
returnables = list( returnables = list(
ReturnableProduct.objects.filter( ReturnableProduct.objects.filter(
@@ -557,7 +568,7 @@ class BaseBasketForm(forms.BaseFormSet):
BasketForm = forms.formset_factory( BasketForm = forms.formset_factory(
BasketItemForm, formset=BaseBasketForm, absolute_max=None, min_num=1 BasketProductForm, formset=BaseBasketForm, absolute_max=None, min_num=1
) )

View File

@@ -1,149 +0,0 @@
# 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 TYPE_CHECKING, Literal, Self from typing import Literal, Self
from dict2xml import dict2xml from dict2xml import dict2xml
from django.conf import settings from django.conf import settings
@@ -47,9 +47,6 @@ 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()
@@ -160,7 +157,14 @@ 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
@@ -359,13 +363,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"), blank=True, default="") description = models.TextField(_("description"), 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=False, blank=True,
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)
@@ -373,6 +377,11 @@ 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",
@@ -385,9 +394,7 @@ 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 = models.BooleanField(_("tray price"), default=False)
_("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
) )
@@ -412,77 +419,41 @@ 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.
class PriceQuerySet(models.QuerySet): This must be not confused with the Customer.can_buy()
def for_user(self, user: User) -> Self: method as the present method returns an information
age = user.age about the relation between a User and a Product,
if user.is_banned_alcohol: whereas the other tells something about a Customer
age = min(age, 17) (and not a user, they are not the same model).
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
class Price(models.Model): Warning:
amount = CurrencyField(_("amount")) This performs a db query, thus you can quickly have
product = models.ForeignKey( a N+1 queries problem if you call it in a loop.
Product, Hopefully, you can avoid that if you prefetch the buying_groups :
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)
objects = PriceQuerySet.as_manager() ```python
user = User.objects.get(username="foobar")
class Meta: products = [
verbose_name = _("price") p
for p in Product.objects.prefetch_related("buying_groups")
def __str__(self): if p.can_be_sold_to(user)
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 full_label(self): def profit(self):
if not self.label: return self.selling_price - self.purchase_price
return self.product.name
return f"{self.product.name} \u2013 {self.label}"
class ProductFormula(models.Model): class ProductFormula(models.Model):
@@ -503,6 +474,18 @@ class ProductFormula(models.Model):
def __str__(self): def __str__(self):
return self.result.name return self.result.name
@cached_property
def max_selling_price(self) -> float:
# iterating over all products is less efficient than doing
# a simple aggregation, but this method is likely to be used in
# coordination with `max_special_selling_price`,
# and Django caches the result of the `all` queryset.
return sum(p.selling_price for p in self.products.all())
@cached_property
def max_special_selling_price(self) -> float:
return sum(p.special_selling_price for p in self.products.all())
class CounterQuerySet(models.QuerySet): class CounterQuerySet(models.QuerySet):
def annotate_has_barman(self, user: User) -> Self: def annotate_has_barman(self, user: User) -> Self:
@@ -729,20 +712,35 @@ 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_prices_for( def get_products_for(self, customer: Customer) -> list[Product]:
self, customer: Customer, *, order_by: Sequence[str] | None = None """
) -> list[Price]: Get all allowed products for the provided customer on this counter
qs = ( Prices will be annotated
Price.objects.filter( """
product__counters=self, product__product_type__isnull=False
) products = (
.for_user(customer.user) self.products.filter(archived=False)
.select_related("product", "product__product_type") .select_related("product_type")
.prefetch_related("groups") .prefetch_related("buying_groups")
) )
if order_by:
qs = qs.order_by(*order_by) # Only include age appropriate products
return list(qs) age = customer.user.age
if customer.user.is_banned_alcohol:
age = min(age, 17)
products = products.filter(limit_age__lte=age)
# Compute special price for customer if he is a barmen on that bar
if self.customer_is_barman(customer):
products = products.annotate(price=F("special_selling_price"))
else:
products = products.annotate(price=F("selling_price"))
return [
product
for product in products.all()
if product.can_be_sold_to(customer.user)
]
class RefillingQuerySet(models.QuerySet): class RefillingQuerySet(models.QuerySet):
@@ -1003,9 +1001,7 @@ 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.\n" "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 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 NonEmptyStr, SimpleUserSchema from core.schemas import GroupSchema, NonEmptyStr, SimpleUserSchema
from counter.models import Counter, Price, Product, ProductType from counter.models import Counter, Product, ProductType
class CounterSchema(ModelSchema): class CounterSchema(ModelSchema):
@@ -66,12 +66,6 @@ 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
@@ -81,12 +75,13 @@ class ProductSchema(ModelSchema):
"code", "code",
"description", "description",
"purchase_price", "purchase_price",
"selling_price",
"icon", "icon",
"limit_age", "limit_age",
"archived", "archived",
] ]
prices: list[ProductPriceSchema] buying_groups: list[GroupSchema]
club: SimpleClubSchema club: SimpleClubSchema
product_type: SimpleProductTypeSchema | None product_type: SimpleProductTypeSchema | None
url: str url: str

View File

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

View File

@@ -1,12 +1,11 @@
import { AlertMessage } from "#core:utils/alert-message"; import { AlertMessage } from "#core:utils/alert-message.ts";
import { BasketItem } from "#counter:counter/basket"; import { BasketItem } from "#counter:counter/basket.ts";
import type { import type {
CounterConfig, CounterConfig,
CounterItem,
ErrorMessage, ErrorMessage,
ProductFormula, ProductFormula,
} from "#counter:counter/types"; } from "#counter:counter/types.ts";
import type { CounterProductSelect } from "./components/counter-product-select-index"; import type { CounterProductSelect } from "./components/counter-product-select-index.ts";
document.addEventListener("alpine:init", () => { document.addEventListener("alpine:init", () => {
Alpine.data("counter", (config: CounterConfig) => ({ Alpine.data("counter", (config: CounterConfig) => ({
@@ -64,10 +63,8 @@ document.addEventListener("alpine:init", () => {
}, },
checkFormulas() { checkFormulas() {
// Try to find a formula.
// A formula is found if all its elements are already in the basket
const products = new Set( const products = new Set(
Object.values(this.basket).map((item: BasketItem) => item.product.productId), Object.keys(this.basket).map((i: string) => Number.parseInt(i)),
); );
const formula: ProductFormula = config.formulas.find((f: ProductFormula) => { const formula: ProductFormula = config.formulas.find((f: ProductFormula) => {
return f.products.every((p: number) => products.has(p)); return f.products.every((p: number) => products.has(p));
@@ -75,29 +72,22 @@ document.addEventListener("alpine:init", () => {
if (formula === undefined) { if (formula === undefined) {
return; return;
} }
// Now that the formula is found, remove the items composing it from the basket
for (const product of formula.products) { for (const product of formula.products) {
const key = Object.entries(this.basket).find( const key = product.toString();
([_, i]: [string, BasketItem]) => i.product.productId === product,
)[0];
this.basket[key].quantity -= 1; this.basket[key].quantity -= 1;
if (this.basket[key].quantity <= 0) { if (this.basket[key].quantity <= 0) {
this.removeFromBasket(key); 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( this.alertMessage.display(
interpolate( interpolate(
gettext("Formula %(formula)s applied"), gettext("Formula %(formula)s applied"),
{ formula: result.name }, { formula: config.products[formula.result.toString()].name },
true, true,
), ),
{ success: true }, { success: true },
); );
this.addToBasket(formula.result.toString(), 1);
}, },
getBasketSize() { getBasketSize() {

View File

@@ -1,9 +1,13 @@
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"; import { paginated } from "#core:utils/api.ts";
import { csv } from "#core:utils/csv"; import { csv } from "#core:utils/csv.ts";
import { getCurrentUrlParams, History, updateQueryString } from "#core:utils/history"; import {
import type { NestedKeyOf } from "#core:utils/types"; getCurrentUrlParams,
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,
@@ -16,9 +20,6 @@ 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.
*/ */
@@ -33,7 +34,7 @@ const csvColumns = [
"purchase_price", "purchase_price",
"selling_price", "selling_price",
"archived", "archived",
] as NestedKeyOf<ProductWithPriceSchema>[]; ] as NestedKeyOf<ProductSchema>[];
/** /**
* Title of the csv columns. * Title of the csv columns.
@@ -174,16 +175,7 @@ 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();
// CSV cannot represent nested data const content = csv.stringify(products, {
// 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,7 +2,7 @@ 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, CounterItem>; id?: keyof Record<string, Product>;
quantity?: number; quantity?: number;
errors?: string[]; errors?: string[];
} }
@@ -15,22 +15,17 @@ export interface ProductFormula {
export interface CounterConfig { export interface CounterConfig {
customerBalance: number; customerBalance: number;
customerId: number; customerId: number;
products: Record<string, CounterItem>; products: Record<string, Product>;
formulas: ProductFormula[]; formulas: ProductFormula[];
formInitial: InitialFormData[]; formInitial: InitialFormData[];
cancelUrl: string; cancelUrl: string;
} }
interface Price { export interface Product {
id: number; id: string;
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

@@ -6,10 +6,10 @@
{% endblock %} {% endblock %}
{% block additional_css %} {% block additional_css %}
<link rel="stylesheet" href="{{ static('counter/css/counter-click.scss') }}"> <link rel="stylesheet" type="text/css" href="{{ static('counter/css/counter-click.scss') }}" defer></link>
<link rel="stylesheet" href="{{ static('bundled/core/components/ajax-select-index.css') }}"> <link rel="stylesheet" type="text/css" href="{{ static('bundled/core/components/ajax-select-index.css') }}" defer></link>
<link rel="stylesheet" href="{{ static('core/components/ajax-select.scss') }}"> <link rel="stylesheet" type="text/css" href="{{ static('core/components/ajax-select.scss') }}" defer></link>
<link rel="stylesheet" href="{{ static('core/components/tabs.scss') }}"> <link rel="stylesheet" type="text/css" href="{{ static('core/components/tabs.scss') }}" defer></link>
<link rel="stylesheet" href="{{ static("core/components/card.scss") }}"> <link rel="stylesheet" href="{{ static("core/components/card.scss") }}">
{% endblock %} {% endblock %}
@@ -65,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, prices in categories.items() -%} {%- for category in categories.keys() -%}
<optgroup label="{{ category }}"> <optgroup label="{{ category }}">
{%- for price in prices -%} {%- for product in categories[category] -%}
<option value="{{ price.id }}">{{ price.full_label }}</option> <option value="{{ product.id }}">{{ product }}</option>
{%- endfor -%} {%- endfor -%}
</optgroup> </optgroup>
{%- endfor -%} {%- endfor -%}
@@ -103,25 +103,24 @@
</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.price.id"> <template x-for="(item, index) in Object.values(basket)" :key="item.product.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="addToBasket(item.product.price.id, -1)">-</button> <button @click.prevent="addToBasket(item.product.id, -1)">-</button>
<span class="quantity" x-text="item.quantity"></span> <span class="quantity" x-text="item.quantity"></span>
<button @click.prevent="addToBasket(item.product.price.id, 1)">+</button> <button @click.prevent="addToBasket(item.product.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" <span x-show="item.getBonusQuantity() > 0" x-text="`${item.getBonusQuantity()} x P`"></span>
x-text="`${item.getBonusQuantity()} x P`"></span>
<button <button
class="remove-item" class="remove-item"
@click.prevent="removeFromBasket(item.product.price.id)" @click.prevent="removeFromBasket(item.product.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 +133,9 @@
> >
<input <input
type="hidden" type="hidden"
:value="item.product.price.id" :value="item.product.id"
:id="`id_form-${index}-price_id`" :id="`id_form-${index}-id`"
:name="`form-${index}-price_id`" :name="`form-${index}-id`"
required required
readonly readonly
> >
@@ -202,30 +201,30 @@
</div> </div>
<div id="products"> <div id="products">
{% if not prices %} {% if not products %}
<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, prices in categories.items() -%} {% for category in categories.keys() -%}
<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 price in prices -%} {% for product in categories[category] -%}
<button class="card shadow" @click="addToBasket('{{ price.id }}', 1)"> <button class="card shadow" @click="addToBasket('{{ product.id }}', 1)">
<img <img
class="card-image" class="card-image"
alt="image de {{ price.full_label }}" alt="image de {{ product.name }}"
{% if price.product.icon %} {% if product.icon %}
src="{{ price.product.icon.url }}" src="{{ 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">{{ price.full_label }}</strong> <strong class="card-title">{{ product.name }}</strong>
<p>{{ price.amount }} €<br>{{ price.product.code }}</p> <p>{{ product.price }} €<br>{{ product.code }}</p>
</span> </span>
</button> </button>
{%- endfor %} {%- endfor %}
@@ -242,14 +241,13 @@
{{ super() }} {{ super() }}
<script> <script>
const products = { const products = {
{%- for price in prices -%} {%- for product in products -%}
{{ price.id }}: { {{ product.id }}: {
productId: {{ price.product_id }}, id: "{{ product.id }}",
price: { id: "{{ price.id }}", amount: {{ price.amount }} }, name: "{{ product.name }}",
code: "{{ price.product.code }}", price: {{ product.price }},
name: "{{ price.full_label }}", hasTrayPrice: {{ product.tray | tojson }},
hasTrayPrice: {{ price.product.tray | tojson }}, quantityForTrayPrice: {{ product.QUANTITY_FOR_TRAY_PRICE }},
quantityForTrayPrice: {{ price.product.QUANTITY_FOR_TRAY_PRICE }},
}, },
{%- endfor -%} {%- endfor -%}
}; };

View File

@@ -49,10 +49,14 @@
<strong class="card-title">{{ formula.result.name }}</strong> <strong class="card-title">{{ formula.result.name }}</strong>
<p> <p>
{% for p in formula.products.all() %} {% for p in formula.products.all() %}
<i>{{ p.name }} ({{ p.code }})</i> <i>{{ p.code }} ({{ p.selling_price }})</i>
{% if not loop.last %}+{% endif %} {% if not loop.last %}+{% endif %}
{% endfor %} {% endfor %}
</p> </p>
<p>
{{ formula.result.selling_price }}
({% trans %}instead of{% endtrans %} {{ formula.max_selling_price}} €)
</p>
</div> </div>
{% if user.has_perm("counter.delete_productformula") %} {% if user.has_perm("counter.delete_productformula") %}
<button <button

View File

@@ -1,87 +1,5 @@
{% 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>
@@ -92,54 +10,7 @@
{% endif %} {% endif %}
<form method="post" enctype="multipart/form-data"> <form method="post" enctype="multipart/form-data">
{% csrf_token %} {% csrf_token %}
{{ form.non_field_errors() }} {{ form.as_p() }}
<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 />
@@ -154,21 +25,34 @@
</em> </em>
</p> </p>
<div x-data="dynamicFormSet({ prefix: '{{ form.action_formset.prefix }}' })" class="margin-bottom"> {{ form.action_formset.management_form }}
{{ form.action_formset.management_form }} {%- for action_form in form.action_formset.forms -%}
<div x-ref="formContainer"> <fieldset x-data="{action: '{{ action_form.task.initial }}'}">
{%- for f in form.action_formset.forms -%} {{ action_form.non_field_errors() }}
{{ action_form(f) }} <div class="row gap-2x margin-bottom">
<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 -%}
</div> </fieldset>
<template x-ref="formTemplate"> {%- if not loop.last -%}
{{ action_form(form.action_formset.empty_form) }} <hr class="margin-bottom">
</template> {%- endif -%}
<button @click.prevent="addForm()" class="btn btn-grey"> {%- endfor -%}
<i class="fa fa-plus"></i>{% trans %}Add action{% endtrans %} <p><input type="submit" value="{% trans %}Save{% endtrans %}" /></p>
</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.prices.map((p) => p.amount).join(' ')} €`"></p> <p x-text="`${p.selling_price} €`"></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, ProductType, ScheduledProductAction from counter.models import Product, ScheduledProductAction
@pytest.mark.django_db @pytest.mark.django_db
@@ -47,22 +47,20 @@ def test_create_actions_alongside_product():
form = ProductForm( form = ProductForm(
data={ data={
"name": "foo", "name": "foo",
"product_type": ProductType.objects.first(), "description": "bar",
"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,
"price-TOTAL_FORMS": "0", "form-TOTAL_FORMS": "2",
"price-INITIAL_FORMS": "0", "form-INITIAL_FORMS": "0",
"action-TOTAL_FORMS": "1", "form-0-task": "counter.tasks.archive_product",
"action-INITIAL_FORMS": "0", "form-0-trigger_at": trigger_at,
"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,6 +20,7 @@ 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
@@ -33,13 +34,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, Group, User from core.models import BanGroup, User
from counter.baker_recipes import price_recipe, product_recipe, sale_recipe from counter.baker_recipes import product_recipe, sale_recipe
from counter.models import ( from counter.models import (
Counter, Counter,
Customer, Customer,
Permanency, Permanency,
ProductType, Product,
Refilling, Refilling,
ReturnableProduct, ReturnableProduct,
Selling, Selling,
@@ -203,7 +204,7 @@ class TestRefilling(TestFullClickBase):
@dataclass @dataclass
class BasketItem: class BasketItem:
price_id: int | None = None 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]:
@@ -235,59 +236,38 @@ 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 = price_recipe.make( cls.gift = product_recipe.make(
amount=-1.5, groups=[subscriber_group], product=_product_recipe.make() selling_price="-1.5",
special_selling_price="-1.5",
) )
cls.beer = price_recipe.make( cls.beer = product_recipe.make(
groups=[subscriber_group], limit_age=18, selling_price=1.5, special_selling_price=1
amount=1.5,
product=_product_recipe.make(limit_age=18),
) )
cls.beer_tap = price_recipe.make( cls.beer_tap = product_recipe.make(
groups=[subscriber_group], limit_age=18, tray=True, selling_price=1.5, special_selling_price=1
amount=1.5,
product=_product_recipe.make(limit_age=18, tray=True),
) )
cls.snack = price_recipe.make( cls.snack = product_recipe.make(
groups=[subscriber_group, old_subscriber_group], limit_age=0, selling_price=1.5, special_selling_price=1
amount=1.5,
product=_product_recipe.make(limit_age=0),
) )
cls.stamps = price_recipe.make( cls.stamps = product_recipe.make(
groups=[subscriber_group], limit_age=0, selling_price=1.5, special_selling_price=1
amount=1.5,
product=_product_recipe.make(limit_age=0),
) )
ReturnableProduct.objects.all().delete() ReturnableProduct.objects.all().delete()
cls.cons = price_recipe.make( cls.cons = baker.make(Product, selling_price=1)
amount=1, groups=[subscriber_group], product=_product_recipe.make() cls.dcons = baker.make(Product, selling_price=-1)
)
cls.dcons = price_recipe.make(
amount=-1, groups=[subscriber_group], product=_product_recipe.make()
)
baker.make( baker.make(
ReturnableProduct, ReturnableProduct,
product=cls.cons.product, product=cls.cons,
returned_product=cls.dcons.product, returned_product=cls.dcons,
max_return=3, max_return=3,
) )
cls.counter.products.add( cls.counter.products.add(
cls.gift.product, cls.gift, cls.beer, cls.beer_tap, cls.snack, cls.cons, cls.dcons
cls.beer.product,
cls.beer_tap.product,
cls.snack.product,
cls.cons.product,
cls.dcons.product,
) )
cls.other_counter.products.add(cls.snack.product) cls.other_counter.products.add(cls.snack)
cls.club_counter.products.add(cls.stamps.product) cls.club_counter.products.add(cls.stamps)
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
@@ -305,7 +285,10 @@ 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 = {"form-TOTAL_FORMS": str(len(basket)), "form-INITIAL_FORMS": "0"} data = {
"form-TOTAL_FORMS": str(len(basket)),
"form-INITIAL_FORMS": "0",
}
for index, item in enumerate(basket): 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(
@@ -348,22 +331,32 @@ 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)]
) )
self.assertRedirects(res, self.counter.get_absolute_url()) assert res.status_code == 302
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)])
self.assertRedirects(res, self.counter.get_absolute_url()) assert res.status_code == 302
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)])
self.assertRedirects(res, self.counter.get_absolute_url()) assert res.status_code == 302
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):
@@ -484,8 +477,7 @@ class TestCounterClick(TestFullClickBase):
BasketItem(None, 1), BasketItem(None, 1),
BasketItem(self.beer.id, None), BasketItem(self.beer.id, None),
]: ]:
res = self.submit_basket(self.customer, [item]) assert self.submit_basket(self.customer, [item]).status_code == 200
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):
@@ -514,30 +506,29 @@ 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)]
) )
self.assertRedirects(res, self.counter.get_absolute_url()) assert res.status_code == 302
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.amount * 3) force_refill_user(self.customer, self.cons.selling_price * 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.product.cons.id, "balance": 3}] ) == [{"returnable": self.cons.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.amount * -3 assert self.updated_amount(self.customer) == self.dcons.selling_price * -3
res = self.submit_basket( res = self.submit_basket(
self.customer, self.customer, [BasketItem(self.dcons.id, self.dcons.dcons.max_return)]
[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.amount * (-3 - self.dcons.product.dcons.max_return) expected_amount = self.dcons.selling_price * (-3 - self.dcons.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
@@ -554,57 +545,48 @@ 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, product=self.dcons,
unit_price=self.dcons.amount, unit_price=self.dcons.selling_price,
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.amount * -10 assert self.updated_amount(self.customer) == self.dcons.selling_price * -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.amount * -10 - self.cons.amount * 3 == self.dcons.selling_price * -10 - self.cons.selling_price * 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.amount * -10 - self.cons.amount * 3 - self.beer.amount == self.dcons.selling_price * -10
- 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)
group.users.add(customer.user) product_recipe.make(archived=True, counters=[counter])
_product_recipe = product_recipe.extend( unarchived_products = product_recipe.make(
counters=[counter], product_type=baker.make(ProductType) archived=False, counters=[counter], _quantity=3
) )
price_recipe.make( customer_products = counter.get_products_for(customer)
_quantity=2, assert unarchived_products == customer_products
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 = price_recipe.make(amount=1).product product = product_recipe.make(selling_price=1)
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]
) )
@@ -803,6 +785,9 @@ 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

@@ -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(_quantity=4, _bulk_create=True) products = product_recipe.make(selling_price=0, _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

@@ -7,7 +7,12 @@ from counter.forms import ProductFormulaForm
class TestFormulaForm(TestCase): class TestFormulaForm(TestCase):
@classmethod @classmethod
def setUpTestData(cls): def setUpTestData(cls):
cls.products = product_recipe.make(_quantity=3, _bulk_create=True) cls.products = product_recipe.make(
selling_price=iter([1.5, 1, 1]),
special_selling_price=iter([1.4, 0.9, 0.9]),
_quantity=3,
_bulk_create=True,
)
def test_ok(self): def test_ok(self):
form = ProductFormulaForm( form = ProductFormulaForm(
@@ -21,6 +26,23 @@ class TestFormulaForm(TestCase):
assert formula.result == self.products[0] assert formula.result == self.products[0]
assert set(formula.products.all()) == set(self.products[1:]) assert set(formula.products.all()) == set(self.products[1:])
def test_price_invalid(self):
self.products[0].selling_price = 2.1
self.products[0].save()
form = ProductFormulaForm(
data={
"result": self.products[0].id,
"products": [self.products[1].id, self.products[2].id],
}
)
assert not form.is_valid()
assert form.errors == {
"result": [
"Le résultat ne peut pas être plus cher "
"que le total des autres produits."
]
}
def test_product_both_in_result_and_products(self): def test_product_both_in_result_and_products(self):
form = ProductFormulaForm( form = ProductFormulaForm(
data={ data={

View File

@@ -9,7 +9,6 @@ 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
@@ -17,8 +16,8 @@ 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.baker_recipes import product_recipe from counter.baker_recipes import product_recipe
from counter.forms import ProductForm, ProductPriceFormSet from counter.forms import ProductForm
from counter.models import Price, Product, ProductType from counter.models import Product, ProductFormula, ProductType
@pytest.mark.django_db @pytest.mark.django_db
@@ -82,11 +81,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(6): with assertNumQueries(5):
# - 2 for authentication # - 2 for authentication
# - 1 for pagination # - 1 for pagination
# - 1 for the actual request # - 1 for the actual request
# - 2 to prefetch the related prices and groups # - 1 to prefetch the related buying_groups
client.get(reverse("api:search_products_detailed")) client.get(reverse("api:search_products_detailed"))
@@ -108,21 +107,48 @@ 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,
"price-TOTAL_FORMS": 0, "form-TOTAL_FORMS": 0,
"price-INITIAL_FORMS": 0, "form-INITIAL_FORMS": 0,
"action-TOTAL_FORMS": 0,
"action-INITIAL_FORMS": 0,
} }
def test_form_simple(self): def test_form(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_simple(self): def test_form_with_product_from_formula(self):
"""Test when the edited product is a result of a formula."""
self.client.force_login(self.counter_admin)
products = product_recipe.make(
selling_price=iter([1.5, 1, 1]),
special_selling_price=iter([1.4, 0.9, 0.9]),
_quantity=3,
_bulk_create=True,
)
baker.make(ProductFormula, result=products[0], products=products[1:])
data = self.data | {"selling_price": 1.7, "special_selling_price": 1.5}
form = ProductForm(data=data, instance=products[0])
assert form.is_valid()
# it shouldn't be possible to give a price higher than the formula's products
data = self.data | {"selling_price": 2.1, "special_selling_price": 1.9}
form = ProductForm(data=data, instance=products[0])
assert not form.is_valid()
assert form.errors == {
"selling_price": [
"Assurez-vous que cette valeur est inférieure ou égale à 2.00."
],
"special_selling_price": [
"Assurez-vous que cette valeur est inférieure ou égale à 1.80."
],
}
def test_view(self):
self.client.force_login(self.counter_admin) self.client.force_login(self.counter_admin)
url = reverse("counter:new_product") url = reverse("counter:new_product")
response = self.client.get(url) response = self.client.get(url)
@@ -133,92 +159,3 @@ 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

@@ -73,7 +73,7 @@ class CounterClick(
kwargs["form_kwargs"] = { kwargs["form_kwargs"] = {
"customer": self.customer, "customer": self.customer,
"counter": self.object, "counter": self.object,
"allowed_prices": {price.id: price for price in self.prices}, "allowed_products": {product.id: product for product in self.products},
} }
return kwargs return kwargs
@@ -103,7 +103,7 @@ class CounterClick(
): ):
return redirect(obj) # Redirect to counter return redirect(obj) # Redirect to counter
self.prices = obj.get_prices_for(self.customer) self.products = obj.get_products_for(self.customer)
return super().dispatch(request, *args, **kwargs) return super().dispatch(request, *args, **kwargs)
@@ -121,31 +121,32 @@ 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.price.amount): for form in sorted(formset, key=lambda form: form.product.price):
self.request.session["last_basket"].append( self.request.session["last_basket"].append(
f"{form.cleaned_data['quantity']} x {form.price.full_label}" f"{form.cleaned_data['quantity']} x {form.product.name}"
) )
common_kwargs = {
"product": form.price.product,
"club_id": form.price.product.club_id,
"counter": self.object,
"seller": operator,
"customer": self.customer,
}
Selling( Selling(
**common_kwargs, label=form.product.name,
label=form.price.full_label, product=form.product,
unit_price=form.price.amount, club=form.product.club,
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(
**common_kwargs, label=f"{form.product.name} (Plateau)",
label=f"{form.price.full_label} (Plateau)", product=form.product,
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()
@@ -206,13 +207,14 @@ 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["prices"] = self.prices kwargs["products"] = self.products
kwargs["formulas"] = ProductFormula.objects.filter( kwargs["formulas"] = ProductFormula.objects.filter(
result__in=[p.product_id for p in self.prices] result__in=self.products
).prefetch_related("products") ).prefetch_related("products")
kwargs["categories"] = defaultdict(list) kwargs["categories"] = defaultdict(list)
for price in self.prices: for product in kwargs["products"]:
kwargs["categories"][price.product.product_type].append(price) if product.product_type:
kwargs["categories"][product.product_type].append(product)
kwargs["customer"] = self.customer kwargs["customer"] = self.customer
kwargs["cancel_url"] = self.get_success_url() kwargs["cancel_url"] = self.get_success_url()

View File

@@ -263,3 +263,35 @@ avec un unique champ permettant de sélectionner des groupes.
Par défaut, seuls les utilisateurs avec la permission Par défaut, seuls les utilisateurs avec la permission
`auth.change_permission` auront accès à ce formulaire `auth.change_permission` auront accès à ce formulaire
(donc, normalement, uniquement les utilisateurs Root). (donc, normalement, uniquement les utilisateurs Root).
```mermaid
sequenceDiagram
participant A as Utilisateur
participant B as ReverseProxy
participant C as MarkdownImage
participant D as Model
A->>B: GET /page/foo
B->>C: GET /page/foo
C-->>B: La page, avec les urls
B-->>A: La page, avec les urls
alt image publique
A->>B: GET markdown/public/2025/img.webp
B-->>A: img.webp
end
alt image privée
A->>B: GET markdown_image/{id}
B->>C: GET markdown_image/{id}
C->>D: user.can_view(image)
alt l'utilisateur a le droit de voir l'image
D-->>C: True
C-->>B: 200 (avec le X-Accel-Redirect)
B-->>A: img.webp
end
alt l'utilisateur n'a pas le droit de l'image
D-->>C: False
C-->>B: 403
B-->>A: 403
end
end
```

View File

@@ -22,22 +22,23 @@ 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(F("items__quantity") * F("items__unit_price"), default=0) total=Sum(
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 = ("label", "unit_price", "quantity") list_display = ("basket", "product_name", "product_unit_price", "quantity")
search_fields = ("label",) search_fields = ("product_name",)
@admin.register(Invoice) @admin.register(Invoice)
@@ -49,6 +50,5 @@ class InvoiceAdmin(admin.ModelAdmin):
@admin.register(InvoiceItem) @admin.register(InvoiceItem)
class InvoiceItemAdmin(admin.ModelAdmin): class InvoiceItemAdmin(admin.ModelAdmin):
list_display = ("label", "unit_price", "quantity") list_display = ("invoice", "product_name", "product_unit_price", "quantity")
search_fields = ("label",) search_fields = ("product_name",)
list_select_related = ("price",)

View File

@@ -1,53 +0,0 @@
# 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 Self from typing import Any, 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,6 +39,22 @@ 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
@@ -78,21 +94,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: User): def can_be_viewed_by(self, 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(
product__product_type_id=settings.SITH_COUNTER_PRODUCTTYPE_REFILLING 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(total=Sum(F("quantity") * F("unit_price"), default=0))[ self.items.aggregate(
"total" total=Sum(F("quantity") * F("product_unit_price"), default=0)
] )["total"]
) )
def generate_sales( def generate_sales(
@@ -104,8 +120,7 @@ class Basket(models.Model):
Example: Example:
```python ```python
counter = Counter.objects.get(name="Eboutic") counter = Counter.objects.get(name="Eboutic")
user = User.objects.get(username="bibou") sales = basket.generate_sales(counter, "SITH_ACCOUNT")
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():
@@ -116,23 +131,31 @@ class Basket(models.Model):
# thus only the sales remain # thus only the sales remain
``` ```
""" """
customer = Customer.get_or_create(self.user)[0] # I must proceed with two distinct requests instead of
return [ # only one with a join because the AbstractBaseItem model has been
Selling( # poorly designed. If you refactor the model, please refactor this too.
label=item.label, items = self.items.order_by("product_id")
counter=counter, ids = [item.product_id for item in items]
club_id=item.product.club_id, products = Product.objects.filter(id__in=ids).order_by("id")
product=item.product, # items and products are sorted in the same order
seller=seller, sales = []
customer=customer, for item, product in zip(items, products, strict=False):
unit_price=item.unit_price, sales.append(
quantity=item.quantity, Selling(
payment_method=payment_method, label=product.name,
counter=counter,
club=product.club,
product=product,
seller=seller,
customer=Customer.get_or_create(self.user)[0],
unit_price=item.product_unit_price,
quantity=item.quantity,
payment_method=payment_method,
)
) )
for item in self.items.select_related("product") return sales
]
def get_e_transaction_data(self) -> list[tuple[str, str]]: def get_e_transaction_data(self) -> list[tuple[str, Any]]:
user = self.user user = self.user
if not hasattr(user, "customer"): if not hasattr(user, "customer"):
raise Customer.DoesNotExist raise Customer.DoesNotExist
@@ -178,7 +201,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 (unit_price * quantity) The total amount is the sum of (product_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,
@@ -188,7 +211,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("unit_price") * F("quantity"))) .annotate(total=Sum(F("product_unit_price") * F("quantity")))
.values("total") .values("total")
) )
) )
@@ -198,7 +221,11 @@ 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, related_name="invoices", verbose_name=_("user"), on_delete=models.CASCADE User,
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)
@@ -219,44 +246,53 @@ 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)
kwargs = { eboutic = Counter.objects.filter(type="EBOUTIC").first()
"counter": get_eboutic(), for i in self.items.all():
"customer": customer, if i.type_id == settings.SITH_COUNTER_PRODUCTTYPE_REFILLING:
"date": self.date, new = Refilling(
"payment_method": Selling.PaymentMethod.CARD, counter=eboutic,
} customer=customer,
for i in self.items.select_related("product"): operator=self.user,
if i.product.product_type_id == settings.SITH_COUNTER_PRODUCTTYPE_REFILLING: amount=i.product_unit_price * i.quantity,
Refilling.objects.create( payment_method=Refilling.PaymentMethod.CARD,
**kwargs, operator=self.user, amount=i.unit_price * i.quantity date=self.date,
) )
new.save()
else: else:
Selling.objects.create( product = Product.objects.filter(id=i.product_id).first()
**kwargs, new = Selling(
label=i.label, label=i.product_name,
club_id=i.product.club_id, counter=eboutic,
product=i.product, club=product.club,
product=product,
seller=self.user, seller=self.user,
unit_price=i.unit_price, customer=customer,
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 = models.ForeignKey( product_id = models.IntegerField(_("product id"))
Product, verbose_name=_("product"), on_delete=models.PROTECT product_name = models.CharField(_("product name"), max_length=255)
) type_id = models.IntegerField(_("product type id"))
label = models.CharField(_("product name"), max_length=255) product_unit_price = CurrencyField(_("unit price"))
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" % (self.product.name, self.unit_price, self.quantity) return "Item: %s (%s) x%d" % (
self.product_name,
self.product_unit_price,
self.quantity,
)
class BasketItem(AbstractBaseItem): class BasketItem(AbstractBaseItem):
@@ -265,16 +301,21 @@ class BasketItem(AbstractBaseItem):
) )
@classmethod @classmethod
def from_price(cls, price: Price, quantity: int, basket: Basket): def from_product(cls, product: Product, quantity: int, basket: Basket):
"""Create a BasketItem with the same characteristics as the """Create a BasketItem with the same characteristics as the
product price passed in parameters, with the specified quantity. product passed in parameters, with the specified quantity.
Warning:
the basket field is not filled, so you must set
it yourself before saving the model.
""" """
return cls( return cls(
basket=basket, basket=basket,
label=price.full_label, product_id=product.id,
product_id=price.product_id, product_name=product.name,
type_id=product.product_type_id,
quantity=quantity, quantity=quantity,
unit_price=price.amount, product_unit_price=product.selling_price,
) )

View File

@@ -1,15 +1,13 @@
export {}; export {};
interface BasketItem { interface BasketItem {
priceId: number; id: number;
name: string; name: string;
quantity: number; quantity: number;
unitPrice: number; // biome-ignore lint/style/useNamingConvention: the python code is snake_case
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[],
@@ -32,24 +30,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
.getElementById("#id_form-TOTAL_FORMS") .querySelector("#id_form-TOTAL_FORMS")
.setAttribute(":value", "basket.length"); .setAttribute(":value", "basket.length");
}, },
loadBasket(): BasketItem[] { loadBasket(): BasketItem[] {
if (localStorage.getItem(BASKET_CACHE_KEY) === null) { if (localStorage.basket === undefined) {
return []; return [];
} }
try { try {
return JSON.parse(localStorage.getItem(BASKET_CACHE_KEY)); return JSON.parse(localStorage.basket);
} catch (_err) { } catch (_err) {
return []; return [];
} }
}, },
saveBasket() { saveBasket() {
localStorage.setItem(BASKET_CACHE_KEY, JSON.stringify(this.basket)); localStorage.basket = JSON.stringify(this.basket);
localStorage.setItem("basketTimestamp", Date.now().toString()); localStorage.basketTimestamp = Date.now();
}, },
/** /**
@@ -58,7 +56,7 @@ document.addEventListener("alpine:init", () => {
*/ */
getTotal() { getTotal() {
return this.basket.reduce( return this.basket.reduce(
(acc: number, item: BasketItem) => acc + item.quantity * item.unitPrice, (acc: number, item: BasketItem) => acc + item.quantity * item.unit_price,
0, 0,
); );
}, },
@@ -76,7 +74,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.priceId === itemId); const index = this.basket.findIndex((e: BasketItem) => e.id === itemId);
if (index < 0) { if (index < 0) {
return; return;
@@ -85,7 +83,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.priceId !== this.basket[index].id, (e: BasketItem) => e.id !== this.basket[index].id,
); );
} }
}, },
@@ -106,10 +104,11 @@ document.addEventListener("alpine:init", () => {
*/ */
createItem(id: number, name: string, price: number): BasketItem { createItem(id: number, name: string, price: number): BasketItem {
const newItem = { const newItem = {
priceId: id, id,
name, name,
quantity: 0, quantity: 0,
unitPrice: price, // biome-ignore lint/style/useNamingConvention: the python code is snake_case
unit_price: price,
} as BasketItem; } as BasketItem;
this.basket.push(newItem); this.basket.push(newItem);
@@ -126,7 +125,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.priceId === id); let item = this.basket.find((e: BasketItem) => e.id === 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.label }}</td> <td>{{ item.product_name }}</td>
<td>{{ item.quantity }}</td> <td>{{ item.quantity }}</td>
<td>{{ item.unit_price }} €</td> <td>{{ item.product_unit_price }} €</td>
</tr> </tr>
{% endfor %} {% endfor %}
</tbody> </tbody>

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.priceId"> <template x-for="(item, index) in Object.values(basket)" :key="item.id">
<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.priceId)"></i> <i class="fa fa-minus fa-xs" @click="remove(item.id)"></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.unitPrice * item.quantity).toFixed(2) + ' €'"></span> <span class="item-price" x-text="(item.unit_price * item.quantity).toFixed(2) + ' €'"></span>
<input <input
type="hidden" type="hidden"
@@ -71,16 +71,16 @@
> >
<input <input
type="hidden" type="hidden"
:value="item.priceId" :value="item.id"
:id="`id_form-${index}-price_id`" :id="`id_form-${index}-id`"
:name="`form-${index}-price_id`" :name="`form-${index}-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,40 +116,45 @@
</span> </span>
</div> </div>
{% endif %} {% endif %}
{% for prices in categories %} {% for priority_groups in products|groupby('order') %}
{% set category = prices[0].product.product_type %} {% for category, items in priority_groups.list|groupby('category') %}
<section> {% if items|count > 0 %}
<div class="category-header"> <section>
<h3>{{ category.name }}</h3> {# I would have wholeheartedly directly used the header element instead
{% if category.comment %} but it has already been made messy in core/style.scss #}
<p><i>{{ category.comment }}</i></p> <div class="category-header">
{% endif %} <h3>{{ category }}</h3>
</div> {% if items[0].category_comment %}
<div class="product-group"> <p><i>{{ items[0].category_comment }}</i></p>
{% for price in prices %}
<button
id="{{ price.id }}"
class="card product-button clickable shadow"
:class="{selected: basket.some((i) => i.priceId === {{ price.id }})}"
@click='addFromCatalog({{ price.id }}, {{ price.full_label|tojson }}, {{ price.amount }})'
>
{% if price.product.icon %}
<img
class="card-image"
src="{{ price.product.icon.url }}"
alt="image de {{ price.full_label }}"
>
{% else %}
<i class="fa-regular fa-image fa-2x card-image"></i>
{% endif %} {% endif %}
<div class="card-content"> </div>
<h4 class="card-title">{{ price.full_label }}</h4> <div class="product-group">
<p>{{ price.amount }} €</p> {% for p in items %}
</div> <button
</button> id="{{ p.id }}"
{% endfor %} class="card product-button clickable shadow"
</div> :class="{selected: basket.some((i) => i.id === {{ p.id }})}"
</section> @click='addFromCatalog({{ p.id }}, {{ p.name|tojson }}, {{ p.selling_price }})'
>
{% if p.icon %}
<img
class="card-image"
src="{{ p.icon.url }}"
alt="image de {{ p.name }}"
>
{% else %}
<i class="fa-regular fa-image fa-2x card-image"></i>
{% endif %}
<div class="card-content">
<h4 class="card-title">{{ p.name }}</h4>
<p>{{ p.selling_price }} €</p>
</div>
</button>
{% endfor %}
</div>
</section>
{% endif %}
{% endfor %}
{% else %} {% 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,12 +11,7 @@ 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 ( from counter.baker_recipes import product_recipe, refill_recipe, sale_recipe
price_recipe,
product_recipe,
refill_recipe,
sale_recipe,
)
from counter.models import ( from counter.models import (
Counter, Counter,
Customer, Customer,
@@ -152,29 +147,29 @@ class TestEboutic(TestCase):
product_type = baker.make(ProductType) product_type = baker.make(ProductType)
cls.snack = price_recipe.make( cls.snack = product_recipe.make(
amount=1.5, product=product_recipe.make(product_type=product_type) selling_price=1.5, special_selling_price=1, product_type=product_type
) )
cls.beer = price_recipe.make( cls.beer = product_recipe.make(
product=product_recipe.make(limit_age=18, product_type=product_type), limit_age=18,
amount=2.5, selling_price=2.5,
special_selling_price=1,
product_type=product_type,
) )
cls.not_in_counter = price_recipe.make( cls.not_in_counter = product_recipe.make(
product=product_recipe.make(product_type=product_type), amount=3.5 selling_price=3.5, product_type=product_type
)
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.prices.add(cls.snack, cls.beer, cls.not_in_counter) cls.group_public.products.add(cls.snack, cls.beer, cls.not_in_counter)
cls.group_cotiz.prices.add(cls.cotiz) cls.group_cotiz.products.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.product, cls.beer.product, cls.snack.product) cls.eboutic.products.add(cls.cotiz, cls.beer, cls.snack)
@classmethod @classmethod
def set_age(cls, user: User, age: int): def set_age(cls, user: User, age: int):
@@ -258,7 +253,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.amount * 2 assert Basket.objects.get(id=1).total == self.snack.selling_price * 2
self.client.force_login(self.new_customer_adult) self.client.force_login(self.new_customer_adult)
assertRedirects( assertRedirects(
@@ -268,7 +263,8 @@ 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 == self.snack.amount * 2 + self.beer.amount Basket.objects.get(id=2).total
== self.snack.selling_price * 2 + self.beer.selling_price
) )
self.client.force_login(self.subscriber) self.client.force_login(self.subscriber)
@@ -284,5 +280,7 @@ class TestEboutic(TestCase):
) )
assert ( assert (
Basket.objects.get(id=3).total Basket.objects.get(id=3).total
== self.snack.amount * 2 + self.beer.amount + self.cotiz.amount == self.snack.selling_price * 2
+ 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 price_recipe, product_recipe from counter.baker_recipes import 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,22 +32,23 @@ 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,
prices=[price_recipe.make(amount=15)], selling_price=15,
) )
product_type = baker.make(ProductType) product_type = baker.make(ProductType)
cls.snack = product_recipe.make( cls.snack = product_recipe.make(
product_type=product_type, prices=[price_recipe.make(amount=1.5)] selling_price=1.5, special_selling_price=1, product_type=product_type
) )
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_price(cls.snack.prices.first(), 1, cls.basket).save() BasketItem.from_product(cls.snack, 1, cls.basket).save()
BasketItem.from_price(cls.beer.prices.first(), 2, cls.basket).save() BasketItem.from_product(cls.beer, 2, cls.basket).save()
class TestPaymentSith(TestPaymentBase): class TestPaymentSith(TestPaymentBase):
@@ -115,13 +116,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.prices.first().amount assert sellings[0].unit_price == self.snack.selling_price
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.prices.first().amount assert sellings[1].unit_price == self.beer.selling_price
assert sellings[1].counter.type == "EBOUTIC" assert sellings[1].counter.type == "EBOUTIC"
assert sellings[1].product == self.beer assert sellings[1].product == self.beer
@@ -145,7 +146,7 @@ class TestPaymentSith(TestPaymentBase):
) )
def test_refilling_in_basket(self): def test_refilling_in_basket(self):
BasketItem.from_price(self.refilling.prices.first(), 1, self.basket).save() BasketItem.from_product(self.refilling, 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()
@@ -190,8 +191,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() == "Payment successful" assert response.content.decode("utf-8") == "Payment successful"
assert not Basket.objects.filter(id=self.basket.id).exists() assert Basket.objects.filter(id=self.basket.id).first() is None
sellings = Selling.objects.filter(customer=self.customer.customer).order_by( sellings = Selling.objects.filter(customer=self.customer.customer).order_by(
"quantity" "quantity"
@@ -199,13 +200,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.prices.first().amount assert sellings[0].unit_price == self.snack.selling_price
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.prices.first().amount assert sellings[1].unit_price == self.beer.selling_price
assert sellings[1].counter.type == "EBOUTIC" assert sellings[1].counter.type == "EBOUTIC"
assert sellings[1].product == self.beer assert sellings[1].product == self.beer
@@ -215,9 +216,7 @@ 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_price( BasketItem.from_product(Product.objects.get(code="2SCOTIZ"), 1, basket).save()
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
@@ -229,13 +228,12 @@ class TestPaymentCard(TestPaymentBase):
assert subscription.location == "EBOUTIC" assert subscription.location == "EBOUTIC"
def test_buy_refilling(self): def test_buy_refilling(self):
price = self.refilling.prices.first() BasketItem.from_product(self.refilling, 2, self.basket).save()
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 == price.amount * 2 assert self.customer.customer.amount == self.refilling.selling_price * 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)
@@ -255,17 +253,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 response.text == ( assert (
"Basket processing failed with error: " response.text
"SuspiciousOperation('Basket does not exists')" == "Basket processing failed with error: 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_price(self.snack.prices.first(), 1, self.basket).save() BasketItem.from_product(self.snack, 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 response.text == ( assert (
"Basket processing failed with error: " response.text == "Basket processing failed with error: "
"SuspiciousOperation('Basket total and amount do not match')" "SuspiciousOperation('Basket total and amount do not match')"
) )

View File

@@ -17,7 +17,6 @@ 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
@@ -29,7 +28,9 @@ 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 LoginRequiredMixin from django.contrib.auth.mixins import (
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
@@ -47,16 +48,23 @@ 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, BasketItemForm, BillingInfoForm from counter.forms import BaseBasketForm, BasketProductForm, BillingInfoForm
from counter.models import ( from counter.models import (
BillingInfo, BillingInfo,
Customer, Customer,
Price, Product,
Refilling, Refilling,
Selling, Selling,
get_eboutic, get_eboutic,
) )
from eboutic.models import Basket, BasketItem, BillingInfoState, Invoice, InvoiceItem from eboutic.models import (
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
@@ -70,7 +78,7 @@ class BaseEbouticBasketForm(BaseBasketForm):
EbouticBasketForm = forms.formset_factory( EbouticBasketForm = forms.formset_factory(
BasketItemForm, formset=BaseEbouticBasketForm, absolute_max=None, min_num=1 BasketProductForm, formset=BaseEbouticBasketForm, absolute_max=None, min_num=1
) )
@@ -80,6 +88,7 @@ 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"
@@ -90,7 +99,7 @@ class EbouticMainView(LoginRequiredMixin, FormView):
kwargs["form_kwargs"] = { kwargs["form_kwargs"] = {
"customer": self.customer, "customer": self.customer,
"counter": get_eboutic(), "counter": get_eboutic(),
"allowed_prices": {price.id: price for price in self.prices}, "allowed_products": {product.id: product for product in self.products},
} }
return kwargs return kwargs
@@ -101,25 +110,19 @@ 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)
BasketItem.objects.bulk_create( for form in formset:
[ BasketItem.from_product(
BasketItem.from_price( form.product, form.cleaned_data["quantity"], self.basket
form.price, form.cleaned_data["quantity"], self.basket ).save()
) 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 prices(self) -> list[Price]: def products(self) -> list[Product]:
return get_eboutic().get_prices_for( return get_eboutic_products(self.request.user)
self.customer,
order_by=["product__product_type__order", "product_id", "amount"],
)
@cached_property @cached_property
def customer(self) -> Customer: def customer(self) -> Customer:
@@ -127,12 +130,7 @@ 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["categories"] = [ context["products"] = self.products
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 = (
@@ -269,8 +267,11 @@ 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(product__product_type_id=refilling).exists(): if basket.items.filter(type_id=refilling).exists():
messages.error(self.request, _("You can't buy a refilling with sith money")) messages.error(
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()
@@ -325,23 +326,22 @@ class EtransactionAutoAnswer(View):
raise SuspiciousOperation( raise SuspiciousOperation(
"Basket total and amount do not match" "Basket total and amount do not match"
) )
i = Invoice.objects.create(user=b.user) i = Invoice()
InvoiceItem.objects.bulk_create( i.user = b.user
[ i.payment_method = "CARD"
InvoiceItem( i.save()
invoice=i, for it in b.items.all():
product_id=item.product_id, InvoiceItem(
label=item.label, invoice=i,
unit_price=item.unit_price, product_id=it.product_id,
quantity=item.quantity, product_name=it.product_name,
) type_id=it.type_id,
for item in b.items.all() product_unit_price=it.product_unit_price,
] 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,6 +1,7 @@
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,
@@ -114,9 +115,16 @@ 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(
id__in=self.request.user.all_groups groups = set(self.election.vote_groups.values_list("id", flat=True))
).exists() if (
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():
@@ -230,9 +238,15 @@ 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
return self.election.edit_groups.filter( groups = set(self.election.edit_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 get_initial(self): def get_initial(self):
return {"election": self.election} return {"election": self.election}
@@ -265,7 +279,14 @@ 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)
) )
return not groups.isdisjoint(self.request.user.all_groups.keys()) if (
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

@@ -25,13 +25,12 @@ import warnings
from datetime import timedelta from datetime import timedelta
from typing import Final, Optional from typing import Final, Optional
from django.conf import settings
from django.core.files.base import ContentFile from django.core.files.base import ContentFile
from django.core.management.base import BaseCommand from django.core.management.base import BaseCommand
from django.utils import timezone from django.utils import timezone
from club.models import Club, Membership from club.models import Club, Membership
from core.models import Group, Page, SithFile, User from core.models import Group, Page, User
from core.utils import RED_PIXEL_PNG from core.utils import RED_PIXEL_PNG
from sas.models import Album, PeoplePictureRelation, Picture from sas.models import Album, PeoplePictureRelation, Picture
from subscription.models import Subscription from subscription.models import Subscription
@@ -91,13 +90,8 @@ class Command(BaseCommand):
self.NB_CLUBS = options["club_count"] self.NB_CLUBS = options["club_count"]
root = User.objects.filter(username="root").first() root = User.objects.filter(username="root").first()
sas = SithFile.objects.get(id=settings.SITH_SAS_ROOT_DIR_ID)
self.galaxy_album = Album.objects.create( self.galaxy_album = Album.objects.create(
name="galaxy-register-file", name="galaxy-register-file", owner=root, is_moderated=True
owner=root,
is_moderated=True,
is_in_sas=True,
parent=sas,
) )
self.make_clubs() self.make_clubs()
@@ -285,14 +279,10 @@ class Command(BaseCommand):
owner=u, owner=u,
name=f"galaxy-picture {u} {i // self.NB_USERS}", name=f"galaxy-picture {u} {i // self.NB_USERS}",
is_moderated=True, is_moderated=True,
is_folder=False,
parent=self.galaxy_album, parent=self.galaxy_album,
is_in_sas=True, original=ContentFile(RED_PIXEL_PNG),
file=ContentFile(RED_PIXEL_PNG),
compressed=ContentFile(RED_PIXEL_PNG), compressed=ContentFile(RED_PIXEL_PNG),
thumbnail=ContentFile(RED_PIXEL_PNG), thumbnail=ContentFile(RED_PIXEL_PNG),
mime_type="image/png",
size=len(RED_PIXEL_PNG),
) )
) )
self.picts[i].file.name = self.picts[i].name self.picts[i].file.name = self.picts[i].name

File diff suppressed because it is too large Load Diff

View File

@@ -33,6 +33,8 @@ 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

@@ -20,9 +20,9 @@ from sas.models import Album, PeoplePictureRelation, Picture, PictureModerationR
@admin.register(Picture) @admin.register(Picture)
class PictureAdmin(admin.ModelAdmin): class PictureAdmin(admin.ModelAdmin):
list_display = ("name", "parent", "date", "size", "is_moderated") list_display = ("name", "parent", "is_moderated")
search_fields = ("name",) search_fields = ("name",)
autocomplete_fields = ("owner", "parent", "edit_groups", "view_groups", "moderator") autocomplete_fields = ("owner", "parent", "moderator")
@admin.register(PeoplePictureRelation) @admin.register(PeoplePictureRelation)
@@ -33,9 +33,9 @@ class PeoplePictureRelationAdmin(admin.ModelAdmin):
@admin.register(Album) @admin.register(Album)
class AlbumAdmin(admin.ModelAdmin): class AlbumAdmin(admin.ModelAdmin):
list_display = ("name", "parent", "date", "owner", "is_moderated") list_display = ("name", "parent")
search_fields = ("name",) search_fields = ("name",)
autocomplete_fields = ("owner", "parent", "edit_groups", "view_groups") autocomplete_fields = ("parent", "edit_groups", "view_groups")
@admin.register(PictureModerationRequest) @admin.register(PictureModerationRequest)

View File

@@ -3,7 +3,8 @@ from typing import Any, Literal
from django.conf import settings from django.conf import settings
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.urls import reverse from django.urls import reverse
from ninja import Body, File, Query from ninja import Body, Query, UploadedFile
from ninja.errors import HttpError
from ninja.security import SessionAuth from ninja.security import SessionAuth
from ninja_extra import ControllerBase, api_controller, paginate, route from ninja_extra import ControllerBase, api_controller, paginate, route
from ninja_extra.exceptions import NotFound, PermissionDenied from ninja_extra.exceptions import NotFound, PermissionDenied
@@ -16,11 +17,12 @@ from api.permissions import (
CanAccessLookup, CanAccessLookup,
CanEdit, CanEdit,
CanView, CanView,
HasPerm,
IsInGroup, IsInGroup,
IsRoot, IsRoot,
) )
from core.models import Notification, User from core.models import Notification, User
from core.schemas import UploadedImage from core.utils import get_list_exact_or_404
from sas.models import Album, PeoplePictureRelation, Picture from sas.models import Album, PeoplePictureRelation, Picture
from sas.schemas import ( from sas.schemas import (
AlbumAutocompleteSchema, AlbumAutocompleteSchema,
@@ -28,6 +30,7 @@ from sas.schemas import (
AlbumSchema, AlbumSchema,
IdentifiedUserSchema, IdentifiedUserSchema,
ModerationRequestSchema, ModerationRequestSchema,
MoveAlbumSchema,
PictureFilterSchema, PictureFilterSchema,
PictureSchema, PictureSchema,
) )
@@ -69,6 +72,44 @@ class AlbumController(ControllerBase):
Album.objects.viewable_by(self.context.request.user).order_by("-date") Album.objects.viewable_by(self.context.request.user).order_by("-date")
) )
@route.patch("/parent")
def change_album_parent(self, payload: list[MoveAlbumSchema]):
"""Change parents of albums
Note:
For this operation to work, the user must be authorized
to edit both the moved albums and their new parent.
"""
user: User = self.context.request.user
albums: list[Album] = get_list_exact_or_404(
Album, pk__in={a.id for a in payload}
)
if not user.has_perm("sas.change_album"):
unauthorized = [a.id for a in albums if not user.can_edit(a)]
if unauthorized:
raise PermissionDenied(
f"You can't move the following albums : {unauthorized}"
)
parents: list[Album] = get_list_exact_or_404(
Album, pk__in={a.new_parent_id for a in payload}
)
if not user.has_perm("sas.change_album"):
unauthorized = [a.id for a in parents if not user.can_edit(a)]
if unauthorized:
raise PermissionDenied(
f"You can't move to the following albums : {unauthorized}"
)
id_to_new_parent = {i.id: i.new_parent_id for i in payload}
for album in albums:
album.parent_id = id_to_new_parent[album.id]
# known caveat : moving an album won't move it's thumbnail.
# E.g. if the album foo/bar is moved to foo/baz,
# the thumbnail will still be foo/bar/thumb.webp
# This has no impact for the end user
# and doing otherwise would be hard for us to implement,
# because we would then have to manage rollbacks on fail.
Album.objects.bulk_update(albums, fields=["parent_id"])
@api_controller("/sas/picture") @api_controller("/sas/picture")
class PicturesController(ControllerBase): class PicturesController(ControllerBase):
@@ -96,7 +137,7 @@ class PicturesController(ControllerBase):
return ( return (
filters.filter(Picture.objects.viewable_by(user)) filters.filter(Picture.objects.viewable_by(user))
.distinct() .distinct()
.order_by("-parent__date", "date") .order_by("-parent__event_date", "created_at")
.select_related("owner", "parent") .select_related("owner", "parent")
) )
@@ -110,27 +151,25 @@ class PicturesController(ControllerBase):
}, },
url_name="upload_picture", url_name="upload_picture",
) )
def upload_picture(self, album_id: Body[int], picture: File[UploadedImage]): def upload_picture(self, album_id: Body[int], picture: UploadedFile):
album = self.get_object_or_exception(Album, pk=album_id) album = self.get_object_or_exception(Album, pk=album_id)
user = self.context.request.user user = self.context.request.user
self_moderate = user.has_perm("sas.moderate_sasfile") self_moderate = user.has_perm("sas.moderate_sasfile")
new = Picture( new = Picture(
parent=album, parent=album,
name=picture.name, name=picture.name,
file=picture, original=picture,
owner=user, owner=user,
is_moderated=self_moderate, is_moderated=self_moderate,
is_folder=False,
mime_type=picture.content_type,
) )
if self_moderate: if self_moderate:
new.moderator = user new.moderator = user
new.generate_thumbnails()
try: try:
new.generate_thumbnails()
new.full_clean() new.full_clean()
new.save()
except ValidationError as e: except ValidationError as e:
return self.create_response({"detail": dict(e)}, status_code=409) raise HttpError(status_code=409, message=str(e)) from e
new.save()
@route.get( @route.get(
"/{picture_id}/identified", "/{picture_id}/identified",
@@ -215,9 +254,9 @@ class UsersIdentifiedController(ControllerBase):
relation = self.get_object_or_exception(PeoplePictureRelation, pk=relation_id) relation = self.get_object_or_exception(PeoplePictureRelation, pk=relation_id)
user: User = self.context.request.user user: User = self.context.request.user
if ( if (
relation.user_id != user.id relation.user_id != user.id
and not user.is_root and not user.is_root
and not user.is_in_group(pk=settings.SITH_GROUP_SAS_ADMIN_ID) and not user.is_in_group(pk=settings.SITH_GROUP_SAS_ADMIN_ID)
): ):
raise PermissionDenied raise PermissionDenied
relation.delete() relation.delete()

View File

@@ -1,18 +1,35 @@
from django.core.files.uploadedfile import SimpleUploadedFile
from model_bakery import seq from model_bakery import seq
from model_bakery.recipe import Recipe from model_bakery.recipe import Recipe
from sas.models import Picture from core.utils import RED_PIXEL_PNG
from sas.models import Album, Picture
album_recipe = Recipe(
Album,
name=seq("Album "),
thumbnail=SimpleUploadedFile(
name="thumb.webp", content=b"", content_type="image/webp"
),
)
picture_recipe = Recipe( picture_recipe = Recipe(
Picture, Picture,
is_in_sas=True,
is_folder=False,
is_moderated=True, is_moderated=True,
name=seq("Picture "), name=seq("Picture "),
original=SimpleUploadedFile(
# compressed and thumbnail are generated on save (except if bulk creating).
# For this step no to fail, original must be a valid image.
name="img.png",
content=RED_PIXEL_PNG,
content_type="image/png",
),
compressed=SimpleUploadedFile(
name="img.webp", content=b"", content_type="image/webp"
),
thumbnail=SimpleUploadedFile(
name="img.webp", content=b"", content_type="image/webp"
),
) )
"""A SAS Picture fixture. """A SAS Picture fixture."""
Warnings:
If you don't `bulk_create` this, you need
to explicitly set the parent album, or it won't work
"""

View File

@@ -48,13 +48,12 @@ class PictureEditForm(forms.ModelForm):
class AlbumEditForm(forms.ModelForm): class AlbumEditForm(forms.ModelForm):
class Meta: class Meta:
model = Album model = Album
fields = ["name", "date", "file", "parent", "edit_groups"] fields = ["name", "date", "thumbnail", "parent", "edit_groups"]
widgets = { widgets = {
"parent": AutoCompleteSelectAlbum, "parent": AutoCompleteSelectAlbum,
"edit_groups": AutoCompleteSelectMultipleGroup, "edit_groups": AutoCompleteSelectMultipleGroup,
} }
name = forms.CharField(max_length=Album.NAME_MAX_LENGTH, label=_("file name"))
date = forms.DateField(label=_("Date"), widget=SelectDate, required=True) date = forms.DateField(label=_("Date"), widget=SelectDate, required=True)
recursive = forms.BooleanField(label=_("Apply rights recursively"), required=False) recursive = forms.BooleanField(label=_("Apply rights recursively"), required=False)

View File

@@ -0,0 +1,357 @@
# Generated by Django 4.2.17 on 2025-01-22 21:53
import collections
import itertools
import logging
from typing import TYPE_CHECKING
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
from django.db.migrations.state import StateApps
import sas.models
if TYPE_CHECKING:
import core.models
# NB : tous les commentaires sont écrits en français,
# parce qu'on est sur des opérations qui sont complexes,
# et qui sont surtout DANGEREUSES.
# Ici, la clarté des explications prime sur toute autre considération.
def copy_albums_and_pictures(apps: StateApps, schema_editor):
SithFile: type[core.models.SithFile] = apps.get_model("core", "SithFile")
Album: type[sas.models.Album] = apps.get_model("sas", "Album")
Picture: type[sas.models.Picture] = apps.get_model("sas", "Picture")
logger = logging.getLogger("django")
# Il y a environ 1800 albums, 257k photos et 488k identifications
# d'utilisateurs dans la db de prod.
# En supposant qu'une insertion prenne 10ms (ce qui est très optimiste),
# migrer tous les enregistrements de la db prendrait plus de 2h.
# C'est trop long.
# Mais d'un autre côté, j'ai pas assez confiance dans les capacités de nos
# machines pour charger presque un million d'objets en mémoire.
# Pour faire un compromis, les albums sont migrés individuellement un à un,
# mais tous les objets liés à ces albums
# (photos, groupes de vue, groupe d'édition, identification d'utilisateurs)
# sont migrés en tas.
#
# Ordre des opérations :
# 1. On migre les albums 1 à 1 (il y en a 1800, donc c'est relativement court)
# 2. On migre les photos par paquet de 2500 (soit ~une centaine d'opérations)
# 3. On migre tous les groupes de vue et tous les groupes d'édition des albums
#
# Au total, la migration devrait demander aux alentours de 2000 insertions,
# ce qui est un compromis acceptable entre une migration
# pas trop longue et une RAM pas trop surchargée.
#
# Pour ce qui est de la répartition des tables, quatre nouvelles tables
# sont créées : sas_album, sas_picture,
# sas_pictureviewgroups et sas_picture_editgroups.
# Tous les albums et toutes les photos qui sont dans core_sithfile
# vont être copiés dans ces tables.
# Comme les albums sont migrés un à un, ils recevront une nouvelle
# clef primaire.
# Pour les photos, en revanche, c'est beaucoup plus sûr de leur donner
# le même id que celui qu'il y avait dans core_sithfile.
#
# Les identifications des photos ne sont pas migrées pour l'instant.
# Ce qu'on va faire, c'est qu'on va changer la contrainte de clef étrangère
# sur la colonne des photos pour pointer vers sas_picture
# au lieu de core_sithfile.
# Cependant, pour que ça marche,
# il faut qu'au moment où ce changement est effectué,
# toutes les clefs primaires référencées existent à la fois dans
# les deux tables, sinon les contraintes d'intégrité ne sont pas respectées.
# La migration de ce fichier va donc s'occuper de créer les nouvelles tables
# et d'y copier les données nécessaires.
# Puis une deuxième migration s'occupera de changer les contraintes.
# Et enfin une troisième migration supprimera les anciennes données.
#
# Pavé César
albums = SithFile.objects.filter(is_in_sas=True, is_folder=True).prefetch_related(
"view_groups", "edit_groups"
)
old_albums = collections.deque(
albums.filter(parent_id=settings.SITH_SAS_ROOT_DIR_ID)
)
# Changement de représentation en DB.
# Dans l'ancien système, un fichier était dans le SAS si
# un fichier spécial (le SAS_ROOT) était parmi ses ancêtres.
# Comme maintenant les fichiers du SAS sont dans des tables à part,
# il ne peut plus y avoir de confusion.
# Les photos ont donc obligatoirement un parent (qui est un album)
# et les albums peuvent avoir un parent null.
# Un album sans parent est considéré comme se trouvant à la racine
# de l'arborescence.
# En quelque sorte, None est le nouveau SITH_SAS_ROOT_DIR_ID
album_id_old_to_new = {settings.SITH_SAS_ROOT_DIR_ID: None}
logger.info(f"migrating {albums.count()} albums")
while len(old_albums) > 0:
# Comme les albums référencent leur parent, les albums doivent être migrés
# par ordre croissant de profondeur dans l'arborescence.
# Chaque album est donc pris par la gauche de la file
# et ses enfants ajoutés sur la droite.
old_album = old_albums.popleft()
old_albums.extend(list(albums.filter(parent=old_album)))
new_album = Album.objects.create(
parent_id=album_id_old_to_new[old_album.parent_id],
event_date=old_album.date.date(),
name=old_album.name,
thumbnail=(old_album.file or None),
is_moderated=old_album.is_moderated,
)
# on garde un dictionnaire qui associe les id des albums dans l'ancienne table
# à leur id dans la nouvelle table, pour pouvoir recréer
# les liens de parenté entre albums
album_id_old_to_new[old_album.id] = new_album.id
pictures = SithFile.objects.filter(is_in_sas=True, is_folder=False)
nb_pictures = pictures.count()
logger.info(f"migrating {nb_pictures} pictures")
for i, pictures_batch in enumerate(itertools.batched(pictures, 2500), start=1):
Picture.objects.bulk_create(
[
Picture(
id=p.id,
name=p.name,
parent_id=album_id_old_to_new[p.parent_id],
thumbnail=p.thumbnail,
compressed=p.compressed,
original=p.file,
owner_id=p.owner_id,
created_at=p.date,
is_moderated=p.is_moderated,
asked_for_removal=p.asked_for_removal,
moderator_id=p.moderator_id,
)
for p in pictures_batch
]
)
logger.info(f"Migrated {min(i * 2500, nb_pictures)} / {nb_pictures} pictures")
logger.info("Migrating album groups")
albums = SithFile.objects.filter(is_in_sas=True, is_folder=True).exclude(
id=settings.SITH_SAS_ROOT_DIR_ID
)
Album.edit_groups.through.objects.bulk_create(
[
Album.view_groups.through(
album=album_id_old_to_new[g.sithfile_id], group_id=g.group_id
)
for g in SithFile.view_groups.through.objects.filter(sithfile__in=albums)
]
)
Album.edit_groups.through.objects.bulk_create(
[
Album.view_groups.through(
album=album_id_old_to_new[g.sithfile_id], group_id=g.group_id
)
for g in SithFile.view_groups.through.objects.filter(sithfile__in=albums)
]
)
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
("core", "0044_alter_userban_options"),
("sas", "0005_alter_sasfile_options"),
]
operations = [
# les relations et les demandes de modération étaient liées à SithFile,
# via le model proxy Picture.
# Pour que la migration marche malgré la disparition du modèle Proxy,
# on change la relation pour qu'elle pointe directement vers SithFile
migrations.AlterField(
model_name="peoplepicturerelation",
name="picture",
field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="people",
to="core.sithfile",
verbose_name="picture",
),
),
migrations.AlterField(
model_name="picturemoderationrequest",
name="picture",
field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="moderation_requests",
to="core.sithfile",
verbose_name="Picture",
),
),
migrations.DeleteModel(name="Album"),
migrations.DeleteModel(name="Picture"),
migrations.DeleteModel(name="SasFile"),
migrations.CreateModel(
name="Album",
fields=[
(
"id",
models.AutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"thumbnail",
models.FileField(
max_length=256,
upload_to=sas.models.get_thumbnail_directory,
verbose_name="thumbnail",
),
),
("name", models.CharField(max_length=100, verbose_name="name")),
(
"event_date",
models.DateField(
default=django.utils.timezone.localdate,
help_text="The date on which the photos in this album were taken",
verbose_name="event date",
),
),
(
"is_moderated",
models.BooleanField(default=False, verbose_name="is moderated"),
),
(
"edit_groups",
models.ManyToManyField(
related_name="editable_albums",
to="core.group",
verbose_name="edit groups",
),
),
(
"parent",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="children",
to="sas.album",
verbose_name="parent",
),
),
(
"view_groups",
models.ManyToManyField(
related_name="viewable_albums",
to="core.group",
verbose_name="view groups",
),
),
],
options={"verbose_name": "album"},
),
migrations.CreateModel(
name="Picture",
fields=[
(
"id",
models.AutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"thumbnail",
models.FileField(
unique=True,
upload_to=sas.models.get_thumbnail_directory,
verbose_name="thumbnail",
max_length=256,
),
),
("name", models.CharField(max_length=256, verbose_name="file name")),
(
"original",
models.FileField(
unique=True,
upload_to=sas.models.get_directory,
verbose_name="original image",
max_length=256,
),
),
(
"compressed",
models.FileField(
unique=True,
upload_to=sas.models.get_compressed_directory,
verbose_name="compressed image",
max_length=256,
),
),
("created_at", models.DateTimeField(default=django.utils.timezone.now)),
(
"is_moderated",
models.BooleanField(default=False, verbose_name="is moderated"),
),
(
"asked_for_removal",
models.BooleanField(
default=False, verbose_name="asked for removal"
),
),
(
"moderator",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="moderated_pictures",
to=settings.AUTH_USER_MODEL,
),
),
(
"owner",
models.ForeignKey(
on_delete=django.db.models.deletion.PROTECT,
related_name="owned_pictures",
to=settings.AUTH_USER_MODEL,
verbose_name="owner",
),
),
(
"parent",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="pictures",
to="sas.album",
verbose_name="album",
),
),
],
options={"abstract": False, "verbose_name": "picture"},
),
migrations.AddConstraint(
model_name="picture",
constraint=models.UniqueConstraint(
fields=("name", "parent"), name="sas_picture_unique_per_album"
),
),
migrations.AddConstraint(
model_name="album",
constraint=models.UniqueConstraint(
fields=("name", "parent"), name="unique_album_name_if_same_parent"
),
),
migrations.RunPython(
copy_albums_and_pictures,
reverse_code=migrations.RunPython.noop,
elidable=True,
),
]

View File

@@ -0,0 +1,31 @@
# Generated by Django 4.2.17 on 2025-01-25 23:50
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [("sas", "0006_move_the_whole_sas")]
operations = [
migrations.AlterField(
model_name="peoplepicturerelation",
name="picture",
field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="people",
to="sas.picture",
verbose_name="picture",
),
),
migrations.AlterField(
model_name="picturemoderationrequest",
name="picture",
field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="moderation_requests",
to="sas.picture",
verbose_name="Picture",
),
),
]

View File

@@ -18,29 +18,57 @@ from __future__ import annotations
import contextlib import contextlib
from io import BytesIO from io import BytesIO
from pathlib import Path from pathlib import Path
from typing import ClassVar, Self from typing import TYPE_CHECKING, ClassVar, Self
from django.conf import settings from django.conf import settings
from django.core.cache import cache from django.core.cache import cache
from django.core.exceptions import ValidationError
from django.core.files.base import ContentFile
from django.db import models from django.db import models
from django.db.models import Exists, OuterRef, Q from django.db.models import Exists, OuterRef, Q
from django.db.models.deletion import Collector
from django.urls import reverse from django.urls import reverse
from django.utils import timezone
from django.utils.functional import cached_property
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from PIL import Image from PIL import Image
from core.models import Notification, SithFile, User from core.models import Group, Notification, User
from core.utils import exif_auto_rotate, resize_image from core.utils import exif_auto_rotate, resize_image
if TYPE_CHECKING:
from django.db.models.fields.files import FieldFile
class SasFile(SithFile):
"""Proxy model for any file in the SAS.
May be used to have logic that should be shared by both def get_directory(instance: SasFile, filename: str):
return f"./{instance.parent_path}/{filename}"
def get_compressed_directory(instance: SasFile, filename: str):
return f"./.compressed/{instance.parent_path}/{filename}"
def get_thumbnail_directory(instance: SasFile, filename: str):
if isinstance(instance, Album):
_, extension = filename.rsplit(".", 1)
filename = f"{instance.name}/thumb.{extension}"
return f"./.thumbnails/{instance.parent_path}/{filename}"
class SasFile(models.Model):
"""Abstract model for SAS files
This model is used to have logic that should be shared by both
[Picture][sas.models.Picture] and [Album][sas.models.Album]. [Picture][sas.models.Picture] and [Album][sas.models.Album].
Notes:
This is an abstract model.
[Album][sas.models.Album] and [Picture][sas.models.Picture]
are separated tables in the database.
""" """
class Meta: class Meta:
proxy = True abstract = True
permissions = [ permissions = [
("moderate_sasfile", "Can moderate SAS files"), ("moderate_sasfile", "Can moderate SAS files"),
("view_unmoderated_sasfile", "Can view not moderated SAS files"), ("view_unmoderated_sasfile", "Can view not moderated SAS files"),
@@ -65,6 +93,169 @@ class SasFile(SithFile):
def can_be_edited_by(self, user): def can_be_edited_by(self, user):
return user.has_perm("sas.change_sasfile") return user.has_perm("sas.change_sasfile")
@cached_property
def parent_path(self) -> str:
"""The parent location in the SAS album tree (e.g. `SAS/foo/bar`)."""
return "/".join(["SAS", *[p.name for p in self.parent_list]])
@cached_property
def parent_list(self) -> list[Album]:
"""The ancestors of this SAS object.
The result is ordered from the direct parent to the farthest one.
"""
parents = []
current = self.parent
while current is not None:
parents.append(current)
current = current.parent
return parents
class AlbumQuerySet(models.QuerySet):
def viewable_by(self, user: User) -> Self:
"""Filter the albums that this user can view.
Warning:
Calling this queryset method may add several additional requests.
"""
if user.is_root or user.is_in_group(pk=settings.SITH_GROUP_SAS_ADMIN_ID):
return self.all()
if user.was_subscribed:
return self.filter(is_moderated=True)
# known bug : if all children of an album are also albums
# then this album is excluded, even if one of the sub-albums should be visible.
# The fs-like navigation is likely to be half-broken for non-subscribers,
# but that's ok, since non-subscribers are expected to see only the albums
# containing pictures on which they have been identified (hence, very few).
# Most, if not all, of their albums will be displayed on the
# `latest albums` section of the SAS.
# Moreover, they will still see all of their picture in their profile.
return self.filter(
Exists(Picture.objects.filter(parent_id=OuterRef("pk")).viewable_by(user))
)
class Album(SasFile):
NAME_MAX_LENGTH: ClassVar[int] = 50
name = models.CharField(_("name"), max_length=100)
parent = models.ForeignKey(
"self",
related_name="children",
verbose_name=_("parent"),
null=True,
blank=True,
on_delete=models.CASCADE,
)
thumbnail = models.FileField(
upload_to=get_thumbnail_directory,
verbose_name=_("thumbnail"),
max_length=256,
blank=True,
)
view_groups = models.ManyToManyField(
Group, related_name="viewable_albums", verbose_name=_("view groups"), blank=True
)
edit_groups = models.ManyToManyField(
Group, related_name="editable_albums", verbose_name=_("edit groups"), blank=True
)
event_date = models.DateField(
_("event date"),
help_text=_("The date on which the photos in this album were taken"),
default=timezone.localdate,
blank=True,
)
is_moderated = models.BooleanField(_("is moderated"), default=False)
objects = AlbumQuerySet.as_manager()
class Meta:
verbose_name = _("album")
constraints = [
models.UniqueConstraint(
fields=["name", "parent"],
name="unique_album_name_if_same_parent",
# TODO : add `nulls_distinct=True` after upgrading to django>=5.0
)
]
def __str__(self):
return f"Album {self.name}"
def save(self, *args, **kwargs):
super().save(*args, **kwargs)
for user in User.objects.filter(
groups__id__in=[settings.SITH_GROUP_SAS_ADMIN_ID]
):
Notification(
user=user,
url=reverse("sas:moderation"),
type="SAS_MODERATION",
param="1",
).save()
def get_absolute_url(self):
return reverse("sas:album", kwargs={"album_id": self.id})
def clean(self):
super().clean()
if "/" in self.name:
raise ValidationError(_("Character '/' not authorized in name"))
if self.parent_id is not None and (
self.id == self.parent_id or self in self.parent_list
):
raise ValidationError(_("Loop in album tree"), code="loop")
if self.thumbnail:
try:
Image.open(BytesIO(self.thumbnail.read()))
except Image.UnidentifiedImageError as e:
raise ValidationError(_("This is not a valid album thumbnail")) from e
def delete(self, *args, **kwargs):
"""Delete the album, all of its children and all linked disk files"""
collector = Collector(using="default")
collector.collect([self])
albums: set[Album] = collector.data[Album]
pictures: set[Picture] = collector.data[Picture]
files: list[FieldFile] = [
*[a.thumbnail for a in albums],
*[p.thumbnail for p in pictures],
*[p.compressed for p in pictures],
*[p.original for p in pictures],
]
# `bool(f)` checks that the file actually exists on the disk
files = [f for f in files if bool(f)]
folders = {Path(f.path).parent for f in files}
res = super().delete(*args, **kwargs)
# once the model instances have been deleted,
# delete the actual files.
for file in files:
# save=False ensures that django doesn't recreate the db record,
# which would make the whole deletion pointless
# cf. https://docs.djangoproject.com/en/stable/ref/models/fields/#django.db.models.fields.files.FieldFile.delete
file.delete(save=False)
for folder in folders:
# now that the files are deleted, remove the empty folders
if folder.is_dir() and next(folder.iterdir(), None) is None:
folder.rmdir()
return res
def get_download_url(self):
return reverse("sas:album_preview", kwargs={"album_id": self.id})
def generate_thumbnail(self):
p = (
self.pictures.exclude(thumbnail="").order_by("?").first()
or self.children.exclude(thumbnail="").order_by("?").first()
)
if p:
# The file is loaded into memory to duplicate it.
# It may not be the most efficient way, but thumbnails are
# usually quite small, so it's still ok
self.thumbnail = ContentFile(p.thumbnail.read(), name="thumb.webp")
self.save()
class PictureQuerySet(models.QuerySet): class PictureQuerySet(models.QuerySet):
def viewable_by(self, user: User) -> Self: def viewable_by(self, user: User) -> Self:
@@ -80,23 +271,65 @@ class PictureQuerySet(models.QuerySet):
return self.filter(people__user_id=user.id, is_moderated=True) return self.filter(people__user_id=user.id, is_moderated=True)
class SASPictureManager(models.Manager):
def get_queryset(self):
return super().get_queryset().filter(is_in_sas=True, is_folder=False)
class Picture(SasFile): class Picture(SasFile):
name = models.CharField(_("file name"), max_length=256)
parent = models.ForeignKey(
Album,
related_name="pictures",
verbose_name=_("album"),
on_delete=models.CASCADE,
)
thumbnail = models.FileField(
upload_to=get_thumbnail_directory,
verbose_name=_("thumbnail"),
max_length=256,
unique=True,
)
original = models.FileField(
upload_to=get_directory,
verbose_name=_("original image"),
max_length=256,
unique=True,
)
compressed = models.FileField(
upload_to=get_compressed_directory,
verbose_name=_("compressed image"),
max_length=256,
unique=True,
)
created_at = models.DateTimeField(default=timezone.now)
owner = models.ForeignKey(
User,
related_name="owned_pictures",
verbose_name=_("owner"),
on_delete=models.PROTECT,
)
is_moderated = models.BooleanField(_("is moderated"), default=False)
asked_for_removal = models.BooleanField(_("asked for removal"), default=False)
moderator = models.ForeignKey(
User,
related_name="moderated_pictures",
null=True,
blank=True,
on_delete=models.SET_NULL,
)
objects = PictureQuerySet.as_manager()
class Meta: class Meta:
proxy = True verbose_name = _("picture")
constraints = [
models.UniqueConstraint(
fields=["name", "parent"], name="sas_picture_unique_per_album"
)
]
objects = SASPictureManager.from_queryset(PictureQuerySet)() def __str__(self):
return self.name
@property def get_absolute_url(self):
def is_vertical(self): return reverse("sas:picture", kwargs={"picture_id": self.id})
with open(settings.MEDIA_ROOT / self.file.name, "rb") as f:
im = Image.open(BytesIO(f.read()))
(w, h) = im.size
return (w / h) < 1
def get_download_url(self): def get_download_url(self):
return reverse("sas:download", kwargs={"picture_id": self.id}) return reverse("sas:download", kwargs={"picture_id": self.id})
@@ -107,41 +340,34 @@ class Picture(SasFile):
def get_download_thumb_url(self): def get_download_thumb_url(self):
return reverse("sas:download_thumb", kwargs={"picture_id": self.id}) return reverse("sas:download_thumb", kwargs={"picture_id": self.id})
def get_absolute_url(self): @property
return reverse("sas:picture", kwargs={"picture_id": self.id}) def is_vertical(self):
# original, compressed and thumbnail image have all three the same ratio,
# so the smallest one is used to tell if the image is vertical
im = Image.open(BytesIO(self.thumbnail.read()))
(w, h) = im.size
return w < h
def generate_thumbnails(self, *, overwrite=False): def generate_thumbnails(self):
im = Image.open(BytesIO(self.file.read())) im = Image.open(self.original)
with contextlib.suppress(Exception): with contextlib.suppress(Exception):
im = exif_auto_rotate(im) im = exif_auto_rotate(im)
# convert the compressed image and the thumbnail into webp # convert the compressed image and the thumbnail into webp
# The original image keeps its original type, because it's not
# meant to be shown on the website, but rather to keep the real image
# for less frequent cases (like downloading the pictures of an user)
extension = self.mime_type.split("/")[-1]
# the HD version of the image doesn't need to be optimized, because : # the HD version of the image doesn't need to be optimized, because :
# - it isn't frequently queried # - it isn't frequently queried
# - optimizing large images takes a lot time, which greatly hinders the UX # - optimizing large images takes a lot of time, which greatly hinders the UX
# - photographers usually already optimize their images # - photographers usually already optimize their images
file = resize_image(im, max(im.size), extension, optimize=False)
thumb = resize_image(im, 200, "webp") thumb = resize_image(im, 200, "webp")
compressed = resize_image(im, 1200, "webp") compressed = resize_image(im, 1200, "webp")
if overwrite: new_extension_name = str(Path(self.original.name).with_suffix(".webp"))
self.file.delete()
self.thumbnail.delete()
self.compressed.delete()
new_extension_name = str(Path(self.name).with_suffix(".webp"))
self.file = file
self.file.name = self.name
self.thumbnail = thumb self.thumbnail = thumb
self.thumbnail.name = new_extension_name self.thumbnail.name = new_extension_name
self.compressed = compressed self.compressed = compressed
self.compressed.name = new_extension_name self.compressed.name = new_extension_name
def rotate(self, degree): def rotate(self, degree):
for attr in ["file", "compressed", "thumbnail"]: for field in self.original, self.compressed, self.thumbnail:
name = self.__getattribute__(attr).name with open(field.file, "r+b") as file:
with open(settings.MEDIA_ROOT / name, "r+b") as file:
if file: if file:
im = Image.open(BytesIO(file.read())) im = Image.open(BytesIO(file.read()))
file.seek(0) file.seek(0)
@@ -154,110 +380,6 @@ class Picture(SasFile):
progressive=True, progressive=True,
) )
def get_next(self):
if self.is_moderated:
pictures_qs = self.parent.children.filter(
is_moderated=True,
asked_for_removal=False,
is_folder=False,
id__gt=self.id,
)
else:
pictures_qs = Picture.objects.filter(id__gt=self.id, is_moderated=False)
return pictures_qs.order_by("id").first()
def get_previous(self):
if self.is_moderated:
pictures_qs = self.parent.children.filter(
is_moderated=True,
asked_for_removal=False,
is_folder=False,
id__lt=self.id,
)
else:
pictures_qs = Picture.objects.filter(id__lt=self.id, is_moderated=False)
return pictures_qs.order_by("-id").first()
class AlbumQuerySet(models.QuerySet):
def viewable_by(self, user: User) -> Self:
"""Filter the albums that this user can view.
Warning:
Calling this queryset method may add several additional requests.
"""
if user.has_perm("sas.moderate_sasfile"):
return self.all()
if user.was_subscribed:
return self.filter(Q(is_moderated=True) | Q(owner=user))
# known bug : if all children of an album are also albums
# then this album is excluded, even if one of the sub-albums should be visible.
# The fs-like navigation is likely to be half-broken for non-subscribers,
# but that's ok, since non-subscribers are expected to see only the albums
# containing pictures on which they have been identified (hence, very few).
# Most, if not all, of their albums will be displayed on the
# `latest albums` section of the SAS.
# Moreover, they will still see all of their picture in their profile.
return self.filter(
Exists(Picture.objects.filter(parent_id=OuterRef("pk")).viewable_by(user))
)
class SASAlbumManager(models.Manager):
def get_queryset(self):
return super().get_queryset().filter(is_in_sas=True, is_folder=True)
class Album(SasFile):
NAME_MAX_LENGTH: ClassVar[int] = 50
"""Maximum length of an album's name.
[SithFile][core.models.SithFile] have a maximum length
of 256 characters.
However, this limit is too high for albums.
Names longer than 50 characters are harder to read
and harder to display on the SAS page.
It is to be noted, though, that this does not
add or modify any db behaviour.
It's just a constant to be used in views and forms.
"""
class Meta:
proxy = True
objects = SASAlbumManager.from_queryset(AlbumQuerySet)()
@property
def children_pictures(self):
return Picture.objects.filter(parent=self)
@property
def children_albums(self):
return Album.objects.filter(parent=self)
def get_absolute_url(self):
if self.id == settings.SITH_SAS_ROOT_DIR_ID:
return reverse("sas:main")
return reverse("sas:album", kwargs={"album_id": self.id})
def get_download_url(self):
return reverse("sas:album_preview", kwargs={"album_id": self.id})
def generate_thumbnail(self):
p = (
self.children_pictures.order_by("?").first()
or self.children_albums.exclude(file=None)
.exclude(file="")
.order_by("?")
.first()
)
if p and p.file:
image = resize_image(Image.open(BytesIO(p.file.read())), 200, "webp")
self.file = image
self.file.name = f"{self.name}/thumb.webp"
self.save()
def sas_notification_callback(notif: Notification): def sas_notification_callback(notif: Notification):
count = Picture.objects.filter(is_moderated=False).count() count = Picture.objects.filter(is_moderated=False).count()

View File

@@ -26,19 +26,10 @@ class SimpleAlbumSchema(ModelSchema):
class AlbumSchema(ModelSchema): class AlbumSchema(ModelSchema):
class Meta: class Meta:
model = Album model = Album
fields = ["id", "name", "is_moderated"] fields = ["id", "name", "is_moderated", "thumbnail"]
thumbnail: str | None
sas_url: str sas_url: str
@staticmethod
def resolve_thumbnail(obj: Album) -> str | None:
# Album thumbnails aren't stored in `Album.thumbnail` but in `Album.file`
# Don't ask me why.
if not obj.file:
return None
return obj.get_download_url()
@staticmethod @staticmethod
def resolve_sas_url(obj: Album) -> str: def resolve_sas_url(obj: Album) -> str:
return obj.get_absolute_url() return obj.get_absolute_url()
@@ -55,7 +46,12 @@ class AlbumAutocompleteSchema(ModelSchema):
@staticmethod @staticmethod
def resolve_path(obj: Album) -> str: def resolve_path(obj: Album) -> str:
return str(Path(obj.get_parent_path()) / obj.name) return str(Path(obj.parent_path) / obj.name)
class MoveAlbumSchema(Schema):
id: int
new_parent_id: int
class PictureFilterSchema(FilterSchema): class PictureFilterSchema(FilterSchema):
@@ -70,7 +66,7 @@ class PictureFilterSchema(FilterSchema):
class PictureSchema(ModelSchema): class PictureSchema(ModelSchema):
class Meta: class Meta:
model = Picture model = Picture
fields = ["id", "name", "date", "size", "is_moderated", "asked_for_removal"] fields = ["id", "name", "created_at", "is_moderated", "asked_for_removal"]
owner: UserProfileSchema owner: UserProfileSchema
sas_url: str sas_url: str

View File

@@ -128,3 +128,108 @@ document.addEventListener("alpine:init", () => {
}, },
})); }));
}); });
// Todo: migrate to alpine.js if we have some time
// $("form#upload_form").submit(function (event) {
// const formData = new FormData($(this)[0]);
//
// if (!formData.get("album_name") && !formData.get("images").name) return false;
//
// if (!formData.get("images").name) {
// return true;
// }
//
// event.preventDefault();
//
// let errorList = this.querySelector("#upload_form ul.errorlist.nonfield");
// if (errorList === null) {
// errorList = document.createElement("ul");
// errorList.classList.add("errorlist", "nonfield");
// this.insertBefore(errorList, this.firstElementChild);
// }
//
// while (errorList.childElementCount > 0)
// errorList.removeChild(errorList.firstElementChild);
//
// let progress = this.querySelector("progress");
// if (progress === null) {
// progress = document.createElement("progress");
// progress.value = 0;
// const p = document.createElement("p");
// p.appendChild(progress);
// this.insertBefore(p, this.lastElementChild);
// }
//
// let dataHolder;
//
// if (formData.get("album_name")) {
// dataHolder = new FormData();
// dataHolder.set("csrfmiddlewaretoken", "{{ csrf_token }}");
// dataHolder.set("album_name", formData.get("album_name"));
// $.ajax({
// method: "POST",
// url: "{{ url('sas:album_upload', album_id=object.id) }}",
// data: dataHolder,
// processData: false,
// contentType: false,
// success: onSuccess,
// });
// }
//
// const images = formData.getAll("images");
// const imagesCount = images.length;
// let completeCount = 0;
//
// const poolSize = 1;
// const imagePool = [];
//
// while (images.length > 0 && imagePool.length < poolSize) {
// const image = images.shift();
// imagePool.push(image);
// sendImage(image);
// }
//
// function sendImage(image) {
// dataHolder = new FormData();
// dataHolder.set("csrfmiddlewaretoken", "{{ csrf_token }}");
// dataHolder.set("images", image);
//
// $.ajax({
// method: "POST",
// url: "{{ url('sas:album_upload', album_id=object.id) }}",
// data: dataHolder,
// processData: false,
// contentType: false,
// })
// .fail(onSuccess.bind(undefined, image))
// .done(onSuccess.bind(undefined, image))
// .always(next.bind(undefined, image));
// }
//
// function next(image, _, __) {
// const index = imagePool.indexOf(image);
// const nextImage = images.shift();
//
// if (index !== -1) {
// imagePool.splice(index, 1);
// }
//
// if (nextImage) {
// imagePool.push(nextImage);
// sendImage(nextImage);
// }
// }
//
// function onSuccess(image, data, _, __) {
// let errors = [];
//
// if ($(data.responseText).find(".errorlist.nonfield")[0])
// errors = Array.from($(data.responseText).find(".errorlist.nonfield")[0].children);
//
// while (errors.length > 0) errorList.appendChild(errors.shift());
//
// progress.value = ++completeCount / imagesCount;
// if (progress.value === 1 && errorList.children.length === 0)
// document.location.reload();
// }
// });

View File

@@ -31,10 +31,10 @@ document.addEventListener("alpine:init", () => {
await Promise.all( await Promise.all(
this.downloadPictures.map((p: PictureSchema) => { this.downloadPictures.map((p: PictureSchema) => {
const imgName = `${p.album.name}/IMG_${p.id}_${p.date.replace(/[:-]/g, "_")}${p.name.slice(p.name.lastIndexOf("."))}`; const imgName = `${p.album.name}/IMG_${p.id}_${p.created_at.replace(/[:-]/g, "_")}${p.name.slice(p.name.lastIndexOf("."))}`;
return zipWriter.add(imgName, new HttpReader(p.full_size_url), { return zipWriter.add(imgName, new HttpReader(p.full_size_url), {
level: 9, level: 9,
lastModDate: new Date(p.date), lastModDate: new Date(p.created_at),
onstart: incrementProgressBar, onstart: incrementProgressBar,
}); });
}), }),

View File

@@ -109,225 +109,233 @@ 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 */
userCanModerate: boolean; userIsSasAdmin: boolean;
} }
/** /**
* Load user picture page with a nice download bar * Load user picture page with a nice download bar
**/ **/
document.addEventListener("alpine:init", () => { exportToHtml("loadViewer", (config: ViewerConfig) => {
Alpine.data("picture_viewer", (config: ViewerConfig) => ({ document.addEventListener("alpine:init", () => {
/** 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 /**
* Default dummy data are pre-loaded to avoid javascript error * The currently displayed picture
* when loading the page at the beginning * Default dummy data are pre-loaded to avoid javascript error
* @type PictureWithIdentifications * when loading the page at the beginning
**/ * @type PictureWithIdentifications
currentPicture: { **/
// biome-ignore lint/style/useNamingConvention: api is in snake_case currentPicture: {
is_moderated: true, // biome-ignore lint/style/useNamingConvention: api is in snake_case
id: null as number, is_moderated: true,
name: "", id: null as number,
// biome-ignore lint/style/useNamingConvention: api is in snake_case name: "",
display_name: "", // biome-ignore lint/style/useNamingConvention: api is in snake_case
// biome-ignore lint/style/useNamingConvention: api is in snake_case display_name: "",
compressed_url: "", // biome-ignore lint/style/useNamingConvention: api is in snake_case
// biome-ignore lint/style/useNamingConvention: api is in snake_case compressed_url: "",
profile_url: "", // biome-ignore lint/style/useNamingConvention: api is in snake_case
// biome-ignore lint/style/useNamingConvention: api is in snake_case profile_url: "",
full_size_url: "", // biome-ignore lint/style/useNamingConvention: api is in snake_case
owner: "", full_size_url: "",
date: new Date(), owner: "",
identifications: [] as IdentifiedUserSchema[], // biome-ignore lint/style/useNamingConvention: api is in snake_case
}, created_at: new Date(),
/** identifications: [] as IdentifiedUserSchema[],
* The picture which will be displayed next if the user press the "next" button },
**/ /**
nextPicture: null as PictureWithIdentifications, * 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 "previous" button nextPicture: null as PictureWithIdentifications,
**/ /**
previousPicture: null as PictureWithIdentifications, * The picture which will be displayed next if the user press the "previous" button
/** **/
* The select2 component used to identify users previousPicture: null as PictureWithIdentifications,
**/ /**
selector: undefined as UserAjaxSelect, * The select2 component used to identify users
/** **/
* Error message when a moderation operation fails selector: undefined as UserAjaxSelect,
**/ /**
moderationError: "", * Error message when a moderation operation fails
/** **/
* Method of pushing new url to the browser history moderationError: "",
* Used by popstate event and always reset to it's default value when used /**
**/ * Method of pushing new url to the browser history
pushstate: History.Push, * Used by popstate event and always reset to it's default value when used
**/
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(
this.currentPicture = this.pictures.find( (i: PictureSchema) => i.id === config.firstPictureId,
(i: PictureSchema) => i.id === config.firstPictureId, );
); this.$watch(
this.$watch( "currentPicture",
"currentPicture", (current: PictureSchema, previous: PictureSchema) => {
(current: PictureSchema, previous: PictureSchema) => { if (current === previous) {
if (current === previous) { /* Avoid recursive updates */
/* Avoid recursive updates */ return;
}
this.updatePicture();
},
);
window.addEventListener("popstate", async (event) => {
if (!event.state || event.state.sasPictureId === undefined) {
return; return;
} }
this.updatePicture(); this.pushstate = History.Replace;
}, this.currentPicture = this.pictures.find(
); (i: PictureSchema) =>
window.addEventListener("popstate", async (event) => { i.id === Number.parseInt(event.state.sasPictureId, 10),
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.pushstate = History.Replace; this.currentPicture.is_moderated = true;
this.currentPicture = this.pictures.find( this.currentPicture.asked_for_removal = false;
(i: PictureSchema) => i.id === Number.parseInt(event.state.sasPictureId, 10), },
);
});
this.pushstate = History.Replace; /* Avoid first url push */
await this.updatePicture();
},
/** async deletePicture() {
* Update the page. const res = await picturesDeletePicture({
* 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
picture_id: this.currentPicture.id, path: { picture_id: this.currentPicture.id },
}, });
body: widget.items.map((i: string) => Number.parseInt(i, 10)), if (res.error) {
}); this.moderationError = `${gettext("Couldn't delete picture")} : ${(res.error as { detail: string }).detail}`;
// refresh the identified users list return;
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 /**
widget.clear(false); * Send the identification request and update the list of identified users.
widget.clearOptions(); */
widget.setTextboxValue(""); async submitIdentification(): Promise<void> {
}, 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
* Check if an identification can be removed by the currently logged user widget.clear(false);
*/ widget.clearOptions();
canBeRemoved(identification: IdentifiedUserSchema): boolean { widget.setTextboxValue("");
return config.userCanModerate || identification.user.id === config.userId; },
},
/** /**
* Untag a user from the current picture * Check if an identification can be removed by the currently logged user
*/ */
async removeIdentification(identification: IdentifiedUserSchema): Promise<void> { canBeRemoved(identification: IdentifiedUserSchema): boolean {
const res = await usersidentifiedDeleteRelation({ return config.userIsSasAdmin || identification.user.id === config.userId;
// biome-ignore lint/style/useNamingConvention: api is in snake_case },
path: { relation_id: identification.id },
}); /**
if (!res.error && Array.isArray(this.currentPicture.identifications)) { * Untag a user from the current picture
this.currentPicture.identifications = */
this.currentPicture.identifications.filter( async removeIdentification(identification: IdentifiedUserSchema): Promise<void> {
(i: IdentifiedUserSchema) => i.id !== identification.id, const res = await usersidentifiedDeleteRelation({
); // 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

@@ -20,7 +20,7 @@
{% block content %} {% block content %}
<code> <code>
<a href="{{ url('sas:main') }}">SAS</a> / {{ print_path(album.parent) }} {{ album.get_display_name() }} <a href="{{ url('sas:main') }}">SAS</a> / {{ print_path(album.parent) }} {{ album.name }}
</code> </code>
{% set is_sas_admin = user.can_edit(album) %} {% set is_sas_admin = user.can_edit(album) %}
@@ -30,7 +30,7 @@
<form action="" method="post" enctype="multipart/form-data"> <form action="" method="post" enctype="multipart/form-data">
{% csrf_token %} {% csrf_token %}
<div class="album-navbar"> <div class="album-navbar">
<h3>{{ album.get_display_name() }}</h3> <h3>{{ album.name }}</h3>
<div class="toolbar"> <div class="toolbar">
<a href="{{ url('sas:album_edit', album_id=album.id) }}">{% trans %}Edit{% endtrans %}</a> <a href="{{ url('sas:album_edit', album_id=album.id) }}">{% trans %}Edit{% endtrans %}</a>
@@ -40,17 +40,17 @@
</div> </div>
</div> </div>
{% if clipboard %} {# {% if clipboard %}#}
<div class="clipboard"> {# <div class="clipboard">#}
{% trans %}Clipboard: {% endtrans %} {# {% trans %}Clipboard: {% endtrans %}#}
<ul> {# <ul>#}
{% for f in clipboard %} {# {% for f in clipboard["albums"] %}#}
<li>{{ f.get_full_path() }}</li> {# <li>{{ f.get_full_path() }}</li>#}
{% endfor %} {# {% endfor %}#}
</ul> {# </ul>#}
<input name="clear" type="submit" value="{% trans %}Clear clipboard{% endtrans %}"> {# <input name="clear" type="submit" value="{% trans %}Clear clipboard{% endtrans %}">#}
</div> {# </div>#}
{% endif %} {# {% endif %}#}
{% endif %} {% endif %}
{% if show_albums %} {% if show_albums %}
@@ -73,8 +73,8 @@
<div class="text">{% trans %}To be moderated{% endtrans %}</div> <div class="text">{% trans %}To be moderated{% endtrans %}</div>
</template> </template>
</div> </div>
{% if is_sas_admin %} {% if edit_mode %}
<input type="checkbox" name="file_list" :value="album.id"> <input type="checkbox" name="album_list" :value="album.id">
{% endif %} {% endif %}
</a> </a>
</template> </template>
@@ -100,7 +100,7 @@
</template> </template>
</div> </div>
{% if is_sas_admin %} {% if is_sas_admin %}
<input type="checkbox" name="file_list" :value="picture.id"> <input type="checkbox" name="picture_list" :value="picture.id">
{% endif %} {% endif %}
</a> </a>
</template> </template>
@@ -120,9 +120,9 @@
{% csrf_token %} {% csrf_token %}
<div class="inputs"> <div class="inputs">
<p> <p>
<label for="{{ upload_form.images.id_for_label }}">{{ upload_form.images.label }} :</label> <label for="{{ form.images.id_for_label }}">{{ form.images.label }} :</label>
{{ upload_form.images|add_attr("x-ref=pictures") }} {{ form.images|add_attr("x-ref=pictures") }}
<span class="helptext">{{ upload_form.images.help_text }}</span> <span class="helptext">{{ form.images.help_text }}</span>
</p> </p>
<input type="submit" value="{% trans %}Upload{% endtrans %}" /> <input type="submit" value="{% trans %}Upload{% endtrans %}" />
<progress x-ref="progress" x-show="sending"></progress> <progress x-ref="progress" x-show="sending"></progress>

View File

@@ -1,19 +1,13 @@
{% macro display_album(a, edit_mode) %} {% macro display_album(a, edit_mode) %}
<a href="{{ url('sas:album', album_id=a.id) }}"> <a href="{{ url('sas:album', album_id=a.id) }}">
{% if a.file %} {% if a.thumbnail %}
{% set img = a.get_download_url() %} {% set img = a.get_download_url() %}
{% set src = a.name %} {% set src = a.name %}
{% elif a.children.filter(is_folder=False, is_moderated=True).exists() %}
{% set picture = a.children.filter(is_folder=False).first().as_picture %}
{% set img = picture.get_download_thumb_url() %}
{% set src = picture.name %}
{% else %} {% else %}
{% set img = static('core/img/sas.jpg') %} {% set img = static('core/img/sas.jpg') %}
{% set src = "sas.jpg" %} {% set src = "sas.jpg" %}
{% endif %} {% endif %}
<div <div class="album{% if not a.is_moderated %} not_moderated{% endif %}">
class="album{% if not a.is_moderated %} not_moderated{% endif %}"
>
<img src="{{ img }}" alt="{{ src }}" loading="lazy" /> <img src="{{ img }}" alt="{{ src }}" loading="lazy" />
{% if not a.is_moderated %} {% if not a.is_moderated %}
<div class="overlay">&nbsp;</div> <div class="overlay">&nbsp;</div>
@@ -31,7 +25,7 @@
{% macro print_path(file) %} {% macro print_path(file) %}
{% if file and file.parent %} {% if file and file.parent %}
{{ print_path(file.parent) }} {{ print_path(file.parent) }}
<a href="{{ url('sas:album', album_id=file.id) }}">{{ file.get_display_name() }}</a> / <a href="{{ url("sas:album", album_id=file.id) }}">{{ file.name }}</a> /
{% endif %} {% endif %}
{% endmacro %} {% endmacro %}

View File

@@ -1,9 +1,9 @@
{% extends "core/base.jinja" %} {% extends "core/base.jinja" %}
{%- block additional_css -%} {%- block additional_css -%}
<link defer rel="stylesheet" href="{{ static('bundled/core/components/ajax-select-index.css') }}"> <link rel="stylesheet" href="{{ static('bundled/core/components/ajax-select-index.css') }}">
<link defer rel="stylesheet" href="{{ static('core/components/ajax-select.scss') }}"> <link rel="stylesheet" href="{{ static('core/components/ajax-select.scss') }}">
<link defer rel="stylesheet" href="{{ static('sas/css/picture.scss') }}"> <link rel="stylesheet" href="{{ static('sas/css/picture.scss') }}">
{%- endblock -%} {%- endblock -%}
{%- block additional_js -%} {%- block additional_js -%}
@@ -17,8 +17,10 @@
{% 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(config)"> <main x-data="picture_viewer">
<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>
@@ -48,13 +50,15 @@
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.has_perm("sas.moderate_sasfile") %} {% if user_is_sas_admin %}
<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 x-text="`${req.author.first_name} ${req.author.last_name}`"></h6> <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'}
@@ -66,7 +70,7 @@
</template> </template>
{% endif %} {% endif %}
</div> </div>
{% if user.has_perm("sas.moderate_sasfile") %} {% if user_is_sas_admin %}
<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 %}
@@ -100,7 +104,7 @@
<span <span
x-text="Intl.DateTimeFormat( x-text="Intl.DateTimeFormat(
'{{ LANGUAGE_CODE }}', {dateStyle: 'long'} '{{ LANGUAGE_CODE }}', {dateStyle: 'long'}
).format(new Date(currentPicture.date))" ).format(new Date(currentPicture.created_at))"
> >
</span> </span>
</div> </div>
@@ -200,13 +204,16 @@
{% endblock %} {% endblock %}
{% block script %} {% block script %}
{{ super() }}
<script> <script>
const config = { window.addEventListener("DOMContentLoaded", () => {
albumId: {{ album.id }}, loadViewer({
albumUrl: "{{ album.get_absolute_url() }}", albumId: {{ album.id }} ,
firstPictureId: {{ picture.id }}, {# id of the first picture to show after page load #} albumUrl: "{{ album.get_absolute_url() }}",
userId: {{ user.id }}, firstPictureId: {{ picture.id }}, {# id of the first picture to show after page load #}
userCanModerate: {{ user.has_perm("sas.moderate_sasfile")|tojson }} userId: {{ user.id }},
} userIsSasAdmin: {{ user_is_sas_admin|tojson }}
});
})
</script> </script>
{% endblock %} {% endblock %}

View File

@@ -27,8 +27,8 @@ class TestSas(TestCase):
cls.user_b, cls.user_c = subscriber_user.make(_quantity=2) cls.user_b, cls.user_c = subscriber_user.make(_quantity=2)
picture = picture_recipe.extend(owner=owner) picture = picture_recipe.extend(owner=owner)
cls.album_a = baker.make(Album, is_in_sas=True, parent=sas) cls.album_a = baker.make(Album)
cls.album_b = baker.make(Album, is_in_sas=True, parent=sas) cls.album_b = baker.make(Album)
relation_recipe = Recipe(PeoplePictureRelation) relation_recipe = Recipe(PeoplePictureRelation)
relations = [] relations = []
for album in cls.album_a, cls.album_b: for album in cls.album_a, cls.album_b:
@@ -61,7 +61,7 @@ class TestPictureSearch(TestSas):
self.client.force_login(self.user_b) self.client.force_login(self.user_b)
res = self.client.get(self.url + f"?album_id={self.album_a.id}") res = self.client.get(self.url + f"?album_id={self.album_a.id}")
assert res.status_code == 200 assert res.status_code == 200
expected = list(self.album_a.children_pictures.values_list("id", flat=True)) expected = list(self.album_a.pictures.values_list("id", flat=True))
assert [i["id"] for i in res.json()["results"]] == expected assert [i["id"] for i in res.json()["results"]] == expected
def test_filter_by_user(self): def test_filter_by_user(self):
@@ -70,7 +70,7 @@ class TestPictureSearch(TestSas):
assert res.status_code == 200 assert res.status_code == 200
expected = list( expected = list(
self.user_a.pictures.order_by( self.user_a.pictures.order_by(
"-picture__parent__date", "picture__date" "-picture__parent__event_date", "picture__created_at"
).values_list("picture_id", flat=True) ).values_list("picture_id", flat=True)
) )
assert [i["id"] for i in res.json()["results"]] == expected assert [i["id"] for i in res.json()["results"]] == expected
@@ -84,7 +84,7 @@ class TestPictureSearch(TestSas):
assert res.status_code == 200 assert res.status_code == 200
expected = list( expected = list(
self.user_a.pictures.union(self.user_b.pictures.all()) self.user_a.pictures.union(self.user_b.pictures.all())
.order_by("-picture__parent__date", "picture__date") .order_by("-picture__parent__event_date", "picture__created_at")
.values_list("picture_id", flat=True) .values_list("picture_id", flat=True)
) )
assert [i["id"] for i in res.json()["results"]] == expected assert [i["id"] for i in res.json()["results"]] == expected
@@ -97,7 +97,7 @@ class TestPictureSearch(TestSas):
assert res.status_code == 200 assert res.status_code == 200
expected = list( expected = list(
self.user_a.pictures.order_by( self.user_a.pictures.order_by(
"-picture__parent__date", "picture__date" "-picture__parent__event_date", "picture__created_at"
).values_list("picture_id", flat=True) ).values_list("picture_id", flat=True)
) )
assert [i["id"] for i in res.json()["results"]] == expected assert [i["id"] for i in res.json()["results"]] == expected
@@ -123,7 +123,7 @@ class TestPictureSearch(TestSas):
assert res.status_code == 200 assert res.status_code == 200
expected = list( expected = list(
self.user_b.pictures.intersection(self.user_a.pictures.all()) self.user_b.pictures.intersection(self.user_a.pictures.all())
.order_by("-picture__parent__date", "picture__date") .order_by("-picture__parent__event_date", "picture__created_at")
.values_list("picture_id", flat=True) .values_list("picture_id", flat=True)
) )
assert [i["id"] for i in res.json()["results"]] == expected assert [i["id"] for i in res.json()["results"]] == expected

View File

@@ -4,8 +4,8 @@ from model_bakery import baker
from core.baker_recipes import old_subscriber_user, subscriber_user from core.baker_recipes import old_subscriber_user, subscriber_user
from core.models import User from core.models import User
from sas.baker_recipes import picture_recipe from sas.baker_recipes import album_recipe, picture_recipe
from sas.models import PeoplePictureRelation, Picture from sas.models import Album, PeoplePictureRelation, Picture
class TestPictureQuerySet(TestCase): class TestPictureQuerySet(TestCase):
@@ -67,3 +67,22 @@ def test_identifications_viewable_by_user():
assert list(picture.people.viewable_by(identifications[1].user)) == [ assert list(picture.people.viewable_by(identifications[1].user)) == [
identifications[1] identifications[1]
] ]
class TestDeleteAlbum(TestCase):
def setUp(cls):
cls.album: Album = album_recipe.make()
cls.album_pictures = picture_recipe.make(parent=cls.album, _quantity=5)
cls.sub_album = album_recipe.make(parent=cls.album)
cls.sub_album_pictures = picture_recipe.make(parent=cls.sub_album, _quantity=5)
def test_delete(self):
album_ids = [self.album.id, self.sub_album.id]
picture_ids = [
*[p.id for p in self.album_pictures],
*[p.id for p in self.sub_album_pictures],
]
self.album.delete()
# assert not p.exists()
assert not Album.objects.filter(id__in=album_ids).exists()
assert not Picture.objects.filter(id__in=picture_ids).exists()

View File

@@ -136,9 +136,7 @@ class TestAlbumUpload:
class TestSasModeration(TestCase): class TestSasModeration(TestCase):
@classmethod @classmethod
def setUpTestData(cls): def setUpTestData(cls):
album = baker.make( album = baker.make(Album)
Album, parent_id=settings.SITH_SAS_ROOT_DIR_ID, is_moderated=True
)
cls.pictures = picture_recipe.make( cls.pictures = picture_recipe.make(
parent=album, _quantity=10, _bulk_create=True parent=album, _quantity=10, _bulk_create=True
) )
@@ -161,22 +159,16 @@ 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

@@ -12,22 +12,23 @@
# OR WITHIN THE LOCAL FILE "LICENSE" # OR WITHIN THE LOCAL FILE "LICENSE"
# #
# #
from pathlib import Path
from typing import Any from typing import Any
from django.conf import settings from django.conf import settings
from django.contrib.auth.mixins import PermissionRequiredMixin from django.core.exceptions import PermissionDenied
from django.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, redirect from django.shortcuts import get_object_or_404
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
from django.views.generic.edit import FormView, UpdateView from django.views.generic.edit import FormMixin, FormView, UpdateView
from core.auth.mixins import CanEditMixin, CanViewMixin from core.auth.mixins import CanEditMixin, CanViewMixin
from core.models import SithFile, User from core.models import SithFile, User
from core.views import UseFragmentsMixin from core.views import FileView, UseFragmentsMixin
from core.views.files import FileView, send_file from core.views.files import send_raw_file
from core.views.mixins import FragmentMixin, FragmentRenderer from core.views.mixins import FragmentMixin, FragmentRenderer
from core.views.user import UserTabsMixin from core.views.user import UserTabsMixin
from sas.forms import ( from sas.forms import (
@@ -63,6 +64,7 @@ class AlbumCreateFragment(FragmentMixin, CreateView):
class SASMainView(UseFragmentsMixin, TemplateView): class SASMainView(UseFragmentsMixin, TemplateView):
form_class = AlbumCreateForm
template_name = "sas/main.jinja" template_name = "sas/main.jinja"
def get_fragments(self) -> dict[str, FragmentRenderer]: def get_fragments(self) -> dict[str, FragmentRenderer]:
@@ -79,12 +81,26 @@ class SASMainView(UseFragmentsMixin, TemplateView):
root_user = User.objects.get(pk=settings.SITH_ROOT_USER_ID) root_user = User.objects.get(pk=settings.SITH_ROOT_USER_ID)
return {"album_create_fragment": {"owner": root_user}} return {"album_create_fragment": {"owner": root_user}}
def dispatch(self, request, *args, **kwargs):
if request.method == "POST" and not self.request.user.has_perm("sas.add_album"):
raise PermissionDenied
return super().dispatch(request, *args, **kwargs)
def get_form(self, form_class=None):
if not self.request.user.has_perm("sas.add_album"):
return None
return super().get_form(form_class)
def get_form_kwargs(self):
return super().get_form_kwargs() | {
"owner": User.objects.get(pk=settings.SITH_ROOT_USER_ID),
"parent": None,
}
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
kwargs = super().get_context_data(**kwargs) kwargs = super().get_context_data(**kwargs)
albums_qs = Album.objects.viewable_by(self.request.user) albums_qs = Album.objects.viewable_by(self.request.user)
kwargs["categories"] = list( kwargs["categories"] = list(albums_qs.filter(parent=None).order_by("id"))
albums_qs.filter(parent_id=settings.SITH_SAS_ROOT_DIR_ID).order_by("id")
)
kwargs["latest"] = list(albums_qs.order_by("-id")[:5]) kwargs["latest"] = list(albums_qs.order_by("-id")[:5])
return kwargs return kwargs
@@ -94,6 +110,9 @@ class PictureView(CanViewMixin, DetailView):
pk_url_kwarg = "picture_id" pk_url_kwarg = "picture_id"
template_name = "sas/picture.jinja" template_name = "sas/picture.jinja"
def get_queryset(self):
return super().get_queryset().select_related("parent")
def get(self, request, *args, **kwargs): def get(self, request, *args, **kwargs):
self.object = self.get_object() self.object = self.get_object()
if "rotate_right" in request.GET: if "rotate_right" in request.GET:
@@ -103,31 +122,42 @@ class PictureView(CanViewMixin, DetailView):
return super().get(request, *args, **kwargs) return super().get(request, *args, **kwargs)
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
return super().get_context_data(**kwargs) | { return super().get_context_data(**kwargs) | {"album": self.object.parent}
"album": Album.objects.get(children=self.object)
}
def send_album(request, album_id): def send_album(request, album_id):
return send_file(request, album_id, Album) album = get_object_or_404(Album, id=album_id)
if not album.can_be_viewed_by(request.user):
raise PermissionDenied
return send_raw_file(Path(album.thumbnail.path))
def send_pict(request, picture_id): def send_pict(request, picture_id):
return send_file(request, picture_id, Picture) picture = get_object_or_404(Picture, id=picture_id)
if not picture.can_be_viewed_by(request.user):
raise PermissionDenied
return send_raw_file(Path(picture.original.path))
def send_compressed(request, picture_id): def send_compressed(request, picture_id):
return send_file(request, picture_id, Picture, "compressed") picture = get_object_or_404(Picture, id=picture_id)
if not picture.can_be_viewed_by(request.user):
raise PermissionDenied
return send_raw_file(Path(picture.compressed.path))
def send_thumb(request, picture_id): def send_thumb(request, picture_id):
return send_file(request, picture_id, Picture, "thumbnail") picture = get_object_or_404(Picture, id=picture_id)
if not picture.can_be_viewed_by(request.user):
raise PermissionDenied
return send_raw_file(Path(picture.thumbnail.path))
class AlbumView(CanViewMixin, UseFragmentsMixin, DetailView): class AlbumView(CanViewMixin, UseFragmentsMixin, FormMixin, DetailView):
model = Album model = Album
pk_url_kwarg = "album_id" pk_url_kwarg = "album_id"
template_name = "sas/album.jinja" template_name = "sas/album.jinja"
form_class = PictureUploadForm
def get_fragments(self) -> dict[str, FragmentRenderer]: def get_fragments(self) -> dict[str, FragmentRenderer]:
return { return {
@@ -142,27 +172,32 @@ class AlbumView(CanViewMixin, UseFragmentsMixin, DetailView):
except ValueError as e: except ValueError as e:
raise Http404 from e raise Http404 from e
if "clipboard" not in request.session: if "clipboard" not in request.session:
request.session["clipboard"] = [] request.session["clipboard"] = {"albums": [], "pictures": []}
return super().dispatch(request, *args, **kwargs) return super().dispatch(request, *args, **kwargs)
def get_form(self, *args, **kwargs):
if not self.request.user.can_edit(self.object):
return None
return super().get_form(*args, **kwargs)
def post(self, request, *args, **kwargs): def post(self, request, *args, **kwargs):
self.object = self.get_object() self.object = self.get_object()
if not self.object.file: form = self.get_form()
self.object.generate_thumbnail() if not form:
if request.user.can_edit(self.object): # Handle the copy-paste functions # the form is reserved for users that can edit this album.
FileView.handle_clipboard(request, self.object) # If there is no form, it means the user has no right to do a POST
return HttpResponseRedirect(self.request.path) raise PermissionDenied
FileView.handle_clipboard(self.request, self.object)
if not form.is_valid():
return self.form_invalid(form)
return self.form_valid(form)
def get_fragment_data(self) -> dict[str, dict[str, Any]]: def get_fragment_data(self) -> dict[str, dict[str, Any]]:
return {"album_create_fragment": {"owner": self.request.user}} return {"album_create_fragment": {"owner": self.request.user}}
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
kwargs = super().get_context_data(**kwargs) kwargs = super().get_context_data(**kwargs)
if ids := self.request.session.get("clipboard", None): kwargs["clipboard"] = {}
kwargs["clipboard"] = SithFile.objects.filter(id__in=ids)
kwargs["upload_form"] = PictureUploadForm()
# if True, the albums will be fetched with a request to the API
# if False, the section won't be displayed at all
kwargs["show_albums"] = ( kwargs["show_albums"] = (
Album.objects.viewable_by(self.request.user) Album.objects.viewable_by(self.request.user)
.filter(parent_id=self.object.id) .filter(parent_id=self.object.id)
@@ -191,26 +226,31 @@ class UserPicturesView(UserTabsMixin, CanViewMixin, DetailView):
# Admin views # Admin views
class ModerationView(PermissionRequiredMixin, TemplateView): class ModerationView(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
album = get_object_or_404(Album, pk=request.POST["album_id"]) if request.user.is_in_group(pk=settings.SITH_GROUP_SAS_ADMIN_ID):
if "moderate" in request.POST: album = get_object_or_404(Album, pk=request.POST["album_id"])
album.moderator = request.user if "moderate" in request.POST:
album.is_moderated = True album.moderator = request.user
album.save() album.is_moderated = True
elif "delete" in request.POST: album.save()
album.delete() elif "delete" in request.POST:
return redirect(self.request.path) album.delete()
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)
kwargs["albums_to_moderate"] = Album.objects.filter( kwargs["albums_to_moderate"] = Album.objects.filter(
is_moderated=False, is_in_sas=True, is_folder=True is_moderated=False
).order_by("id") ).order_by("id")
pictures = Picture.objects.filter(is_moderated=False).select_related("parent") pictures = Picture.objects.filter(is_moderated=False).select_related("parent")
kwargs["pictures"] = pictures kwargs["pictures"] = pictures

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": 16, "price": 12,
"duration": 1, "duration": 1,
}, },
"deux-semestres-reduction": { "deux-semestres-reduction": {
"name": _("Two semesters (-20%)"), "name": _("Two semesters (-20%)"),
"price": 28, "price": 22,
"duration": 2, "duration": 2,
}, },
"cursus-tronc-commun-reduction": { "cursus-tronc-commun-reduction": {
"name": _("Common core cursus (-20%)"), "name": _("Common core cursus (-20%)"),
"price": 48, "price": 36,
"duration": 4, "duration": 4,
}, },
"cursus-branche-reduction": { "cursus-branche-reduction": {
"name": _("Branch cursus (-20%)"), "name": _("Branch cursus (-20%)"),
"price": 48, "price": 36,
"duration": 6, "duration": 6,
}, },
"cursus-alternant-reduction": { "cursus-alternant-reduction": {
"name": _("Alternating cursus (-20%)"), "name": _("Alternating cursus (-20%)"),
"price": 28, "price": 24,
"duration": 6, "duration": 6,
}, },
# CA special offer # CA special offer

View File

@@ -182,13 +182,12 @@ class OpenApi:
path[action]["operationId"] = "_".join( path[action]["operationId"] = "_".join(
desc["operationId"].split("_")[:-1] desc["operationId"].split("_")[:-1]
) )
schema = str(schema) schema = str(schema)
if old_hash == sha1(schema.encode("utf-8")).hexdigest(): if old_hash == sha1(schema.encode("utf-8")).hexdigest():
logging.getLogger("django").info("✨ Api did not change, nothing to do ✨") logging.getLogger("django").info("✨ Api did not change, nothing to do ✨")
return return
with open(out, "w") as f: out.write_text(schema)
_ = f.write(schema)
return subprocess.Popen(["npm", "run", "openapi"]) return subprocess.Popen(["npm", "run", "openapi"])