mirror of
https://github.com/ae-utbm/sith.git
synced 2026-03-13 15:15:03 +00:00
Compare commits
26 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
59d7fadf4f | ||
|
|
e188acc78b | ||
|
|
654ba383b4 | ||
|
|
2ee0c091be | ||
|
|
0f1660ad79 | ||
|
|
680dc44486 | ||
|
|
5f7a9fc600 | ||
|
|
5126dc2a82 | ||
|
|
7322a0c1cb | ||
|
|
c2dfbc8bec | ||
|
|
a019707d4a | ||
|
|
85f1a0b9cb | ||
|
|
0a3f8b8e6f | ||
|
|
1845a7cbcf | ||
|
|
f17f17d8de | ||
|
|
7bb3d064ee | ||
|
|
296feb6e32 | ||
|
|
30663d87a4 | ||
|
|
b5ff9b4c13 | ||
|
|
e2f6671ad0 | ||
|
|
9a67926a49 | ||
|
|
09a98db786 | ||
|
|
84ed180c1e | ||
|
|
52759764a1 | ||
|
|
be1563f46f | ||
|
|
5d3d44ec67 |
29
club/api.py
29
club/api.py
@@ -6,9 +6,15 @@ from ninja_extra.pagination import PageNumberPaginationExtra
|
|||||||
from ninja_extra.schemas import PaginatedResponseSchema
|
from ninja_extra.schemas import PaginatedResponseSchema
|
||||||
|
|
||||||
from api.auth import ApiKeyAuth
|
from api.auth import ApiKeyAuth
|
||||||
from api.permissions import CanAccessLookup, HasPerm
|
from api.permissions import CanAccessLookup, CanView, HasPerm
|
||||||
from club.models import Club, Membership
|
from club.models import Club, Membership
|
||||||
from club.schemas import ClubSchema, ClubSearchFilterSchema, SimpleClubSchema
|
from club.schemas import (
|
||||||
|
ClubSchema,
|
||||||
|
ClubSearchFilterSchema,
|
||||||
|
SimpleClubSchema,
|
||||||
|
UserMembershipSchema,
|
||||||
|
)
|
||||||
|
from core.models import User
|
||||||
|
|
||||||
|
|
||||||
@api_controller("/club")
|
@api_controller("/club")
|
||||||
@@ -38,3 +44,22 @@ class ClubController(ControllerBase):
|
|||||||
return self.get_object_or_exception(
|
return self.get_object_or_exception(
|
||||||
Club.objects.prefetch_related(prefetch), id=club_id
|
Club.objects.prefetch_related(prefetch), id=club_id
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@api_controller("/user/{int:user_id}/club")
|
||||||
|
class UserClubController(ControllerBase):
|
||||||
|
@route.get(
|
||||||
|
"",
|
||||||
|
response=list[UserMembershipSchema],
|
||||||
|
auth=[ApiKeyAuth(), SessionAuth()],
|
||||||
|
permissions=[CanView],
|
||||||
|
url_name="fetch_user_clubs",
|
||||||
|
)
|
||||||
|
def fetch_user_clubs(self, user_id: int):
|
||||||
|
"""Get all the active memberships of the given user."""
|
||||||
|
user = self.get_object_or_exception(User, id=user_id)
|
||||||
|
return (
|
||||||
|
Membership.objects.ongoing()
|
||||||
|
.filter(user=user)
|
||||||
|
.select_related("club", "user")
|
||||||
|
)
|
||||||
|
|||||||
@@ -40,6 +40,8 @@ class ClubProfileSchema(ModelSchema):
|
|||||||
|
|
||||||
|
|
||||||
class ClubMemberSchema(ModelSchema):
|
class ClubMemberSchema(ModelSchema):
|
||||||
|
"""A schema to represent all memberships in a club."""
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Membership
|
model = Membership
|
||||||
fields = ["start_date", "end_date", "role", "description"]
|
fields = ["start_date", "end_date", "role", "description"]
|
||||||
@@ -53,3 +55,13 @@ class ClubSchema(ModelSchema):
|
|||||||
fields = ["id", "name", "logo", "is_active", "short_description", "address"]
|
fields = ["id", "name", "logo", "is_active", "short_description", "address"]
|
||||||
|
|
||||||
members: list[ClubMemberSchema]
|
members: list[ClubMemberSchema]
|
||||||
|
|
||||||
|
|
||||||
|
class UserMembershipSchema(ModelSchema):
|
||||||
|
"""A schema to represent the active club memberships of a user."""
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = Membership
|
||||||
|
fields = ["id", "start_date", "role", "description"]
|
||||||
|
|
||||||
|
club: SimpleClubSchema
|
||||||
|
|||||||
50
club/tests/test_user_club_controller.py
Normal file
50
club/tests/test_user_club_controller.py
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
from django.test import TestCase
|
||||||
|
from django.urls import reverse
|
||||||
|
from django.utils.timezone import localdate
|
||||||
|
from model_bakery import baker
|
||||||
|
from model_bakery.recipe import Recipe
|
||||||
|
|
||||||
|
from club.models import Club, Membership
|
||||||
|
from club.schemas import UserMembershipSchema
|
||||||
|
from core.baker_recipes import subscriber_user
|
||||||
|
from core.models import Page
|
||||||
|
|
||||||
|
|
||||||
|
class TestFetchClub(TestCase):
|
||||||
|
@classmethod
|
||||||
|
def setUpTestData(cls):
|
||||||
|
cls.user = subscriber_user.make()
|
||||||
|
pages = baker.make(Page, _quantity=3, _bulk_create=True)
|
||||||
|
clubs = baker.make(Club, page=iter(pages), _quantity=3, _bulk_create=True)
|
||||||
|
recipe = Recipe(
|
||||||
|
Membership, user=cls.user, start_date=localdate() - timedelta(days=2)
|
||||||
|
)
|
||||||
|
cls.members = Membership.objects.bulk_create(
|
||||||
|
[
|
||||||
|
recipe.prepare(club=clubs[0]),
|
||||||
|
recipe.prepare(club=clubs[1], end_date=localdate() - timedelta(days=1)),
|
||||||
|
recipe.prepare(club=clubs[1]),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_fetch_memberships(self):
|
||||||
|
self.client.force_login(subscriber_user.make())
|
||||||
|
res = self.client.get(
|
||||||
|
reverse("api:fetch_user_clubs", kwargs={"user_id": self.user.id})
|
||||||
|
)
|
||||||
|
assert res.status_code == 200
|
||||||
|
assert [UserMembershipSchema.model_validate(m) for m in res.json()] == [
|
||||||
|
UserMembershipSchema.from_orm(m) for m in (self.members[0], self.members[2])
|
||||||
|
]
|
||||||
|
|
||||||
|
def test_fetch_club_nb_queries(self):
|
||||||
|
self.client.force_login(subscriber_user.make())
|
||||||
|
with self.assertNumQueries(6):
|
||||||
|
# - 5 queries for authentication
|
||||||
|
# - 1 query for the actual data
|
||||||
|
res = self.client.get(
|
||||||
|
reverse("api:fetch_user_clubs", kwargs={"user_id": self.user.id})
|
||||||
|
)
|
||||||
|
assert res.status_code == 200
|
||||||
@@ -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")
|
list_display = ("name", "owner", "size", "date", "is_in_sas")
|
||||||
autocomplete_fields = ("parent", "owner", "moderator")
|
autocomplete_fields = ("parent", "owner", "moderator")
|
||||||
search_fields = ("name",)
|
search_fields = ("name", "parent__name")
|
||||||
|
|
||||||
|
|
||||||
@admin.register(OperationLog)
|
@admin.register(OperationLog)
|
||||||
|
|||||||
@@ -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(name__icontains=search)
|
return SithFile.objects.filter(is_in_sas=False).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).values()
|
return Group.objects.filter(name__icontains=search).order_by("name").values()
|
||||||
|
|
||||||
|
|
||||||
DepthValue = Annotated[int, Ge(0), Le(10)]
|
DepthValue = Annotated[int, Ge(0), Le(10)]
|
||||||
|
|||||||
@@ -307,6 +307,7 @@ class PermissionOrClubBoardRequiredMixin(PermissionRequiredMixin):
|
|||||||
return False
|
return False
|
||||||
if super().has_permission():
|
if super().has_permission():
|
||||||
return True
|
return True
|
||||||
return self.club is not None and any(
|
return (
|
||||||
g.id == self.club.board_group_id for g in self.request.user.cached_groups
|
self.club is not None
|
||||||
|
and self.club.board_group_id in self.request.user.all_groups
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -41,7 +41,14 @@ from com.ics_calendar import IcsCalendar
|
|||||||
from com.models import News, NewsDate, Sith, Weekmail
|
from com.models import News, NewsDate, Sith, Weekmail
|
||||||
from core.models import BanGroup, Group, Page, PageRev, SithFile, User
|
from core.models import BanGroup, Group, Page, PageRev, SithFile, User
|
||||||
from core.utils import resize_image
|
from core.utils import resize_image
|
||||||
from counter.models import Counter, Product, ProductType, ReturnableProduct, StudentCard
|
from counter.models import (
|
||||||
|
Counter,
|
||||||
|
Price,
|
||||||
|
Product,
|
||||||
|
ProductType,
|
||||||
|
ReturnableProduct,
|
||||||
|
StudentCard,
|
||||||
|
)
|
||||||
from election.models import Candidature, Election, ElectionList, Role
|
from election.models import Candidature, Election, ElectionList, Role
|
||||||
from forum.models import Forum
|
from forum.models import Forum
|
||||||
from pedagogy.models import UE
|
from pedagogy.models import UE
|
||||||
@@ -110,6 +117,7 @@ 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"
|
||||||
)
|
)
|
||||||
@@ -367,125 +375,15 @@ class Command(BaseCommand):
|
|||||||
end_date=localdate() - timedelta(days=100),
|
end_date=localdate() - timedelta(days=100),
|
||||||
)
|
)
|
||||||
|
|
||||||
p = ProductType.objects.create(name="Bières bouteilles")
|
self._create_products(groups, main_club, refound)
|
||||||
c = ProductType.objects.create(name="Cotisations")
|
|
||||||
r = ProductType.objects.create(name="Rechargements")
|
|
||||||
verre = ProductType.objects.create(name="Verre")
|
|
||||||
cotis = Product.objects.create(
|
|
||||||
name="Cotis 1 semestre",
|
|
||||||
code="1SCOTIZ",
|
|
||||||
product_type=c,
|
|
||||||
purchase_price="15",
|
|
||||||
selling_price="15",
|
|
||||||
special_selling_price="15",
|
|
||||||
club=main_club,
|
|
||||||
)
|
|
||||||
cotis2 = Product.objects.create(
|
|
||||||
name="Cotis 2 semestres",
|
|
||||||
code="2SCOTIZ",
|
|
||||||
product_type=c,
|
|
||||||
purchase_price="28",
|
|
||||||
selling_price="28",
|
|
||||||
special_selling_price="28",
|
|
||||||
club=main_club,
|
|
||||||
)
|
|
||||||
refill = Product.objects.create(
|
|
||||||
name="Rechargement 15 €",
|
|
||||||
code="15REFILL",
|
|
||||||
product_type=r,
|
|
||||||
purchase_price="15",
|
|
||||||
selling_price="15",
|
|
||||||
special_selling_price="15",
|
|
||||||
club=main_club,
|
|
||||||
)
|
|
||||||
barb = Product.objects.create(
|
|
||||||
name="Barbar",
|
|
||||||
code="BARB",
|
|
||||||
product_type=p,
|
|
||||||
purchase_price="1.50",
|
|
||||||
selling_price="1.7",
|
|
||||||
special_selling_price="1.6",
|
|
||||||
club=main_club,
|
|
||||||
limit_age=18,
|
|
||||||
)
|
|
||||||
cble = Product.objects.create(
|
|
||||||
name="Chimay Bleue",
|
|
||||||
code="CBLE",
|
|
||||||
product_type=p,
|
|
||||||
purchase_price="1.50",
|
|
||||||
selling_price="1.7",
|
|
||||||
special_selling_price="1.6",
|
|
||||||
club=main_club,
|
|
||||||
limit_age=18,
|
|
||||||
)
|
|
||||||
cons = Product.objects.create(
|
|
||||||
name="Consigne Eco-cup",
|
|
||||||
code="CONS",
|
|
||||||
product_type=verre,
|
|
||||||
purchase_price="1",
|
|
||||||
selling_price="1",
|
|
||||||
special_selling_price="1",
|
|
||||||
club=main_club,
|
|
||||||
)
|
|
||||||
dcons = Product.objects.create(
|
|
||||||
name="Déconsigne Eco-cup",
|
|
||||||
code="DECO",
|
|
||||||
product_type=verre,
|
|
||||||
purchase_price="-1",
|
|
||||||
selling_price="-1",
|
|
||||||
special_selling_price="-1",
|
|
||||||
club=main_club,
|
|
||||||
)
|
|
||||||
cors = Product.objects.create(
|
|
||||||
name="Corsendonk",
|
|
||||||
code="CORS",
|
|
||||||
product_type=p,
|
|
||||||
purchase_price="1.50",
|
|
||||||
selling_price="1.7",
|
|
||||||
special_selling_price="1.6",
|
|
||||||
club=main_club,
|
|
||||||
limit_age=18,
|
|
||||||
)
|
|
||||||
carolus = Product.objects.create(
|
|
||||||
name="Carolus",
|
|
||||||
code="CARO",
|
|
||||||
product_type=p,
|
|
||||||
purchase_price="1.50",
|
|
||||||
selling_price="1.7",
|
|
||||||
special_selling_price="1.6",
|
|
||||||
club=main_club,
|
|
||||||
limit_age=18,
|
|
||||||
)
|
|
||||||
Product.objects.create(
|
|
||||||
name="remboursement",
|
|
||||||
code="REMBOURS",
|
|
||||||
purchase_price="0",
|
|
||||||
selling_price="0",
|
|
||||||
special_selling_price="0",
|
|
||||||
club=refound,
|
|
||||||
)
|
|
||||||
groups.subscribers.products.add(
|
|
||||||
cotis, cotis2, refill, barb, cble, cors, carolus
|
|
||||||
)
|
|
||||||
groups.old_subscribers.products.add(cotis, cotis2)
|
|
||||||
|
|
||||||
mde = Counter.objects.get(name="MDE")
|
|
||||||
mde.products.add(barb, cble, cons, dcons)
|
|
||||||
|
|
||||||
eboutic = Counter.objects.get(name="Eboutic")
|
|
||||||
eboutic.products.add(barb, cotis, cotis2, refill)
|
|
||||||
|
|
||||||
Counter.objects.create(name="Carte AE", club=refound, type="OFFICE")
|
Counter.objects.create(name="Carte AE", club=refound, type="OFFICE")
|
||||||
|
|
||||||
ReturnableProduct.objects.create(
|
|
||||||
product=cons, returned_product=dcons, max_return=3
|
|
||||||
)
|
|
||||||
|
|
||||||
# Add barman to counter
|
# Add barman to counter
|
||||||
Counter.sellers.through.objects.bulk_create(
|
Counter.sellers.through.objects.bulk_create(
|
||||||
[
|
[
|
||||||
Counter.sellers.through(counter_id=2, user=krophil),
|
Counter.sellers.through(counter_id=1, user=skia), # MDE
|
||||||
Counter.sellers.through(counter=mde, user=skia),
|
Counter.sellers.through(counter_id=2, user=krophil), # Foyer
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -693,21 +591,33 @@ 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.objects.create(name=f.name, is_moderated=True)
|
album = Album(
|
||||||
|
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,
|
||||||
original=file,
|
file=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.original.name = pict.name
|
pict.file.name = p.name
|
||||||
pict.generate_thumbnails()
|
|
||||||
pict.full_clean()
|
pict.full_clean()
|
||||||
|
pict.generate_thumbnails()
|
||||||
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")
|
||||||
@@ -729,6 +639,131 @@ class Command(BaseCommand):
|
|||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def _create_products(
|
||||||
|
self, groups: PopulatedGroups, main_club: Club, refound_club: Club
|
||||||
|
):
|
||||||
|
beers_type, cotis_type, refill_type, verre_type = (
|
||||||
|
ProductType.objects.bulk_create(
|
||||||
|
[
|
||||||
|
ProductType(name="Bières bouteilles"),
|
||||||
|
ProductType(name="Cotisations"),
|
||||||
|
ProductType(name="Rechargements"),
|
||||||
|
ProductType(name="Verre"),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
cotis = Product.objects.create(
|
||||||
|
name="Cotis 1 semestre",
|
||||||
|
code="1SCOTIZ",
|
||||||
|
product_type=cotis_type,
|
||||||
|
purchase_price=15,
|
||||||
|
club=main_club,
|
||||||
|
)
|
||||||
|
cotis2 = Product.objects.create(
|
||||||
|
name="Cotis 2 semestres",
|
||||||
|
code="2SCOTIZ",
|
||||||
|
product_type=cotis_type,
|
||||||
|
purchase_price="28",
|
||||||
|
club=main_club,
|
||||||
|
)
|
||||||
|
refill = Product.objects.create(
|
||||||
|
name="Rechargement 15 €",
|
||||||
|
code="15REFILL",
|
||||||
|
product_type=refill_type,
|
||||||
|
purchase_price=15,
|
||||||
|
club=main_club,
|
||||||
|
)
|
||||||
|
barb = Product.objects.create(
|
||||||
|
name="Barbar",
|
||||||
|
code="BARB",
|
||||||
|
product_type=beers_type,
|
||||||
|
purchase_price="1.50",
|
||||||
|
club=main_club,
|
||||||
|
limit_age=18,
|
||||||
|
)
|
||||||
|
cble = Product.objects.create(
|
||||||
|
name="Chimay Bleue",
|
||||||
|
code="CBLE",
|
||||||
|
product_type=beers_type,
|
||||||
|
purchase_price="1.50",
|
||||||
|
club=main_club,
|
||||||
|
limit_age=18,
|
||||||
|
)
|
||||||
|
cons = Product.objects.create(
|
||||||
|
name="Consigne Eco-cup",
|
||||||
|
code="CONS",
|
||||||
|
product_type=verre_type,
|
||||||
|
purchase_price="1",
|
||||||
|
club=main_club,
|
||||||
|
)
|
||||||
|
dcons = Product.objects.create(
|
||||||
|
name="Déconsigne Eco-cup",
|
||||||
|
code="DECO",
|
||||||
|
product_type=verre_type,
|
||||||
|
purchase_price="-1",
|
||||||
|
club=main_club,
|
||||||
|
)
|
||||||
|
cors = Product.objects.create(
|
||||||
|
name="Corsendonk",
|
||||||
|
code="CORS",
|
||||||
|
product_type=beers_type,
|
||||||
|
purchase_price="1.50",
|
||||||
|
club=main_club,
|
||||||
|
limit_age=18,
|
||||||
|
)
|
||||||
|
carolus = Product.objects.create(
|
||||||
|
name="Carolus",
|
||||||
|
code="CARO",
|
||||||
|
product_type=beers_type,
|
||||||
|
purchase_price="1.50",
|
||||||
|
club=main_club,
|
||||||
|
limit_age=18,
|
||||||
|
)
|
||||||
|
Product.objects.create(
|
||||||
|
name="remboursement",
|
||||||
|
code="REMBOURS",
|
||||||
|
purchase_price=0,
|
||||||
|
club=refound_club,
|
||||||
|
)
|
||||||
|
ReturnableProduct.objects.create(
|
||||||
|
product=cons, returned_product=dcons, max_return=3
|
||||||
|
)
|
||||||
|
mde = Counter.objects.get(name="MDE")
|
||||||
|
mde.products.add(barb, cble, cons, dcons)
|
||||||
|
eboutic = Counter.objects.get(name="Eboutic")
|
||||||
|
eboutic.products.add(barb, cotis, cotis2, refill)
|
||||||
|
|
||||||
|
cotis, cotis2, refill, barb, cble, cors, carolus, cons, dcons = (
|
||||||
|
Price.objects.bulk_create(
|
||||||
|
[
|
||||||
|
Price(product=cotis, amount=15),
|
||||||
|
Price(product=cotis2, amount=28),
|
||||||
|
Price(product=refill, amount=15),
|
||||||
|
Price(product=barb, amount=1.7),
|
||||||
|
Price(product=cble, amount=1.7),
|
||||||
|
Price(product=cors, amount=1.7),
|
||||||
|
Price(product=carolus, amount=1.7),
|
||||||
|
Price(product=cons, amount=1),
|
||||||
|
Price(product=dcons, amount=-1),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
Price.groups.through.objects.bulk_create(
|
||||||
|
[
|
||||||
|
Price.groups.through(price=cotis, group=groups.subscribers),
|
||||||
|
Price.groups.through(price=cotis2, group=groups.subscribers),
|
||||||
|
Price.groups.through(price=refill, group=groups.subscribers),
|
||||||
|
Price.groups.through(price=barb, group=groups.subscribers),
|
||||||
|
Price.groups.through(price=cble, group=groups.subscribers),
|
||||||
|
Price.groups.through(price=cors, group=groups.subscribers),
|
||||||
|
Price.groups.through(price=carolus, group=groups.subscribers),
|
||||||
|
Price.groups.through(price=cotis, group=groups.old_subscribers),
|
||||||
|
Price.groups.through(price=cotis2, group=groups.old_subscribers),
|
||||||
|
Price.groups.through(price=cons, group=groups.old_subscribers),
|
||||||
|
Price.groups.through(price=dcons, group=groups.old_subscribers),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
def _create_profile_pict(self, user: User):
|
def _create_profile_pict(self, user: User):
|
||||||
path = self.SAS_FIXTURE_PATH / "Family" / f"{user.username}.jpg"
|
path = self.SAS_FIXTURE_PATH / "Family" / f"{user.username}.jpg"
|
||||||
file = resize_image(Image.open(path), 400, "WEBP")
|
file = resize_image(Image.open(path), 400, "WEBP")
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ from counter.models import (
|
|||||||
Counter,
|
Counter,
|
||||||
Customer,
|
Customer,
|
||||||
Permanency,
|
Permanency,
|
||||||
|
Price,
|
||||||
Product,
|
Product,
|
||||||
ProductType,
|
ProductType,
|
||||||
Refilling,
|
Refilling,
|
||||||
@@ -278,6 +279,7 @@ class Command(BaseCommand):
|
|||||||
# 2/3 of the products are owned by AE
|
# 2/3 of the products are owned by AE
|
||||||
clubs = [ae, ae, ae, ae, ae, ae, *other_clubs]
|
clubs = [ae, ae, ae, ae, ae, ae, *other_clubs]
|
||||||
products = []
|
products = []
|
||||||
|
prices = []
|
||||||
buying_groups = []
|
buying_groups = []
|
||||||
selling_places = []
|
selling_places = []
|
||||||
for _ in range(200):
|
for _ in range(200):
|
||||||
@@ -288,25 +290,28 @@ class Command(BaseCommand):
|
|||||||
product_type=random.choice(categories),
|
product_type=random.choice(categories),
|
||||||
code="".join(self.faker.random_letters(length=random.randint(4, 8))),
|
code="".join(self.faker.random_letters(length=random.randint(4, 8))),
|
||||||
purchase_price=price,
|
purchase_price=price,
|
||||||
selling_price=price,
|
|
||||||
special_selling_price=price - min(0.5, price),
|
|
||||||
club=random.choice(clubs),
|
club=random.choice(clubs),
|
||||||
limit_age=0 if random.random() > 0.2 else 18,
|
limit_age=0 if random.random() > 0.2 else 18,
|
||||||
archived=bool(random.random() > 0.7),
|
archived=self.faker.boolean(60),
|
||||||
)
|
)
|
||||||
products.append(product)
|
products.append(product)
|
||||||
# there will be products without buying groups
|
for i in range(random.randint(0, 3)):
|
||||||
# but there are also such products in the real database
|
product_price = Price(
|
||||||
buying_groups.extend(
|
amount=price, product=product, is_always_shown=self.faker.boolean()
|
||||||
Product.buying_groups.through(product=product, group=group)
|
)
|
||||||
for group in random.sample(groups, k=random.randint(0, 3))
|
# prices for non-subscribers will be higher than for subscribers
|
||||||
)
|
price *= 1.2
|
||||||
|
prices.append(product_price)
|
||||||
|
buying_groups.append(
|
||||||
|
Price.groups.through(price=product_price, group=groups[i])
|
||||||
|
)
|
||||||
selling_places.extend(
|
selling_places.extend(
|
||||||
Counter.products.through(counter=counter, product=product)
|
Counter.products.through(counter=counter, product=product)
|
||||||
for counter in random.sample(counters, random.randint(0, 4))
|
for counter in random.sample(counters, random.randint(0, 4))
|
||||||
)
|
)
|
||||||
Product.objects.bulk_create(products)
|
Product.objects.bulk_create(products)
|
||||||
Product.buying_groups.through.objects.bulk_create(buying_groups)
|
Price.objects.bulk_create(prices)
|
||||||
|
Price.groups.through.objects.bulk_create(buying_groups)
|
||||||
Counter.products.through.objects.bulk_create(selling_places)
|
Counter.products.through.objects.bulk_create(selling_places)
|
||||||
|
|
||||||
def create_sales(self, sellers: list[User]):
|
def create_sales(self, sellers: list[User]):
|
||||||
@@ -320,7 +325,7 @@ class Command(BaseCommand):
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
products = list(Product.objects.all())
|
prices = list(Price.objects.select_related("product").all())
|
||||||
counters = list(
|
counters = list(
|
||||||
Counter.objects.filter(name__in=["Foyer", "MDE", "La Gommette"])
|
Counter.objects.filter(name__in=["Foyer", "MDE", "La Gommette"])
|
||||||
)
|
)
|
||||||
@@ -330,14 +335,14 @@ class Command(BaseCommand):
|
|||||||
# the longer the customer has existed, the higher the mean of nb_products
|
# the longer the customer has existed, the higher the mean of nb_products
|
||||||
mu = 5 + (now().year - customer.since.year) * 2
|
mu = 5 + (now().year - customer.since.year) * 2
|
||||||
nb_sales = max(0, int(random.normalvariate(mu=mu, sigma=mu * 5)))
|
nb_sales = max(0, int(random.normalvariate(mu=mu, sigma=mu * 5)))
|
||||||
favoured_products = random.sample(products, k=(random.randint(1, 5)))
|
favoured_prices = random.sample(prices, k=(random.randint(1, 5)))
|
||||||
favoured_counter = random.choice(counters)
|
favoured_counter = random.choice(counters)
|
||||||
this_customer_sales = []
|
this_customer_sales = []
|
||||||
for _ in range(nb_sales):
|
for _ in range(nb_sales):
|
||||||
product = (
|
price = (
|
||||||
random.choice(favoured_products)
|
random.choice(favoured_prices)
|
||||||
if random.random() > 0.7
|
if random.random() > 0.7
|
||||||
else random.choice(products)
|
else random.choice(prices)
|
||||||
)
|
)
|
||||||
counter = (
|
counter = (
|
||||||
favoured_counter
|
favoured_counter
|
||||||
@@ -346,11 +351,11 @@ class Command(BaseCommand):
|
|||||||
)
|
)
|
||||||
this_customer_sales.append(
|
this_customer_sales.append(
|
||||||
Selling(
|
Selling(
|
||||||
product=product,
|
product=price.product,
|
||||||
counter=counter,
|
counter=counter,
|
||||||
club_id=product.club_id,
|
club_id=price.product.club_id,
|
||||||
quantity=random.randint(1, 5),
|
quantity=random.randint(1, 5),
|
||||||
unit_price=product.selling_price,
|
unit_price=price.amount,
|
||||||
seller=random.choice(sellers),
|
seller=random.choice(sellers),
|
||||||
customer=customer,
|
customer=customer,
|
||||||
date=make_aware(
|
date=make_aware(
|
||||||
|
|||||||
@@ -1,27 +0,0 @@
|
|||||||
# 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
|
|
||||||
)
|
|
||||||
]
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
# 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")]
|
|
||||||
@@ -356,23 +356,27 @@ class User(AbstractUser):
|
|||||||
)
|
)
|
||||||
if group_id is None:
|
if group_id is None:
|
||||||
return False
|
return False
|
||||||
if group_id == settings.SITH_GROUP_SUBSCRIBERS_ID:
|
return group_id in self.all_groups
|
||||||
return self.is_subscribed
|
|
||||||
if group_id == settings.SITH_GROUP_ROOT_ID:
|
|
||||||
return self.is_root
|
|
||||||
return any(g.id == group_id for g in self.cached_groups)
|
|
||||||
|
|
||||||
@cached_property
|
@cached_property
|
||||||
def cached_groups(self) -> list[Group]:
|
def all_groups(self) -> dict[int, Group]:
|
||||||
"""Get the list of groups this user is in."""
|
"""Get the list of groups this user is in."""
|
||||||
return list(self.groups.all())
|
additional_groups = []
|
||||||
|
if self.is_subscribed:
|
||||||
|
additional_groups.append(settings.SITH_GROUP_SUBSCRIBERS_ID)
|
||||||
|
if self.is_superuser:
|
||||||
|
additional_groups.append(settings.SITH_GROUP_ROOT_ID)
|
||||||
|
qs = self.groups.all()
|
||||||
|
if additional_groups:
|
||||||
|
# This is somewhat counter-intuitive, but this query runs way faster with
|
||||||
|
# a UNION rather than a OR (in average, 0.25ms vs 14ms).
|
||||||
|
# For the why, cf. https://dba.stackexchange.com/questions/293836/why-is-an-or-statement-slower-than-union
|
||||||
|
qs = qs.union(Group.objects.filter(id__in=additional_groups))
|
||||||
|
return {g.id: g for g in qs}
|
||||||
|
|
||||||
@cached_property
|
@cached_property
|
||||||
def is_root(self) -> bool:
|
def is_root(self) -> bool:
|
||||||
if self.is_superuser:
|
return self.is_superuser or settings.SITH_GROUP_ROOT_ID in self.all_groups
|
||||||
return True
|
|
||||||
root_id = settings.SITH_GROUP_ROOT_ID
|
|
||||||
return any(g.id == root_id for g in self.cached_groups)
|
|
||||||
|
|
||||||
@cached_property
|
@cached_property
|
||||||
def is_board_member(self) -> bool:
|
def is_board_member(self) -> bool:
|
||||||
@@ -833,6 +837,9 @@ 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")
|
||||||
@@ -841,10 +848,22 @@ 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:
|
||||||
@@ -857,6 +876,8 @@ 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:
|
||||||
@@ -883,6 +904,8 @@ 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()
|
||||||
):
|
):
|
||||||
@@ -963,6 +986,18 @@ 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
|
||||||
@@ -1068,10 +1103,7 @@ class PageQuerySet(models.QuerySet):
|
|||||||
return self.filter(view_groups=settings.SITH_GROUP_PUBLIC_ID)
|
return self.filter(view_groups=settings.SITH_GROUP_PUBLIC_ID)
|
||||||
if user.has_perm("core.view_page"):
|
if user.has_perm("core.view_page"):
|
||||||
return self.all()
|
return self.all()
|
||||||
groups_ids = [g.id for g in user.cached_groups]
|
return self.filter(view_groups__in=user.all_groups)
|
||||||
if user.is_subscribed:
|
|
||||||
groups_ids.append(settings.SITH_GROUP_SUBSCRIBERS_ID)
|
|
||||||
return self.filter(view_groups__in=groups_ids)
|
|
||||||
|
|
||||||
|
|
||||||
# This function prevents generating migration upon settings change
|
# This function prevents generating migration upon settings change
|
||||||
@@ -1345,7 +1377,7 @@ class PageRev(models.Model):
|
|||||||
return self.page.can_be_edited_by(user)
|
return self.page.can_be_edited_by(user)
|
||||||
|
|
||||||
def is_owned_by(self, user: User) -> bool:
|
def is_owned_by(self, user: User) -> bool:
|
||||||
return any(g.id == self.page.owner_group_id for g in user.cached_groups)
|
return self.page.owner_group_id in user.all_groups
|
||||||
|
|
||||||
def similarity_ratio(self, text: str) -> float:
|
def similarity_ratio(self, text: str) -> float:
|
||||||
"""Similarity ratio between this revision's content and the given text.
|
"""Similarity ratio between this revision's content and the given text.
|
||||||
|
|||||||
77
core/static/bundled/core/dynamic-formset-index.ts
Normal file
77
core/static/bundled/core/dynamic-formset-index.ts
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
interface Config {
|
||||||
|
/**
|
||||||
|
* The prefix of the formset, in case it has been changed.
|
||||||
|
* See https://docs.djangoproject.com/fr/stable/topics/forms/formsets/#customizing-a-formset-s-prefix
|
||||||
|
*/
|
||||||
|
prefix?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// biome-ignore lint/style/useNamingConvention: It's the DOM API naming
|
||||||
|
type HTMLFormInputElement = HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement;
|
||||||
|
|
||||||
|
document.addEventListener("alpine:init", () => {
|
||||||
|
/**
|
||||||
|
* Alpine data element to allow the dynamic addition of forms to a formset.
|
||||||
|
*
|
||||||
|
* To use this, you need :
|
||||||
|
* - an HTML element containing the existing forms, noted by `x-ref="formContainer"`
|
||||||
|
* - a template containing the empty form
|
||||||
|
* (that you can obtain jinja-side with `{{ formset.empty_form }}`),
|
||||||
|
* noted by `x-ref="formTemplate"`
|
||||||
|
* - a button with `@click="addForm"`
|
||||||
|
* - you may also have one or more buttons with `@click="removeForm(element)"`,
|
||||||
|
* where `element` is the HTML element containing the form.
|
||||||
|
*
|
||||||
|
* For an example of how this is used, you can have a look to
|
||||||
|
* `counter/templates/counter/product_form.jinja`
|
||||||
|
*/
|
||||||
|
Alpine.data("dynamicFormSet", (config?: Config) => ({
|
||||||
|
init() {
|
||||||
|
this.formContainer = this.$refs.formContainer as HTMLElement;
|
||||||
|
this.nbForms = this.formContainer.children.length as number;
|
||||||
|
this.template = this.$refs.formTemplate as HTMLTemplateElement;
|
||||||
|
const prefix = config?.prefix ?? "form";
|
||||||
|
this.$root
|
||||||
|
.querySelector(`#id_${prefix}-TOTAL_FORMS`)
|
||||||
|
.setAttribute(":value", "nbForms");
|
||||||
|
},
|
||||||
|
|
||||||
|
addForm() {
|
||||||
|
this.formContainer.appendChild(document.importNode(this.template.content, true));
|
||||||
|
const newForm = this.formContainer.lastElementChild;
|
||||||
|
const inputs: NodeListOf<HTMLFormInputElement> = newForm.querySelectorAll(
|
||||||
|
"input, select, textarea",
|
||||||
|
);
|
||||||
|
for (const el of inputs) {
|
||||||
|
el.name = el.name.replace("__prefix__", this.nbForms.toString());
|
||||||
|
el.id = el.id.replace("__prefix__", this.nbForms.toString());
|
||||||
|
}
|
||||||
|
const labels: NodeListOf<HTMLLabelElement> = newForm.querySelectorAll("label");
|
||||||
|
for (const el of labels) {
|
||||||
|
el.htmlFor = el.htmlFor.replace("__prefix__", this.nbForms.toString());
|
||||||
|
}
|
||||||
|
inputs[0].focus();
|
||||||
|
this.nbForms += 1;
|
||||||
|
},
|
||||||
|
|
||||||
|
removeForm(container: HTMLDivElement) {
|
||||||
|
container.remove();
|
||||||
|
this.nbForms -= 1;
|
||||||
|
// adjust the id of remaining forms
|
||||||
|
for (let i = 0; i < this.nbForms; i++) {
|
||||||
|
const form: HTMLDivElement = this.formContainer.children[i];
|
||||||
|
const inputs: NodeListOf<HTMLFormInputElement> = form.querySelectorAll(
|
||||||
|
"input, select, textarea",
|
||||||
|
);
|
||||||
|
for (const el of inputs) {
|
||||||
|
el.name = el.name.replace(/\d+/, i.toString());
|
||||||
|
el.id = el.id.replace(/\d+/, i.toString());
|
||||||
|
}
|
||||||
|
const labels: NodeListOf<HTMLLabelElement> = form.querySelectorAll("label");
|
||||||
|
for (const el of labels) {
|
||||||
|
el.htmlFor = el.htmlFor.replace(/\d+/, i.toString());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
});
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -418,16 +418,16 @@ class TestUserIsInGroup(TestCase):
|
|||||||
group_in = baker.make(Group)
|
group_in = baker.make(Group)
|
||||||
self.public_user.groups.add(group_in)
|
self.public_user.groups.add(group_in)
|
||||||
|
|
||||||
# clear the cached property `User.cached_groups`
|
# clear the cached property `User.all_groups`
|
||||||
self.public_user.__dict__.pop("cached_groups", None)
|
self.public_user.__dict__.pop("all_groups", None)
|
||||||
# Test when the user is in the group
|
# Test when the user is in the group
|
||||||
with self.assertNumQueries(1):
|
with self.assertNumQueries(2):
|
||||||
self.public_user.is_in_group(pk=group_in.id)
|
self.public_user.is_in_group(pk=group_in.id)
|
||||||
with self.assertNumQueries(0):
|
with self.assertNumQueries(0):
|
||||||
self.public_user.is_in_group(pk=group_in.id)
|
self.public_user.is_in_group(pk=group_in.id)
|
||||||
|
|
||||||
group_not_in = baker.make(Group)
|
group_not_in = baker.make(Group)
|
||||||
self.public_user.__dict__.pop("cached_groups", None)
|
self.public_user.__dict__.pop("all_groups", None)
|
||||||
# Test when the user is not in the group
|
# Test when the user is not in the group
|
||||||
with self.assertNumQueries(1):
|
with self.assertNumQueries(1):
|
||||||
self.public_user.is_in_group(pk=group_not_in.id)
|
self.public_user.is_in_group(pk=group_not_in.id)
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ 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
|
||||||
@@ -18,8 +17,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
|
||||||
@@ -31,19 +30,24 @@ 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 = picture_recipe.make()
|
picture: SithFile = baker.make(
|
||||||
assert user.can_edit(picture)
|
Picture, parent=SithFile.objects.get(pk=settings.SITH_SAS_ROOT_DIR_ID)
|
||||||
|
)
|
||||||
|
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_recipe.make(owner=user)
|
picture: Picture = baker.make(Picture, owner=user)
|
||||||
assert user.can_edit(picture)
|
assert picture.is_owned_by(user)
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
"user_factory",
|
"user_factory",
|
||||||
@@ -59,41 +63,7 @@ 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 user.can_edit(picture)
|
assert not picture.is_owned_by(user)
|
||||||
|
|
||||||
|
|
||||||
@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:
|
||||||
|
|||||||
@@ -27,7 +27,6 @@ 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):
|
||||||
@@ -35,7 +34,6 @@ 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,
|
||||||
@@ -215,9 +213,9 @@ def test_user_invoice_with_multiple_items():
|
|||||||
"""Test that annotate_total() works when invoices contain multiple items."""
|
"""Test that annotate_total() works when invoices contain multiple items."""
|
||||||
user: User = subscriber_user.make()
|
user: User = subscriber_user.make()
|
||||||
item_recipe = Recipe(InvoiceItem, invoice=foreign_key(Recipe(Invoice, user=user)))
|
item_recipe = Recipe(InvoiceItem, invoice=foreign_key(Recipe(Invoice, user=user)))
|
||||||
item_recipe.make(_quantity=3, quantity=1, product_unit_price=5)
|
item_recipe.make(_quantity=3, quantity=1, unit_price=5)
|
||||||
item_recipe.make(_quantity=1, quantity=1, product_unit_price=5)
|
item_recipe.make(_quantity=1, quantity=1, unit_price=5)
|
||||||
item_recipe.make(_quantity=2, quantity=1, product_unit_price=iter([5, 8]))
|
item_recipe.make(_quantity=2, quantity=1, unit_price=iter([5, 8]))
|
||||||
res = list(
|
res = list(
|
||||||
Invoice.objects.filter(user=user)
|
Invoice.objects.filter(user=user)
|
||||||
.annotate_total()
|
.annotate_total()
|
||||||
|
|||||||
@@ -12,23 +12,18 @@
|
|||||||
# 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 Any, Final, Unpack
|
from typing import Final
|
||||||
|
|
||||||
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.db import models
|
from django.http import HttpRequest
|
||||||
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
|
||||||
@@ -47,21 +42,6 @@ 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.
|
||||||
@@ -225,56 +205,3 @@ 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
|
|
||||||
|
|||||||
@@ -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)
|
queryset = SithFile.objects.filter(is_moderated=False, is_in_sas=False)
|
||||||
ordering = "id"
|
ordering = "id"
|
||||||
paginate_by = 100
|
paginate_by = 100
|
||||||
|
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ from counter.models import (
|
|||||||
Eticket,
|
Eticket,
|
||||||
InvoiceCall,
|
InvoiceCall,
|
||||||
Permanency,
|
Permanency,
|
||||||
|
Price,
|
||||||
Product,
|
Product,
|
||||||
ProductType,
|
ProductType,
|
||||||
Refilling,
|
Refilling,
|
||||||
@@ -32,19 +33,24 @@ from counter.models import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class PriceInline(admin.TabularInline):
|
||||||
|
model = Price
|
||||||
|
autocomplete_fields = ("groups",)
|
||||||
|
|
||||||
|
|
||||||
@admin.register(Product)
|
@admin.register(Product)
|
||||||
class ProductAdmin(SearchModelAdmin):
|
class ProductAdmin(SearchModelAdmin):
|
||||||
list_display = (
|
list_display = (
|
||||||
"name",
|
"name",
|
||||||
"code",
|
"code",
|
||||||
"product_type",
|
"product_type",
|
||||||
"selling_price",
|
|
||||||
"archived",
|
"archived",
|
||||||
"created_at",
|
"created_at",
|
||||||
"updated_at",
|
"updated_at",
|
||||||
)
|
)
|
||||||
list_select_related = ("product_type",)
|
list_select_related = ("product_type",)
|
||||||
search_fields = ("name", "code")
|
search_fields = ("name", "code")
|
||||||
|
inlines = [PriceInline]
|
||||||
|
|
||||||
|
|
||||||
@admin.register(ReturnableProduct)
|
@admin.register(ReturnableProduct)
|
||||||
|
|||||||
@@ -101,13 +101,9 @@ class ProductController(ControllerBase):
|
|||||||
"""Get the detailed information about the products."""
|
"""Get the detailed information about the products."""
|
||||||
return filters.filter(
|
return filters.filter(
|
||||||
Product.objects.select_related("club")
|
Product.objects.select_related("club")
|
||||||
.prefetch_related("buying_groups")
|
.prefetch_related("prices", "prices__groups")
|
||||||
.select_related("product_type")
|
.select_related("product_type")
|
||||||
.order_by(
|
.order_by(F("product_type__order").asc(nulls_last=True), "name")
|
||||||
F("product_type__order").asc(nulls_last=True),
|
|
||||||
"product_type",
|
|
||||||
"name",
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -2,10 +2,11 @@ from model_bakery.recipe import Recipe, foreign_key
|
|||||||
|
|
||||||
from club.models import Club
|
from club.models import Club
|
||||||
from core.models import User
|
from core.models import User
|
||||||
from counter.models import Counter, Product, Refilling, Selling
|
from counter.models import Counter, Price, Product, Refilling, Selling
|
||||||
|
|
||||||
counter_recipe = Recipe(Counter)
|
counter_recipe = Recipe(Counter)
|
||||||
product_recipe = Recipe(Product, club=foreign_key(Recipe(Club)))
|
product_recipe = Recipe(Product, club=foreign_key(Recipe(Club)))
|
||||||
|
price_recipe = Recipe(Price, product=foreign_key(product_recipe))
|
||||||
sale_recipe = Recipe(
|
sale_recipe = Recipe(
|
||||||
Selling,
|
Selling,
|
||||||
product=foreign_key(product_recipe),
|
product=foreign_key(product_recipe),
|
||||||
|
|||||||
123
counter/forms.py
123
counter/forms.py
@@ -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,6 +35,7 @@ from counter.models import (
|
|||||||
Customer,
|
Customer,
|
||||||
Eticket,
|
Eticket,
|
||||||
InvoiceCall,
|
InvoiceCall,
|
||||||
|
Price,
|
||||||
Product,
|
Product,
|
||||||
ProductFormula,
|
ProductFormula,
|
||||||
Refilling,
|
Refilling,
|
||||||
@@ -291,7 +292,22 @@ ScheduledProductActionFormSet = forms.modelformset_factory(
|
|||||||
absolute_max=None,
|
absolute_max=None,
|
||||||
can_delete=True,
|
can_delete=True,
|
||||||
can_delete_extra=False,
|
can_delete_extra=False,
|
||||||
extra=2,
|
extra=0,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
ProductPriceFormSet = forms.inlineformset_factory(
|
||||||
|
parent_model=Product,
|
||||||
|
model=Price,
|
||||||
|
fields=["amount", "label", "groups", "is_always_shown"],
|
||||||
|
widgets={
|
||||||
|
"groups": AutoCompleteSelectMultipleGroup,
|
||||||
|
"is_always_shown": forms.CheckboxInput(attrs={"class": "switch"}),
|
||||||
|
},
|
||||||
|
absolute_max=None,
|
||||||
|
can_delete_extra=False,
|
||||||
|
min_num=1,
|
||||||
|
extra=0,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -306,10 +322,7 @@ class ProductForm(forms.ModelForm):
|
|||||||
"description",
|
"description",
|
||||||
"product_type",
|
"product_type",
|
||||||
"code",
|
"code",
|
||||||
"buying_groups",
|
|
||||||
"purchase_price",
|
"purchase_price",
|
||||||
"selling_price",
|
|
||||||
"special_selling_price",
|
|
||||||
"icon",
|
"icon",
|
||||||
"club",
|
"club",
|
||||||
"limit_age",
|
"limit_age",
|
||||||
@@ -324,8 +337,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(
|
||||||
@@ -335,50 +348,40 @@ class ProductForm(forms.ModelForm):
|
|||||||
queryset=Counter.objects.all(),
|
queryset=Counter.objects.all(),
|
||||||
)
|
)
|
||||||
|
|
||||||
def __init__(self, *args, instance=None, **kwargs):
|
def __init__(self, *args, prefix: str | None = None, instance=None, **kwargs):
|
||||||
super().__init__(*args, instance=instance, **kwargs)
|
super().__init__(*args, prefix=prefix, instance=instance, **kwargs)
|
||||||
|
self.fields["name"].widget.attrs["autofocus"] = "autofocus"
|
||||||
if self.instance.id:
|
if self.instance.id:
|
||||||
self.fields["counters"].initial = self.instance.counters.all()
|
self.fields["counters"].initial = self.instance.counters.all()
|
||||||
if hasattr(self.instance, "formula"):
|
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, **kwargs
|
*args, product=self.instance, prefix="action", **kwargs
|
||||||
)
|
)
|
||||||
|
|
||||||
def formula_init(self, formula: ProductFormula):
|
|
||||||
"""Part of the form initialisation specific to formula products."""
|
|
||||||
self.fields["selling_price"].help_text = _(
|
|
||||||
"This product is a formula. "
|
|
||||||
"Its price cannot be greater than the price "
|
|
||||||
"of the products constituting it, which is %(price)s €"
|
|
||||||
) % {"price": formula.max_selling_price}
|
|
||||||
self.fields["special_selling_price"].help_text = _(
|
|
||||||
"This product is a formula. "
|
|
||||||
"Its special price cannot be greater than the price "
|
|
||||||
"of the products constituting it, which is %(price)s €"
|
|
||||||
) % {"price": formula.max_special_selling_price}
|
|
||||||
for key, price in (
|
|
||||||
("selling_price", formula.max_selling_price),
|
|
||||||
("special_selling_price", formula.max_special_selling_price),
|
|
||||||
):
|
|
||||||
self.fields[key].widget.attrs["max"] = price
|
|
||||||
self.fields[key].validators.append(MaxValueValidator(price))
|
|
||||||
|
|
||||||
def is_valid(self):
|
def is_valid(self):
|
||||||
return super().is_valid() and self.action_formset.is_valid()
|
return (
|
||||||
|
super().is_valid()
|
||||||
|
and self.price_formset.is_valid()
|
||||||
|
and self.action_formset.is_valid()
|
||||||
|
)
|
||||||
|
|
||||||
def save(self, *args, **kwargs) -> Product:
|
def save(self, *args, **kwargs) -> Product:
|
||||||
product = super().save(*args, **kwargs)
|
product = super().save(*args, **kwargs)
|
||||||
product.counters.set(self.cleaned_data["counters"])
|
product.counters.set(self.cleaned_data["counters"])
|
||||||
|
# if it's a creation, the product given in the formset
|
||||||
|
# wasn't a persisted instance.
|
||||||
|
# So if we tried to persist the related objects in the current state,
|
||||||
|
# they would be linked to no product, thus be completely useless
|
||||||
|
# To make it work, we have to replace
|
||||||
|
# the initial product with a persisted one
|
||||||
for form in self.action_formset:
|
for form in self.action_formset:
|
||||||
# if it's a creation, the product given in the formset
|
|
||||||
# wasn't a persisted instance.
|
|
||||||
# So if we tried to persist the scheduled actions in the current state,
|
|
||||||
# they would be linked to no product, thus be completely useless
|
|
||||||
# To make it work, we have to replace
|
|
||||||
# the initial product with a persisted one
|
|
||||||
form.set_product(product)
|
form.set_product(product)
|
||||||
self.action_formset.save()
|
self.action_formset.save()
|
||||||
|
self.price_formset.save()
|
||||||
return product
|
return product
|
||||||
|
|
||||||
|
|
||||||
@@ -401,18 +404,6 @@ 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
|
||||||
|
|
||||||
|
|
||||||
@@ -463,48 +454,47 @@ class CloseCustomerAccountForm(forms.Form):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class BasketProductForm(forms.Form):
|
class BasketItemForm(forms.Form):
|
||||||
quantity = forms.IntegerField(min_value=1, required=True)
|
quantity = forms.IntegerField(min_value=1, required=True)
|
||||||
id = forms.IntegerField(min_value=0, required=True)
|
price_id = forms.IntegerField(min_value=0, required=True)
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
customer: Customer,
|
customer: Customer,
|
||||||
counter: Counter,
|
counter: Counter,
|
||||||
allowed_products: dict[int, Product],
|
allowed_prices: dict[int, Price],
|
||||||
*args,
|
*args,
|
||||||
**kwargs,
|
**kwargs,
|
||||||
):
|
):
|
||||||
self.customer = customer # Used by formset
|
self.customer = customer # Used by formset
|
||||||
self.counter = counter # Used by formset
|
self.counter = counter # Used by formset
|
||||||
self.allowed_products = allowed_products
|
self.allowed_prices = allowed_prices
|
||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
def clean_id(self):
|
def clean_price_id(self):
|
||||||
data = self.cleaned_data["id"]
|
data = self.cleaned_data["price_id"]
|
||||||
|
|
||||||
# We store self.product so we can use it later on the formset validation
|
# We store self.price so we can use it later on the formset validation
|
||||||
# And also in the global clean
|
# And also in the global clean
|
||||||
self.product = self.allowed_products.get(data, None)
|
self.price = self.allowed_prices.get(data, None)
|
||||||
if self.product is None:
|
if self.price is None:
|
||||||
raise forms.ValidationError(
|
raise forms.ValidationError(
|
||||||
_("The selected product isn't available for this user")
|
_("The selected product isn't available for this user")
|
||||||
)
|
)
|
||||||
|
|
||||||
return data
|
return data
|
||||||
|
|
||||||
def clean(self):
|
def clean(self):
|
||||||
cleaned_data = super().clean()
|
cleaned_data = super().clean()
|
||||||
if len(self.errors) > 0:
|
if len(self.errors) > 0:
|
||||||
return
|
return cleaned_data
|
||||||
|
|
||||||
# Compute prices
|
# Compute prices
|
||||||
cleaned_data["bonus_quantity"] = 0
|
cleaned_data["bonus_quantity"] = 0
|
||||||
if self.product.tray:
|
if self.price.product.tray:
|
||||||
cleaned_data["bonus_quantity"] = math.floor(
|
cleaned_data["bonus_quantity"] = math.floor(
|
||||||
cleaned_data["quantity"] / Product.QUANTITY_FOR_TRAY_PRICE
|
cleaned_data["quantity"] / Product.QUANTITY_FOR_TRAY_PRICE
|
||||||
)
|
)
|
||||||
cleaned_data["total_price"] = self.product.price * (
|
cleaned_data["total_price"] = self.price.amount * (
|
||||||
cleaned_data["quantity"] - cleaned_data["bonus_quantity"]
|
cleaned_data["quantity"] - cleaned_data["bonus_quantity"]
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -528,8 +518,8 @@ class BaseBasketForm(forms.BaseFormSet):
|
|||||||
raise forms.ValidationError(_("Submitted basket is invalid"))
|
raise forms.ValidationError(_("Submitted basket is invalid"))
|
||||||
|
|
||||||
def _check_product_are_unique(self):
|
def _check_product_are_unique(self):
|
||||||
product_ids = {form.cleaned_data["id"] for form in self.forms}
|
price_ids = {form.cleaned_data["price_id"] for form in self.forms}
|
||||||
if len(product_ids) != len(self.forms):
|
if len(price_ids) != len(self.forms):
|
||||||
raise forms.ValidationError(_("Duplicated product entries."))
|
raise forms.ValidationError(_("Duplicated product entries."))
|
||||||
|
|
||||||
def _check_enough_money(self, counter: Counter, customer: Customer):
|
def _check_enough_money(self, counter: Counter, customer: Customer):
|
||||||
@@ -539,10 +529,9 @@ class BaseBasketForm(forms.BaseFormSet):
|
|||||||
|
|
||||||
def _check_recorded_products(self, customer: Customer):
|
def _check_recorded_products(self, customer: Customer):
|
||||||
"""Check for, among other things, ecocups and pitchers"""
|
"""Check for, among other things, ecocups and pitchers"""
|
||||||
items = {
|
items = defaultdict(int)
|
||||||
form.cleaned_data["id"]: form.cleaned_data["quantity"]
|
for form in self.forms:
|
||||||
for form in self.forms
|
items[form.price.product_id] += form.cleaned_data["quantity"]
|
||||||
}
|
|
||||||
ids = list(items.keys())
|
ids = list(items.keys())
|
||||||
returnables = list(
|
returnables = list(
|
||||||
ReturnableProduct.objects.filter(
|
ReturnableProduct.objects.filter(
|
||||||
@@ -568,7 +557,7 @@ class BaseBasketForm(forms.BaseFormSet):
|
|||||||
|
|
||||||
|
|
||||||
BasketForm = forms.formset_factory(
|
BasketForm = forms.formset_factory(
|
||||||
BasketProductForm, formset=BaseBasketForm, absolute_max=None, min_num=1
|
BasketItemForm, formset=BaseBasketForm, absolute_max=None, min_num=1
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
149
counter/migrations/0038_price.py
Normal file
149
counter/migrations/0038_price.py
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
# Generated by Django 5.2.11 on 2026-02-18 13:30
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.db import migrations, models
|
||||||
|
from django.db.migrations.state import StateApps
|
||||||
|
|
||||||
|
import counter.fields
|
||||||
|
|
||||||
|
|
||||||
|
def migrate_prices(apps: StateApps, schema_editor):
|
||||||
|
Product = apps.get_model("counter", "Product")
|
||||||
|
Price = apps.get_model("counter", "Price")
|
||||||
|
prices = [
|
||||||
|
Price(
|
||||||
|
amount=p.selling_price,
|
||||||
|
product=p,
|
||||||
|
created_at=p.created_at,
|
||||||
|
updated_at=p.updated_at,
|
||||||
|
)
|
||||||
|
for p in Product.objects.all()
|
||||||
|
]
|
||||||
|
Price.objects.bulk_create(prices)
|
||||||
|
groups = [
|
||||||
|
Price.groups.through(price=price, group=group)
|
||||||
|
for price in Price.objects.select_related("product").prefetch_related(
|
||||||
|
"product__buying_groups"
|
||||||
|
)
|
||||||
|
for group in price.product.buying_groups.all()
|
||||||
|
]
|
||||||
|
Price.groups.through.objects.bulk_create(groups)
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("core", "0048_alter_user_options"),
|
||||||
|
("counter", "0037_productformula"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="Price",
|
||||||
|
fields=[
|
||||||
|
(
|
||||||
|
"id",
|
||||||
|
models.AutoField(
|
||||||
|
auto_created=True,
|
||||||
|
primary_key=True,
|
||||||
|
serialize=False,
|
||||||
|
verbose_name="ID",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"amount",
|
||||||
|
counter.fields.CurrencyField(
|
||||||
|
decimal_places=2, max_digits=12, verbose_name="amount"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"is_always_shown",
|
||||||
|
models.BooleanField(
|
||||||
|
default=False,
|
||||||
|
help_text=(
|
||||||
|
"If this option is enabled, "
|
||||||
|
"people will see this price and be able to pay it, "
|
||||||
|
"even if another cheaper price exists. "
|
||||||
|
"Else it will visible only if it is the cheapest available price."
|
||||||
|
),
|
||||||
|
verbose_name="always show",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"label",
|
||||||
|
models.CharField(
|
||||||
|
default="",
|
||||||
|
help_text=(
|
||||||
|
"A short label for easier differentiation "
|
||||||
|
"if a user can see multiple prices."
|
||||||
|
),
|
||||||
|
max_length=32,
|
||||||
|
verbose_name="label",
|
||||||
|
blank=True,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"created_at",
|
||||||
|
models.DateTimeField(auto_now_add=True, verbose_name="created at"),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"updated_at",
|
||||||
|
models.DateTimeField(auto_now=True, verbose_name="updated at"),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"groups",
|
||||||
|
models.ManyToManyField(
|
||||||
|
related_name="prices", to="core.group", verbose_name="groups"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"product",
|
||||||
|
models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
related_name="prices",
|
||||||
|
to="counter.product",
|
||||||
|
verbose_name="product",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
options={"verbose_name": "price"},
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="product",
|
||||||
|
name="tray",
|
||||||
|
field=models.BooleanField(
|
||||||
|
default=False,
|
||||||
|
help_text="Buy five, get the sixth free",
|
||||||
|
verbose_name="tray price",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.RunPython(migrate_prices, reverse_code=migrations.RunPython.noop),
|
||||||
|
migrations.RemoveField(model_name="product", name="selling_price"),
|
||||||
|
migrations.RemoveField(model_name="product", name="special_selling_price"),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="product",
|
||||||
|
name="description",
|
||||||
|
field=models.TextField(blank=True, default="", verbose_name="description"),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="product",
|
||||||
|
name="product_type",
|
||||||
|
field=models.ForeignKey(
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.SET_NULL,
|
||||||
|
related_name="products",
|
||||||
|
to="counter.producttype",
|
||||||
|
verbose_name="product type",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="productformula",
|
||||||
|
name="result",
|
||||||
|
field=models.OneToOneField(
|
||||||
|
help_text="The product got with the formula.",
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
related_name="formula",
|
||||||
|
to="counter.product",
|
||||||
|
verbose_name="result product",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -22,7 +22,7 @@ import string
|
|||||||
from datetime import date, datetime, timedelta
|
from datetime import date, datetime, timedelta
|
||||||
from datetime import timezone as tz
|
from datetime import timezone as tz
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
from typing import Literal, Self
|
from typing import TYPE_CHECKING, Literal, Self
|
||||||
|
|
||||||
from dict2xml import dict2xml
|
from dict2xml import dict2xml
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
@@ -47,6 +47,9 @@ from core.utils import get_start_of_semester
|
|||||||
from counter.fields import CurrencyField
|
from counter.fields import CurrencyField
|
||||||
from subscription.models import Subscription
|
from subscription.models import Subscription
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
|
|
||||||
def get_eboutic() -> Counter:
|
def get_eboutic() -> Counter:
|
||||||
return Counter.objects.filter(type="EBOUTIC").order_by("id").first()
|
return Counter.objects.filter(type="EBOUTIC").order_by("id").first()
|
||||||
@@ -157,14 +160,7 @@ class Customer(models.Model):
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def can_buy(self) -> bool:
|
def can_buy(self) -> bool:
|
||||||
"""Check if whether this customer has the right to purchase any item.
|
"""Check if whether this customer has the right to purchase any item."""
|
||||||
|
|
||||||
This must be not confused with the Product.can_be_sold_to(user)
|
|
||||||
method as the present method returns an information
|
|
||||||
about a customer whereas the other tells something
|
|
||||||
about the relation between a User (not a Customer,
|
|
||||||
don't mix them) and a Product.
|
|
||||||
"""
|
|
||||||
subscription = self.user.subscriptions.order_by("subscription_end").last()
|
subscription = self.user.subscriptions.order_by("subscription_end").last()
|
||||||
if subscription is None:
|
if subscription is None:
|
||||||
return False
|
return False
|
||||||
@@ -363,13 +359,13 @@ class Product(models.Model):
|
|||||||
QUANTITY_FOR_TRAY_PRICE = 6
|
QUANTITY_FOR_TRAY_PRICE = 6
|
||||||
|
|
||||||
name = models.CharField(_("name"), max_length=64)
|
name = models.CharField(_("name"), max_length=64)
|
||||||
description = models.TextField(_("description"), default="")
|
description = models.TextField(_("description"), blank=True, default="")
|
||||||
product_type = models.ForeignKey(
|
product_type = models.ForeignKey(
|
||||||
ProductType,
|
ProductType,
|
||||||
related_name="products",
|
related_name="products",
|
||||||
verbose_name=_("product type"),
|
verbose_name=_("product type"),
|
||||||
null=True,
|
null=True,
|
||||||
blank=True,
|
blank=False,
|
||||||
on_delete=models.SET_NULL,
|
on_delete=models.SET_NULL,
|
||||||
)
|
)
|
||||||
code = models.CharField(_("code"), max_length=16, blank=True)
|
code = models.CharField(_("code"), max_length=16, blank=True)
|
||||||
@@ -377,11 +373,6 @@ class Product(models.Model):
|
|||||||
_("purchase price"),
|
_("purchase price"),
|
||||||
help_text=_("Initial cost of purchasing the product"),
|
help_text=_("Initial cost of purchasing the product"),
|
||||||
)
|
)
|
||||||
selling_price = CurrencyField(_("selling price"))
|
|
||||||
special_selling_price = CurrencyField(
|
|
||||||
_("special selling price"),
|
|
||||||
help_text=_("Price for barmen during their permanence"),
|
|
||||||
)
|
|
||||||
icon = ResizedImageField(
|
icon = ResizedImageField(
|
||||||
height=70,
|
height=70,
|
||||||
force_format="WEBP",
|
force_format="WEBP",
|
||||||
@@ -394,7 +385,9 @@ class Product(models.Model):
|
|||||||
Club, related_name="products", verbose_name=_("club"), on_delete=models.CASCADE
|
Club, related_name="products", verbose_name=_("club"), on_delete=models.CASCADE
|
||||||
)
|
)
|
||||||
limit_age = models.IntegerField(_("limit age"), default=0)
|
limit_age = models.IntegerField(_("limit age"), default=0)
|
||||||
tray = models.BooleanField(_("tray price"), default=False)
|
tray = models.BooleanField(
|
||||||
|
_("tray price"), help_text=_("Buy five, get the sixth free"), default=False
|
||||||
|
)
|
||||||
buying_groups = models.ManyToManyField(
|
buying_groups = models.ManyToManyField(
|
||||||
Group, related_name="products", verbose_name=_("buying groups"), blank=True
|
Group, related_name="products", verbose_name=_("buying groups"), blank=True
|
||||||
)
|
)
|
||||||
@@ -419,41 +412,77 @@ class Product(models.Model):
|
|||||||
pk=settings.SITH_GROUP_ACCOUNTING_ADMIN_ID
|
pk=settings.SITH_GROUP_ACCOUNTING_ADMIN_ID
|
||||||
) or user.is_in_group(pk=settings.SITH_GROUP_COUNTER_ADMIN_ID)
|
) or user.is_in_group(pk=settings.SITH_GROUP_COUNTER_ADMIN_ID)
|
||||||
|
|
||||||
def can_be_sold_to(self, user: User) -> bool:
|
|
||||||
"""Check if whether the user given in parameter has the right to buy
|
|
||||||
this product or not.
|
|
||||||
|
|
||||||
This must be not confused with the Customer.can_buy()
|
class PriceQuerySet(models.QuerySet):
|
||||||
method as the present method returns an information
|
def for_user(self, user: User) -> Self:
|
||||||
about the relation between a User and a Product,
|
age = user.age
|
||||||
whereas the other tells something about a Customer
|
if user.is_banned_alcohol:
|
||||||
(and not a user, they are not the same model).
|
age = min(age, 17)
|
||||||
|
return self.filter(
|
||||||
|
Q(is_always_shown=True, groups__in=user.all_groups)
|
||||||
|
| Q(
|
||||||
|
id=Subquery(
|
||||||
|
Price.objects.filter(
|
||||||
|
product_id=OuterRef("product_id"), groups__in=user.all_groups
|
||||||
|
)
|
||||||
|
.order_by("amount")
|
||||||
|
.values("id")[:1]
|
||||||
|
)
|
||||||
|
),
|
||||||
|
product__archived=False,
|
||||||
|
product__limit_age__lte=age,
|
||||||
|
)
|
||||||
|
|
||||||
Returns:
|
|
||||||
True if the user can buy this product else False
|
|
||||||
|
|
||||||
Warning:
|
class Price(models.Model):
|
||||||
This performs a db query, thus you can quickly have
|
amount = CurrencyField(_("amount"))
|
||||||
a N+1 queries problem if you call it in a loop.
|
product = models.ForeignKey(
|
||||||
Hopefully, you can avoid that if you prefetch the buying_groups :
|
Product,
|
||||||
|
verbose_name=_("product"),
|
||||||
|
related_name="prices",
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
)
|
||||||
|
groups = models.ManyToManyField(
|
||||||
|
Group, verbose_name=_("groups"), related_name="prices"
|
||||||
|
)
|
||||||
|
is_always_shown = models.BooleanField(
|
||||||
|
_("always show"),
|
||||||
|
help_text=_(
|
||||||
|
"If this option is enabled, "
|
||||||
|
"people will see this price and be able to pay it, "
|
||||||
|
"even if another cheaper price exists. "
|
||||||
|
"Else it will visible only if it is the cheapest available price."
|
||||||
|
),
|
||||||
|
default=False,
|
||||||
|
)
|
||||||
|
label = models.CharField(
|
||||||
|
_("label"),
|
||||||
|
help_text=_(
|
||||||
|
"A short label for easier differentiation "
|
||||||
|
"if a user can see multiple prices."
|
||||||
|
),
|
||||||
|
max_length=32,
|
||||||
|
default="",
|
||||||
|
blank=True,
|
||||||
|
)
|
||||||
|
created_at = models.DateTimeField(_("created at"), auto_now_add=True)
|
||||||
|
updated_at = models.DateTimeField(_("updated at"), auto_now=True)
|
||||||
|
|
||||||
```python
|
objects = PriceQuerySet.as_manager()
|
||||||
user = User.objects.get(username="foobar")
|
|
||||||
products = [
|
class Meta:
|
||||||
p
|
verbose_name = _("price")
|
||||||
for p in Product.objects.prefetch_related("buying_groups")
|
|
||||||
if p.can_be_sold_to(user)
|
def __str__(self):
|
||||||
]
|
if not self.label:
|
||||||
```
|
return f"{self.product.name} ({self.amount}€)"
|
||||||
"""
|
return f"{self.product.name} {self.label} ({self.amount}€)"
|
||||||
buying_groups = list(self.buying_groups.all())
|
|
||||||
if not buying_groups:
|
|
||||||
return True
|
|
||||||
return any(user.is_in_group(pk=group.id) for group in buying_groups)
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def profit(self):
|
def full_label(self):
|
||||||
return self.selling_price - self.purchase_price
|
if not self.label:
|
||||||
|
return self.product.name
|
||||||
|
return f"{self.product.name} \u2013 {self.label}"
|
||||||
|
|
||||||
|
|
||||||
class ProductFormula(models.Model):
|
class ProductFormula(models.Model):
|
||||||
@@ -474,18 +503,6 @@ 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:
|
||||||
@@ -712,35 +729,20 @@ class Counter(models.Model):
|
|||||||
# but they share the same primary key
|
# but they share the same primary key
|
||||||
return self.type == "BAR" and any(b.pk == customer.pk for b in self.barmen_list)
|
return self.type == "BAR" and any(b.pk == customer.pk for b in self.barmen_list)
|
||||||
|
|
||||||
def get_products_for(self, customer: Customer) -> list[Product]:
|
def get_prices_for(
|
||||||
"""
|
self, customer: Customer, *, order_by: Sequence[str] | None = None
|
||||||
Get all allowed products for the provided customer on this counter
|
) -> list[Price]:
|
||||||
Prices will be annotated
|
qs = (
|
||||||
"""
|
Price.objects.filter(
|
||||||
|
product__counters=self, product__product_type__isnull=False
|
||||||
products = (
|
)
|
||||||
self.products.filter(archived=False)
|
.for_user(customer.user)
|
||||||
.select_related("product_type")
|
.select_related("product", "product__product_type")
|
||||||
.prefetch_related("buying_groups")
|
.prefetch_related("groups")
|
||||||
)
|
)
|
||||||
|
if order_by:
|
||||||
# Only include age appropriate products
|
qs = qs.order_by(*order_by)
|
||||||
age = customer.user.age
|
return list(qs)
|
||||||
if customer.user.is_banned_alcohol:
|
|
||||||
age = min(age, 17)
|
|
||||||
products = products.filter(limit_age__lte=age)
|
|
||||||
|
|
||||||
# Compute special price for customer if he is a barmen on that bar
|
|
||||||
if self.customer_is_barman(customer):
|
|
||||||
products = products.annotate(price=F("special_selling_price"))
|
|
||||||
else:
|
|
||||||
products = products.annotate(price=F("selling_price"))
|
|
||||||
|
|
||||||
return [
|
|
||||||
product
|
|
||||||
for product in products.all()
|
|
||||||
if product.can_be_sold_to(customer.user)
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
class RefillingQuerySet(models.QuerySet):
|
class RefillingQuerySet(models.QuerySet):
|
||||||
@@ -1001,7 +1003,9 @@ class Selling(models.Model):
|
|||||||
event = self.product.eticket.event_title or _("Unknown event")
|
event = self.product.eticket.event_title or _("Unknown event")
|
||||||
subject = _("Eticket bought for the event %(event)s") % {"event": event}
|
subject = _("Eticket bought for the event %(event)s") % {"event": event}
|
||||||
message_html = _(
|
message_html = _(
|
||||||
"You bought an eticket for the event %(event)s.\nYou can download it directly from this link %(eticket)s.\nYou can also retrieve all your e-tickets on your account page %(url)s."
|
"You bought an eticket for the event %(event)s.\n"
|
||||||
|
"You can download it directly from this link %(eticket)s.\n"
|
||||||
|
"You can also retrieve all your e-tickets on your account page %(url)s."
|
||||||
) % {
|
) % {
|
||||||
"event": event,
|
"event": event,
|
||||||
"url": (
|
"url": (
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ from ninja import FilterLookup, FilterSchema, ModelSchema, Schema
|
|||||||
from pydantic import model_validator
|
from pydantic import model_validator
|
||||||
|
|
||||||
from club.schemas import SimpleClubSchema
|
from club.schemas import SimpleClubSchema
|
||||||
from core.schemas import GroupSchema, NonEmptyStr, SimpleUserSchema
|
from core.schemas import NonEmptyStr, SimpleUserSchema
|
||||||
from counter.models import Counter, Product, ProductType
|
from counter.models import Counter, Price, Product, ProductType
|
||||||
|
|
||||||
|
|
||||||
class CounterSchema(ModelSchema):
|
class CounterSchema(ModelSchema):
|
||||||
@@ -66,6 +66,12 @@ class SimpleProductSchema(ModelSchema):
|
|||||||
fields = ["id", "name", "code"]
|
fields = ["id", "name", "code"]
|
||||||
|
|
||||||
|
|
||||||
|
class ProductPriceSchema(ModelSchema):
|
||||||
|
class Meta:
|
||||||
|
model = Price
|
||||||
|
fields = ["amount", "groups"]
|
||||||
|
|
||||||
|
|
||||||
class ProductSchema(ModelSchema):
|
class ProductSchema(ModelSchema):
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Product
|
model = Product
|
||||||
@@ -75,13 +81,12 @@ class ProductSchema(ModelSchema):
|
|||||||
"code",
|
"code",
|
||||||
"description",
|
"description",
|
||||||
"purchase_price",
|
"purchase_price",
|
||||||
"selling_price",
|
|
||||||
"icon",
|
"icon",
|
||||||
"limit_age",
|
"limit_age",
|
||||||
"archived",
|
"archived",
|
||||||
]
|
]
|
||||||
|
|
||||||
buying_groups: list[GroupSchema]
|
prices: list[ProductPriceSchema]
|
||||||
club: SimpleClubSchema
|
club: SimpleClubSchema
|
||||||
product_type: SimpleProductTypeSchema | None
|
product_type: SimpleProductTypeSchema | None
|
||||||
url: str
|
url: str
|
||||||
|
|||||||
@@ -1,12 +1,11 @@
|
|||||||
import type { Product } from "#counter:counter/types.ts";
|
import type { CounterItem } from "#counter:counter/types";
|
||||||
|
|
||||||
export class BasketItem {
|
export class BasketItem {
|
||||||
quantity: number;
|
quantity: number;
|
||||||
product: Product;
|
product: CounterItem;
|
||||||
quantityForTrayPrice: number;
|
|
||||||
errors: string[];
|
errors: string[];
|
||||||
|
|
||||||
constructor(product: Product, quantity: number) {
|
constructor(product: CounterItem, quantity: number) {
|
||||||
this.quantity = quantity;
|
this.quantity = quantity;
|
||||||
this.product = product;
|
this.product = product;
|
||||||
this.errors = [];
|
this.errors = [];
|
||||||
@@ -20,6 +19,6 @@ export class BasketItem {
|
|||||||
}
|
}
|
||||||
|
|
||||||
sum(): number {
|
sum(): number {
|
||||||
return (this.quantity - this.getBonusQuantity()) * this.product.price;
|
return (this.quantity - this.getBonusQuantity()) * this.product.price.amount;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import { AlertMessage } from "#core:utils/alert-message.ts";
|
import { AlertMessage } from "#core:utils/alert-message";
|
||||||
import { BasketItem } from "#counter:counter/basket.ts";
|
import { BasketItem } from "#counter:counter/basket";
|
||||||
import type {
|
import type {
|
||||||
CounterConfig,
|
CounterConfig,
|
||||||
|
CounterItem,
|
||||||
ErrorMessage,
|
ErrorMessage,
|
||||||
ProductFormula,
|
ProductFormula,
|
||||||
} from "#counter:counter/types.ts";
|
} from "#counter:counter/types";
|
||||||
import type { CounterProductSelect } from "./components/counter-product-select-index.ts";
|
import type { CounterProductSelect } from "./components/counter-product-select-index";
|
||||||
|
|
||||||
document.addEventListener("alpine:init", () => {
|
document.addEventListener("alpine:init", () => {
|
||||||
Alpine.data("counter", (config: CounterConfig) => ({
|
Alpine.data("counter", (config: CounterConfig) => ({
|
||||||
@@ -63,8 +64,10 @@ 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.keys(this.basket).map((i: string) => Number.parseInt(i)),
|
Object.values(this.basket).map((item: BasketItem) => item.product.productId),
|
||||||
);
|
);
|
||||||
const formula: ProductFormula = config.formulas.find((f: ProductFormula) => {
|
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));
|
||||||
@@ -72,22 +75,29 @@ 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 = product.toString();
|
const key = Object.entries(this.basket).find(
|
||||||
|
([_, 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: config.products[formula.result.toString()].name },
|
{ formula: result.name },
|
||||||
true,
|
true,
|
||||||
),
|
),
|
||||||
{ success: true },
|
{ success: true },
|
||||||
);
|
);
|
||||||
this.addToBasket(formula.result.toString(), 1);
|
|
||||||
},
|
},
|
||||||
|
|
||||||
getBasketSize() {
|
getBasketSize() {
|
||||||
|
|||||||
@@ -1,13 +1,9 @@
|
|||||||
import { showSaveFilePicker } from "native-file-system-adapter";
|
import { showSaveFilePicker } from "native-file-system-adapter";
|
||||||
import type TomSelect from "tom-select";
|
import type TomSelect from "tom-select";
|
||||||
import { paginated } from "#core:utils/api.ts";
|
import { paginated } from "#core:utils/api";
|
||||||
import { csv } from "#core:utils/csv.ts";
|
import { csv } from "#core:utils/csv";
|
||||||
import {
|
import { getCurrentUrlParams, History, updateQueryString } from "#core:utils/history";
|
||||||
getCurrentUrlParams,
|
import type { NestedKeyOf } from "#core:utils/types";
|
||||||
History,
|
|
||||||
updateQueryString,
|
|
||||||
} from "#core:utils/history.ts";
|
|
||||||
import type { NestedKeyOf } from "#core:utils/types.ts";
|
|
||||||
import {
|
import {
|
||||||
type ProductSchema,
|
type ProductSchema,
|
||||||
type ProductSearchProductsDetailedData,
|
type ProductSearchProductsDetailedData,
|
||||||
@@ -20,6 +16,9 @@ type GroupedProducts = Record<ProductType, ProductSchema[]>;
|
|||||||
const defaultPageSize = 100;
|
const defaultPageSize = 100;
|
||||||
const defaultPage = 1;
|
const defaultPage = 1;
|
||||||
|
|
||||||
|
// biome-ignore lint/style/useNamingConvention: api is snake case
|
||||||
|
type ProductWithPriceSchema = ProductSchema & { selling_price: string };
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Keys of the properties to include in the CSV.
|
* Keys of the properties to include in the CSV.
|
||||||
*/
|
*/
|
||||||
@@ -34,7 +33,7 @@ const csvColumns = [
|
|||||||
"purchase_price",
|
"purchase_price",
|
||||||
"selling_price",
|
"selling_price",
|
||||||
"archived",
|
"archived",
|
||||||
] as NestedKeyOf<ProductSchema>[];
|
] as NestedKeyOf<ProductWithPriceSchema>[];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Title of the csv columns.
|
* Title of the csv columns.
|
||||||
@@ -175,7 +174,16 @@ document.addEventListener("alpine:init", () => {
|
|||||||
this.nbPages > 1
|
this.nbPages > 1
|
||||||
? await paginated(productSearchProductsDetailed, this.getQueryParams())
|
? await paginated(productSearchProductsDetailed, this.getQueryParams())
|
||||||
: Object.values<ProductSchema[]>(this.products).flat();
|
: Object.values<ProductSchema[]>(this.products).flat();
|
||||||
const content = csv.stringify(products, {
|
// CSV cannot represent nested data
|
||||||
|
// so we create a row for each price of each product.
|
||||||
|
const productsWithPrice: ProductWithPriceSchema[] = products.flatMap(
|
||||||
|
(product: ProductSchema) =>
|
||||||
|
product.prices.map((price) =>
|
||||||
|
// biome-ignore lint/style/useNamingConvention: API is snake_case
|
||||||
|
Object.assign(product, { selling_price: price.amount }),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const content = csv.stringify(productsWithPrice, {
|
||||||
columns: csvColumns,
|
columns: csvColumns,
|
||||||
titleRow: csvColumnTitles,
|
titleRow: csvColumnTitles,
|
||||||
});
|
});
|
||||||
|
|||||||
15
counter/static/bundled/counter/types.d.ts
vendored
15
counter/static/bundled/counter/types.d.ts
vendored
@@ -2,7 +2,7 @@ export type ErrorMessage = string;
|
|||||||
|
|
||||||
export interface InitialFormData {
|
export interface InitialFormData {
|
||||||
/* Used to refill the form when the backend raises an error */
|
/* Used to refill the form when the backend raises an error */
|
||||||
id?: keyof Record<string, Product>;
|
id?: keyof Record<string, CounterItem>;
|
||||||
quantity?: number;
|
quantity?: number;
|
||||||
errors?: string[];
|
errors?: string[];
|
||||||
}
|
}
|
||||||
@@ -15,17 +15,22 @@ export interface ProductFormula {
|
|||||||
export interface CounterConfig {
|
export interface CounterConfig {
|
||||||
customerBalance: number;
|
customerBalance: number;
|
||||||
customerId: number;
|
customerId: number;
|
||||||
products: Record<string, Product>;
|
products: Record<string, CounterItem>;
|
||||||
formulas: ProductFormula[];
|
formulas: ProductFormula[];
|
||||||
formInitial: InitialFormData[];
|
formInitial: InitialFormData[];
|
||||||
cancelUrl: string;
|
cancelUrl: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Product {
|
interface Price {
|
||||||
id: string;
|
id: number;
|
||||||
|
amount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CounterItem {
|
||||||
|
productId: number;
|
||||||
|
price: Price;
|
||||||
code: string;
|
code: string;
|
||||||
name: string;
|
name: string;
|
||||||
price: number;
|
|
||||||
hasTrayPrice: boolean;
|
hasTrayPrice: boolean;
|
||||||
quantityForTrayPrice: number;
|
quantityForTrayPrice: number;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,10 +6,10 @@
|
|||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block additional_css %}
|
{% block additional_css %}
|
||||||
<link rel="stylesheet" type="text/css" href="{{ static('counter/css/counter-click.scss') }}" defer></link>
|
<link rel="stylesheet" href="{{ static('counter/css/counter-click.scss') }}">
|
||||||
<link rel="stylesheet" type="text/css" href="{{ static('bundled/core/components/ajax-select-index.css') }}" defer></link>
|
<link rel="stylesheet" href="{{ static('bundled/core/components/ajax-select-index.css') }}">
|
||||||
<link rel="stylesheet" type="text/css" href="{{ static('core/components/ajax-select.scss') }}" defer></link>
|
<link rel="stylesheet" href="{{ static('core/components/ajax-select.scss') }}">
|
||||||
<link rel="stylesheet" type="text/css" href="{{ static('core/components/tabs.scss') }}" defer></link>
|
<link rel="stylesheet" href="{{ static('core/components/tabs.scss') }}">
|
||||||
<link rel="stylesheet" href="{{ static("core/components/card.scss") }}">
|
<link rel="stylesheet" href="{{ static("core/components/card.scss") }}">
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
@@ -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 in categories.keys() -%}
|
{%- for category, prices in categories.items() -%}
|
||||||
<optgroup label="{{ category }}">
|
<optgroup label="{{ category }}">
|
||||||
{%- for product in categories[category] -%}
|
{%- for price in prices -%}
|
||||||
<option value="{{ product.id }}">{{ product }}</option>
|
<option value="{{ price.id }}">{{ price.full_label }}</option>
|
||||||
{%- endfor -%}
|
{%- endfor -%}
|
||||||
</optgroup>
|
</optgroup>
|
||||||
{%- endfor -%}
|
{%- endfor -%}
|
||||||
@@ -103,24 +103,25 @@
|
|||||||
</div>
|
</div>
|
||||||
<ul>
|
<ul>
|
||||||
<li x-show="getBasketSize() === 0">{% trans %}This basket is empty{% endtrans %}</li>
|
<li x-show="getBasketSize() === 0">{% trans %}This basket is empty{% endtrans %}</li>
|
||||||
<template x-for="(item, index) in Object.values(basket)" :key="item.product.id">
|
<template x-for="(item, index) in Object.values(basket)" :key="item.product.price.id">
|
||||||
<li>
|
<li>
|
||||||
<template x-for="error in item.errors">
|
<template x-for="error in item.errors">
|
||||||
<div class="alert alert-red" x-text="error">
|
<div class="alert alert-red" x-text="error">
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<button @click.prevent="addToBasket(item.product.id, -1)">-</button>
|
<button @click.prevent="addToBasket(item.product.price.id, -1)">-</button>
|
||||||
<span class="quantity" x-text="item.quantity"></span>
|
<span class="quantity" x-text="item.quantity"></span>
|
||||||
<button @click.prevent="addToBasket(item.product.id, 1)">+</button>
|
<button @click.prevent="addToBasket(item.product.price.id, 1)">+</button>
|
||||||
|
|
||||||
<span x-text="item.product.name"></span> :
|
<span x-text="item.product.name"></span> :
|
||||||
<span x-text="item.sum().toLocaleString(undefined, { minimumFractionDigits: 2 })">€</span>
|
<span x-text="item.sum().toLocaleString(undefined, { minimumFractionDigits: 2 })">€</span>
|
||||||
<span x-show="item.getBonusQuantity() > 0" x-text="`${item.getBonusQuantity()} x P`"></span>
|
<span x-show="item.getBonusQuantity() > 0"
|
||||||
|
x-text="`${item.getBonusQuantity()} x P`"></span>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
class="remove-item"
|
class="remove-item"
|
||||||
@click.prevent="removeFromBasket(item.product.id)"
|
@click.prevent="removeFromBasket(item.product.price.id)"
|
||||||
><i class="fa fa-trash-can delete-action"></i></button>
|
><i class="fa fa-trash-can delete-action"></i></button>
|
||||||
|
|
||||||
<input
|
<input
|
||||||
@@ -133,9 +134,9 @@
|
|||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
type="hidden"
|
type="hidden"
|
||||||
:value="item.product.id"
|
:value="item.product.price.id"
|
||||||
:id="`id_form-${index}-id`"
|
:id="`id_form-${index}-price_id`"
|
||||||
:name="`form-${index}-id`"
|
:name="`form-${index}-price_id`"
|
||||||
required
|
required
|
||||||
readonly
|
readonly
|
||||||
>
|
>
|
||||||
@@ -201,30 +202,30 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="products">
|
<div id="products">
|
||||||
{% if not products %}
|
{% if not prices %}
|
||||||
<div class="alert alert-red">
|
<div class="alert alert-red">
|
||||||
{% trans %}No products available on this counter for this user{% endtrans %}
|
{% trans %}No products available on this counter for this user{% endtrans %}
|
||||||
</div>
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
<ui-tab-group>
|
<ui-tab-group>
|
||||||
{% for category in categories.keys() -%}
|
{% for category, prices in categories.items() -%}
|
||||||
<ui-tab title="{{ category }}" {% if loop.index == 1 -%}active{%- endif -%}>
|
<ui-tab title="{{ category }}" {% if loop.index == 1 -%}active{%- endif -%}>
|
||||||
<h5 class="margin-bottom">{{ category }}</h5>
|
<h5 class="margin-bottom">{{ category }}</h5>
|
||||||
<div class="row gap-2x">
|
<div class="row gap-2x">
|
||||||
{% for product in categories[category] -%}
|
{% for price in prices -%}
|
||||||
<button class="card shadow" @click="addToBasket('{{ product.id }}', 1)">
|
<button class="card shadow" @click="addToBasket('{{ price.id }}', 1)">
|
||||||
<img
|
<img
|
||||||
class="card-image"
|
class="card-image"
|
||||||
alt="image de {{ product.name }}"
|
alt="image de {{ price.full_label }}"
|
||||||
{% if product.icon %}
|
{% if price.product.icon %}
|
||||||
src="{{ product.icon.url }}"
|
src="{{ price.product.icon.url }}"
|
||||||
{% else %}
|
{% else %}
|
||||||
src="{{ static('core/img/na.gif') }}"
|
src="{{ static('core/img/na.gif') }}"
|
||||||
{% endif %}
|
{% endif %}
|
||||||
/>
|
/>
|
||||||
<span class="card-content">
|
<span class="card-content">
|
||||||
<strong class="card-title">{{ product.name }}</strong>
|
<strong class="card-title">{{ price.full_label }}</strong>
|
||||||
<p>{{ product.price }} €<br>{{ product.code }}</p>
|
<p>{{ price.amount }} €<br>{{ price.product.code }}</p>
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
{%- endfor %}
|
{%- endfor %}
|
||||||
@@ -241,13 +242,14 @@
|
|||||||
{{ super() }}
|
{{ super() }}
|
||||||
<script>
|
<script>
|
||||||
const products = {
|
const products = {
|
||||||
{%- for product in products -%}
|
{%- for price in prices -%}
|
||||||
{{ product.id }}: {
|
{{ price.id }}: {
|
||||||
id: "{{ product.id }}",
|
productId: {{ price.product_id }},
|
||||||
name: "{{ product.name }}",
|
price: { id: "{{ price.id }}", amount: {{ price.amount }} },
|
||||||
price: {{ product.price }},
|
code: "{{ price.product.code }}",
|
||||||
hasTrayPrice: {{ product.tray | tojson }},
|
name: "{{ price.full_label }}",
|
||||||
quantityForTrayPrice: {{ product.QUANTITY_FOR_TRAY_PRICE }},
|
hasTrayPrice: {{ price.product.tray | tojson }},
|
||||||
|
quantityForTrayPrice: {{ price.product.QUANTITY_FOR_TRAY_PRICE }},
|
||||||
},
|
},
|
||||||
{%- endfor -%}
|
{%- endfor -%}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -49,14 +49,10 @@
|
|||||||
<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.code }} ({{ p.selling_price }} €)</i>
|
<i>{{ p.name }} ({{ p.code }})</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
|
||||||
|
|||||||
@@ -1,5 +1,87 @@
|
|||||||
{% extends "core/base.jinja" %}
|
{% extends "core/base.jinja" %}
|
||||||
|
|
||||||
|
{% block additional_js %}
|
||||||
|
<script type="module" src="{{ static("bundled/core/dynamic-formset-index.ts") }}"></script>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
|
||||||
|
{% macro action_form(form) %}
|
||||||
|
<fieldset x-data="{action: '{{ form.task.initial }}'}">
|
||||||
|
{{ form.non_field_errors() }}
|
||||||
|
<div class="row gap-2x margin-bottom">
|
||||||
|
<div>
|
||||||
|
{{ form.task.errors }}
|
||||||
|
{{ form.task.label_tag() }}
|
||||||
|
{{ form.task|add_attr("x-model=action") }}
|
||||||
|
</div>
|
||||||
|
<div>{{ form.trigger_at.as_field_group() }}</div>
|
||||||
|
</div>
|
||||||
|
<div x-show="action==='counter.tasks.change_counters'" class="margin-bottom">
|
||||||
|
{{ form.counters.as_field_group() }}
|
||||||
|
</div>
|
||||||
|
{%- if form.DELETE -%}
|
||||||
|
<div class="row gap">
|
||||||
|
{{ form.DELETE.as_field_group() }}
|
||||||
|
</div>
|
||||||
|
{%- else -%}
|
||||||
|
<button
|
||||||
|
class="btn btn-grey"
|
||||||
|
@click.prevent="removeForm($event.target.closest('fieldset'))"
|
||||||
|
>
|
||||||
|
<i class="fa fa-minus"></i>{% trans %}Remove this action{% endtrans %}
|
||||||
|
</button>
|
||||||
|
{%- endif -%}
|
||||||
|
{%- for field in form.hidden_fields() -%}
|
||||||
|
{{ field }}
|
||||||
|
{%- endfor -%}
|
||||||
|
<hr />
|
||||||
|
</fieldset>
|
||||||
|
{% endmacro %}
|
||||||
|
|
||||||
|
|
||||||
|
{% macro price_form(form) %}
|
||||||
|
<fieldset>
|
||||||
|
{{ form.non_field_errors() }}
|
||||||
|
<div class="form-group row gap-2x">
|
||||||
|
<div>{{ form.amount.as_field_group() }}</div>
|
||||||
|
<div>
|
||||||
|
{{ form.label.errors }}
|
||||||
|
<label for="{{ form.label.id_for_label }}">{{ form.label.label }}</label>
|
||||||
|
{{ form.label }}
|
||||||
|
<span class="helptext">{{ form.label.help_text }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="grow">{{ form.groups.as_field_group() }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<div>
|
||||||
|
{{ form.is_always_shown.errors }}
|
||||||
|
<div class="row gap">
|
||||||
|
{{ form.is_always_shown }}
|
||||||
|
<label for="{{ form.is_always_shown.id_for_label }}">{{ form.is_always_shown.label }}</label>
|
||||||
|
</div>
|
||||||
|
<span class="helptext">{{ form.is_always_shown.help_text }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{%- if form.DELETE -%}
|
||||||
|
<div class="form-group row gap">
|
||||||
|
{{ form.DELETE.as_field_group() }}
|
||||||
|
</div>
|
||||||
|
{%- else -%}
|
||||||
|
<br>
|
||||||
|
<button
|
||||||
|
class="btn btn-grey"
|
||||||
|
@click.prevent="removeForm($event.target.closest('fieldset').parentElement)"
|
||||||
|
>
|
||||||
|
<i class="fa fa-minus"></i> {% trans %}Remove price{% endtrans %}
|
||||||
|
</button>
|
||||||
|
{%- endif -%}
|
||||||
|
{%- for field in form.hidden_fields() -%}
|
||||||
|
{{ field }}
|
||||||
|
{%- endfor -%}
|
||||||
|
</fieldset>
|
||||||
|
<hr class="margin-bottom">
|
||||||
|
{% endmacro %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
{% if object %}
|
{% if object %}
|
||||||
<h2>{% trans name=object %}Edit product {{ name }}{% endtrans %}</h2>
|
<h2>{% trans name=object %}Edit product {{ name }}{% endtrans %}</h2>
|
||||||
@@ -10,7 +92,54 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
<form method="post" enctype="multipart/form-data">
|
<form method="post" enctype="multipart/form-data">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
{{ form.as_p() }}
|
{{ form.non_field_errors() }}
|
||||||
|
<fieldset class="row gap">
|
||||||
|
<div>{{ form.name.as_field_group() }}</div>
|
||||||
|
<div>{{ form.code.as_field_group() }}</div>
|
||||||
|
</fieldset>
|
||||||
|
<fieldset>
|
||||||
|
<div class="form-group">{{ form.description.as_field_group() }}</div>
|
||||||
|
</fieldset>
|
||||||
|
<fieldset class="row gap">
|
||||||
|
<div>{{ form.club.as_field_group() }}</div>
|
||||||
|
<div>{{ form.product_type.as_field_group() }}</div>
|
||||||
|
</fieldset>
|
||||||
|
<fieldset><div>{{ form.icon.as_field_group() }}</div></fieldset>
|
||||||
|
<fieldset><div>{{ form.purchase_price.as_field_group() }}</div></fieldset>
|
||||||
|
<fieldset>
|
||||||
|
<div>{{ form.limit_age.as_field_group() }}</div>
|
||||||
|
</fieldset>
|
||||||
|
<fieldset>
|
||||||
|
<div class="row gap">
|
||||||
|
{{ form.tray }}
|
||||||
|
<div>
|
||||||
|
{{ form.tray.label_tag() }}
|
||||||
|
<span class="helptext">{{ form.tray.help_text }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
<fieldset><div>{{ form.counters.as_field_group() }}</div></fieldset>
|
||||||
|
|
||||||
|
<h3 class="margin-bottom">{% trans %}Prices{% endtrans %}</h3>
|
||||||
|
|
||||||
|
<div x-data="dynamicFormSet({ prefix: '{{ form.price_formset.prefix }}' })">
|
||||||
|
{{ form.price_formset.management_form }}
|
||||||
|
<div x-ref="formContainer">
|
||||||
|
{%- for form in form.price_formset.forms -%}
|
||||||
|
<div>
|
||||||
|
{{ price_form(form) }}
|
||||||
|
</div>
|
||||||
|
{%- endfor -%}
|
||||||
|
</div>
|
||||||
|
<template x-ref="formTemplate">
|
||||||
|
<div>
|
||||||
|
{{ price_form(form.price_formset.empty_form) }}
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<button class="btn btn-grey" @click.prevent="addForm()">
|
||||||
|
<i class="fa fa-plus"></i> {% trans %}Add a price{% endtrans %}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<br />
|
<br />
|
||||||
|
|
||||||
@@ -25,34 +154,21 @@
|
|||||||
</em>
|
</em>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{{ form.action_formset.management_form }}
|
<div x-data="dynamicFormSet({ prefix: '{{ form.action_formset.prefix }}' })" class="margin-bottom">
|
||||||
{%- for action_form in form.action_formset.forms -%}
|
{{ form.action_formset.management_form }}
|
||||||
<fieldset x-data="{action: '{{ action_form.task.initial }}'}">
|
<div x-ref="formContainer">
|
||||||
{{ action_form.non_field_errors() }}
|
{%- for f in form.action_formset.forms -%}
|
||||||
<div class="row gap-2x margin-bottom">
|
{{ action_form(f) }}
|
||||||
<div>
|
|
||||||
{{ action_form.task.errors }}
|
|
||||||
{{ action_form.task.label_tag() }}
|
|
||||||
{{ action_form.task|add_attr("x-model=action") }}
|
|
||||||
</div>
|
|
||||||
<div>{{ action_form.trigger_at.as_field_group() }}</div>
|
|
||||||
</div>
|
|
||||||
<div x-show="action==='counter.tasks.change_counters'" class="margin-bottom">
|
|
||||||
{{ action_form.counters.as_field_group() }}
|
|
||||||
</div>
|
|
||||||
{%- if action_form.DELETE -%}
|
|
||||||
<div class="row gap">
|
|
||||||
{{ action_form.DELETE.as_field_group() }}
|
|
||||||
</div>
|
|
||||||
{%- endif -%}
|
|
||||||
{%- for field in action_form.hidden_fields() -%}
|
|
||||||
{{ field }}
|
|
||||||
{%- endfor -%}
|
{%- endfor -%}
|
||||||
</fieldset>
|
</div>
|
||||||
{%- if not loop.last -%}
|
<template x-ref="formTemplate">
|
||||||
<hr class="margin-bottom">
|
{{ action_form(form.action_formset.empty_form) }}
|
||||||
{%- endif -%}
|
</template>
|
||||||
{%- endfor -%}
|
<button @click.prevent="addForm()" class="btn btn-grey">
|
||||||
<p><input type="submit" value="{% trans %}Save{% endtrans %}" /></p>
|
<i class="fa fa-plus"></i>{% trans %}Add action{% endtrans %}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="row gap margin-bottom">{{ form.archived.as_field_group() }}</div>
|
||||||
|
<p><input class="btn btn-blue" type="submit" value="{% trans %}Save{% endtrans %}" /></p>
|
||||||
</form>
|
</form>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -108,7 +108,7 @@
|
|||||||
</template>
|
</template>
|
||||||
<span class="card-content">
|
<span class="card-content">
|
||||||
<strong class="card-title" x-text="`${p.name} (${p.code})`"></strong>
|
<strong class="card-title" x-text="`${p.name} (${p.code})`"></strong>
|
||||||
<p x-text="`${p.selling_price} €`"></p>
|
<p x-text="`${p.prices.map((p) => p.amount).join(' – ')} €`"></p>
|
||||||
</span>
|
</span>
|
||||||
</a>
|
</a>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ from counter.forms import (
|
|||||||
ScheduledProductActionForm,
|
ScheduledProductActionForm,
|
||||||
ScheduledProductActionFormSet,
|
ScheduledProductActionFormSet,
|
||||||
)
|
)
|
||||||
from counter.models import Product, ScheduledProductAction
|
from counter.models import Product, ProductType, ScheduledProductAction
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
@@ -47,20 +47,22 @@ def test_create_actions_alongside_product():
|
|||||||
form = ProductForm(
|
form = ProductForm(
|
||||||
data={
|
data={
|
||||||
"name": "foo",
|
"name": "foo",
|
||||||
"description": "bar",
|
"product_type": ProductType.objects.first(),
|
||||||
"product_type": product.product_type_id,
|
|
||||||
"club": product.club_id,
|
"club": product.club_id,
|
||||||
"code": "FOO",
|
"code": "FOO",
|
||||||
"purchase_price": 1.0,
|
"purchase_price": 1.0,
|
||||||
"selling_price": 1.0,
|
"selling_price": 1.0,
|
||||||
"special_selling_price": 1.0,
|
"special_selling_price": 1.0,
|
||||||
"limit_age": 0,
|
"limit_age": 0,
|
||||||
"form-TOTAL_FORMS": "2",
|
"price-TOTAL_FORMS": "0",
|
||||||
"form-INITIAL_FORMS": "0",
|
"price-INITIAL_FORMS": "0",
|
||||||
"form-0-task": "counter.tasks.archive_product",
|
"action-TOTAL_FORMS": "1",
|
||||||
"form-0-trigger_at": trigger_at,
|
"action-INITIAL_FORMS": "0",
|
||||||
|
"action-0-task": "counter.tasks.archive_product",
|
||||||
|
"action-0-trigger_at": trigger_at,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
form.is_valid()
|
||||||
assert form.is_valid()
|
assert form.is_valid()
|
||||||
product = form.save()
|
product = form.save()
|
||||||
action = ScheduledProductAction.objects.last()
|
action = ScheduledProductAction.objects.last()
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ import pytest
|
|||||||
from dateutil.relativedelta import relativedelta
|
from dateutil.relativedelta import relativedelta
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.contrib.auth.models import Permission, make_password
|
from django.contrib.auth.models import Permission, make_password
|
||||||
from django.core.cache import cache
|
|
||||||
from django.http import HttpResponse
|
from django.http import HttpResponse
|
||||||
from django.shortcuts import resolve_url
|
from django.shortcuts import resolve_url
|
||||||
from django.test import Client, TestCase
|
from django.test import Client, TestCase
|
||||||
@@ -34,13 +33,13 @@ from pytest_django.asserts import assertRedirects
|
|||||||
|
|
||||||
from club.models import Membership
|
from club.models import Membership
|
||||||
from core.baker_recipes import board_user, subscriber_user, very_old_subscriber_user
|
from core.baker_recipes import board_user, subscriber_user, very_old_subscriber_user
|
||||||
from core.models import BanGroup, User
|
from core.models import BanGroup, Group, User
|
||||||
from counter.baker_recipes import product_recipe, sale_recipe
|
from counter.baker_recipes import price_recipe, product_recipe, sale_recipe
|
||||||
from counter.models import (
|
from counter.models import (
|
||||||
Counter,
|
Counter,
|
||||||
Customer,
|
Customer,
|
||||||
Permanency,
|
Permanency,
|
||||||
Product,
|
ProductType,
|
||||||
Refilling,
|
Refilling,
|
||||||
ReturnableProduct,
|
ReturnableProduct,
|
||||||
Selling,
|
Selling,
|
||||||
@@ -204,7 +203,7 @@ class TestRefilling(TestFullClickBase):
|
|||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class BasketItem:
|
class BasketItem:
|
||||||
id: int | None = None
|
price_id: int | None = None
|
||||||
quantity: int | None = None
|
quantity: int | None = None
|
||||||
|
|
||||||
def to_form(self, index: int) -> dict[str, str]:
|
def to_form(self, index: int) -> dict[str, str]:
|
||||||
@@ -236,38 +235,59 @@ class TestCounterClick(TestFullClickBase):
|
|||||||
cls.banned_counter_customer.ban_groups.add(
|
cls.banned_counter_customer.ban_groups.add(
|
||||||
BanGroup.objects.get(pk=settings.SITH_GROUP_BANNED_COUNTER_ID)
|
BanGroup.objects.get(pk=settings.SITH_GROUP_BANNED_COUNTER_ID)
|
||||||
)
|
)
|
||||||
|
subscriber_group = Group.objects.get(id=settings.SITH_GROUP_SUBSCRIBERS_ID)
|
||||||
|
old_subscriber_group = Group.objects.get(
|
||||||
|
id=settings.SITH_GROUP_OLD_SUBSCRIBERS_ID
|
||||||
|
)
|
||||||
|
_product_recipe = product_recipe.extend(product_type=baker.make(ProductType))
|
||||||
|
|
||||||
cls.gift = product_recipe.make(
|
cls.gift = price_recipe.make(
|
||||||
selling_price="-1.5",
|
amount=-1.5, groups=[subscriber_group], product=_product_recipe.make()
|
||||||
special_selling_price="-1.5",
|
|
||||||
)
|
)
|
||||||
cls.beer = product_recipe.make(
|
cls.beer = price_recipe.make(
|
||||||
limit_age=18, selling_price=1.5, special_selling_price=1
|
groups=[subscriber_group],
|
||||||
|
amount=1.5,
|
||||||
|
product=_product_recipe.make(limit_age=18),
|
||||||
)
|
)
|
||||||
cls.beer_tap = product_recipe.make(
|
cls.beer_tap = price_recipe.make(
|
||||||
limit_age=18, tray=True, selling_price=1.5, special_selling_price=1
|
groups=[subscriber_group],
|
||||||
|
amount=1.5,
|
||||||
|
product=_product_recipe.make(limit_age=18, tray=True),
|
||||||
)
|
)
|
||||||
cls.snack = product_recipe.make(
|
cls.snack = price_recipe.make(
|
||||||
limit_age=0, selling_price=1.5, special_selling_price=1
|
groups=[subscriber_group, old_subscriber_group],
|
||||||
|
amount=1.5,
|
||||||
|
product=_product_recipe.make(limit_age=0),
|
||||||
)
|
)
|
||||||
cls.stamps = product_recipe.make(
|
cls.stamps = price_recipe.make(
|
||||||
limit_age=0, selling_price=1.5, special_selling_price=1
|
groups=[subscriber_group],
|
||||||
|
amount=1.5,
|
||||||
|
product=_product_recipe.make(limit_age=0),
|
||||||
)
|
)
|
||||||
ReturnableProduct.objects.all().delete()
|
ReturnableProduct.objects.all().delete()
|
||||||
cls.cons = baker.make(Product, selling_price=1)
|
cls.cons = price_recipe.make(
|
||||||
cls.dcons = baker.make(Product, selling_price=-1)
|
amount=1, groups=[subscriber_group], product=_product_recipe.make()
|
||||||
|
)
|
||||||
|
cls.dcons = price_recipe.make(
|
||||||
|
amount=-1, groups=[subscriber_group], product=_product_recipe.make()
|
||||||
|
)
|
||||||
baker.make(
|
baker.make(
|
||||||
ReturnableProduct,
|
ReturnableProduct,
|
||||||
product=cls.cons,
|
product=cls.cons.product,
|
||||||
returned_product=cls.dcons,
|
returned_product=cls.dcons.product,
|
||||||
max_return=3,
|
max_return=3,
|
||||||
)
|
)
|
||||||
|
|
||||||
cls.counter.products.add(
|
cls.counter.products.add(
|
||||||
cls.gift, cls.beer, cls.beer_tap, cls.snack, cls.cons, cls.dcons
|
cls.gift.product,
|
||||||
|
cls.beer.product,
|
||||||
|
cls.beer_tap.product,
|
||||||
|
cls.snack.product,
|
||||||
|
cls.cons.product,
|
||||||
|
cls.dcons.product,
|
||||||
)
|
)
|
||||||
cls.other_counter.products.add(cls.snack)
|
cls.other_counter.products.add(cls.snack.product)
|
||||||
cls.club_counter.products.add(cls.stamps)
|
cls.club_counter.products.add(cls.stamps.product)
|
||||||
|
|
||||||
def login_in_bar(self, barmen: User | None = None):
|
def login_in_bar(self, barmen: User | None = None):
|
||||||
used_barman = barmen if barmen is not None else self.barmen
|
used_barman = barmen if barmen is not None else self.barmen
|
||||||
@@ -285,10 +305,7 @@ class TestCounterClick(TestFullClickBase):
|
|||||||
) -> HttpResponse:
|
) -> HttpResponse:
|
||||||
used_counter = counter if counter is not None else self.counter
|
used_counter = counter if counter is not None else self.counter
|
||||||
used_client = client if client is not None else self.client
|
used_client = client if client is not None else self.client
|
||||||
data = {
|
data = {"form-TOTAL_FORMS": str(len(basket)), "form-INITIAL_FORMS": "0"}
|
||||||
"form-TOTAL_FORMS": str(len(basket)),
|
|
||||||
"form-INITIAL_FORMS": "0",
|
|
||||||
}
|
|
||||||
for index, item in enumerate(basket):
|
for index, item in enumerate(basket):
|
||||||
data.update(item.to_form(index))
|
data.update(item.to_form(index))
|
||||||
return used_client.post(
|
return used_client.post(
|
||||||
@@ -331,32 +348,22 @@ class TestCounterClick(TestFullClickBase):
|
|||||||
res = self.submit_basket(
|
res = self.submit_basket(
|
||||||
self.customer, [BasketItem(self.beer.id, 2), BasketItem(self.snack.id, 1)]
|
self.customer, [BasketItem(self.beer.id, 2), BasketItem(self.snack.id, 1)]
|
||||||
)
|
)
|
||||||
assert res.status_code == 302
|
self.assertRedirects(res, self.counter.get_absolute_url())
|
||||||
|
|
||||||
assert self.updated_amount(self.customer) == Decimal("5.5")
|
assert self.updated_amount(self.customer) == Decimal("5.5")
|
||||||
|
|
||||||
# Test barmen special price
|
|
||||||
|
|
||||||
force_refill_user(self.barmen, 10)
|
|
||||||
|
|
||||||
assert (
|
|
||||||
self.submit_basket(self.barmen, [BasketItem(self.beer.id, 1)])
|
|
||||||
).status_code == 302
|
|
||||||
|
|
||||||
assert self.updated_amount(self.barmen) == Decimal(9)
|
|
||||||
|
|
||||||
def test_click_tray_price(self):
|
def test_click_tray_price(self):
|
||||||
force_refill_user(self.customer, 20)
|
force_refill_user(self.customer, 20)
|
||||||
self.login_in_bar(self.barmen)
|
self.login_in_bar(self.barmen)
|
||||||
|
|
||||||
# Not applying tray price
|
# Not applying tray price
|
||||||
res = self.submit_basket(self.customer, [BasketItem(self.beer_tap.id, 2)])
|
res = self.submit_basket(self.customer, [BasketItem(self.beer_tap.id, 2)])
|
||||||
assert res.status_code == 302
|
self.assertRedirects(res, self.counter.get_absolute_url())
|
||||||
assert self.updated_amount(self.customer) == Decimal(17)
|
assert self.updated_amount(self.customer) == Decimal(17)
|
||||||
|
|
||||||
# Applying tray price
|
# Applying tray price
|
||||||
res = self.submit_basket(self.customer, [BasketItem(self.beer_tap.id, 7)])
|
res = self.submit_basket(self.customer, [BasketItem(self.beer_tap.id, 7)])
|
||||||
assert res.status_code == 302
|
self.assertRedirects(res, self.counter.get_absolute_url())
|
||||||
assert self.updated_amount(self.customer) == Decimal(8)
|
assert self.updated_amount(self.customer) == Decimal(8)
|
||||||
|
|
||||||
def test_click_alcool_unauthorized(self):
|
def test_click_alcool_unauthorized(self):
|
||||||
@@ -477,7 +484,8 @@ class TestCounterClick(TestFullClickBase):
|
|||||||
BasketItem(None, 1),
|
BasketItem(None, 1),
|
||||||
BasketItem(self.beer.id, None),
|
BasketItem(self.beer.id, None),
|
||||||
]:
|
]:
|
||||||
assert self.submit_basket(self.customer, [item]).status_code == 200
|
res = self.submit_basket(self.customer, [item])
|
||||||
|
assert res.status_code == 200
|
||||||
assert self.updated_amount(self.customer) == Decimal(10)
|
assert self.updated_amount(self.customer) == Decimal(10)
|
||||||
|
|
||||||
def test_click_not_enough_money(self):
|
def test_click_not_enough_money(self):
|
||||||
@@ -506,29 +514,30 @@ class TestCounterClick(TestFullClickBase):
|
|||||||
res = self.submit_basket(
|
res = self.submit_basket(
|
||||||
self.customer, [BasketItem(self.beer.id, 1), BasketItem(self.gift.id, 1)]
|
self.customer, [BasketItem(self.beer.id, 1), BasketItem(self.gift.id, 1)]
|
||||||
)
|
)
|
||||||
assert res.status_code == 302
|
self.assertRedirects(res, self.counter.get_absolute_url())
|
||||||
|
|
||||||
assert self.updated_amount(self.customer) == 0
|
assert self.updated_amount(self.customer) == 0
|
||||||
|
|
||||||
def test_recordings(self):
|
def test_recordings(self):
|
||||||
force_refill_user(self.customer, self.cons.selling_price * 3)
|
force_refill_user(self.customer, self.cons.amount * 3)
|
||||||
self.login_in_bar(self.barmen)
|
self.login_in_bar(self.barmen)
|
||||||
res = self.submit_basket(self.customer, [BasketItem(self.cons.id, 3)])
|
res = self.submit_basket(self.customer, [BasketItem(self.cons.id, 3)])
|
||||||
assert res.status_code == 302
|
assert res.status_code == 302
|
||||||
assert self.updated_amount(self.customer) == 0
|
assert self.updated_amount(self.customer) == 0
|
||||||
assert list(
|
assert list(
|
||||||
self.customer.customer.return_balances.values("returnable", "balance")
|
self.customer.customer.return_balances.values("returnable", "balance")
|
||||||
) == [{"returnable": self.cons.cons.id, "balance": 3}]
|
) == [{"returnable": self.cons.product.cons.id, "balance": 3}]
|
||||||
|
|
||||||
res = self.submit_basket(self.customer, [BasketItem(self.dcons.id, 3)])
|
res = self.submit_basket(self.customer, [BasketItem(self.dcons.id, 3)])
|
||||||
assert res.status_code == 302
|
assert res.status_code == 302
|
||||||
assert self.updated_amount(self.customer) == self.dcons.selling_price * -3
|
assert self.updated_amount(self.customer) == self.dcons.amount * -3
|
||||||
|
|
||||||
res = self.submit_basket(
|
res = self.submit_basket(
|
||||||
self.customer, [BasketItem(self.dcons.id, self.dcons.dcons.max_return)]
|
self.customer,
|
||||||
|
[BasketItem(self.dcons.id, self.dcons.product.dcons.max_return)],
|
||||||
)
|
)
|
||||||
# from now on, the user amount should not change
|
# from now on, the user amount should not change
|
||||||
expected_amount = self.dcons.selling_price * (-3 - self.dcons.dcons.max_return)
|
expected_amount = self.dcons.amount * (-3 - self.dcons.product.dcons.max_return)
|
||||||
assert res.status_code == 302
|
assert res.status_code == 302
|
||||||
assert self.updated_amount(self.customer) == expected_amount
|
assert self.updated_amount(self.customer) == expected_amount
|
||||||
|
|
||||||
@@ -545,48 +554,57 @@ class TestCounterClick(TestFullClickBase):
|
|||||||
def test_recordings_when_negative(self):
|
def test_recordings_when_negative(self):
|
||||||
sale_recipe.make(
|
sale_recipe.make(
|
||||||
customer=self.customer.customer,
|
customer=self.customer.customer,
|
||||||
product=self.dcons,
|
product=self.dcons.product,
|
||||||
unit_price=self.dcons.selling_price,
|
unit_price=self.dcons.amount,
|
||||||
quantity=10,
|
quantity=10,
|
||||||
)
|
)
|
||||||
self.customer.customer.update_returnable_balance()
|
self.customer.customer.update_returnable_balance()
|
||||||
self.login_in_bar(self.barmen)
|
self.login_in_bar(self.barmen)
|
||||||
res = self.submit_basket(self.customer, [BasketItem(self.dcons.id, 1)])
|
res = self.submit_basket(self.customer, [BasketItem(self.dcons.id, 1)])
|
||||||
assert res.status_code == 200
|
assert res.status_code == 200
|
||||||
assert self.updated_amount(self.customer) == self.dcons.selling_price * -10
|
assert self.updated_amount(self.customer) == self.dcons.amount * -10
|
||||||
|
|
||||||
res = self.submit_basket(self.customer, [BasketItem(self.cons.id, 3)])
|
res = self.submit_basket(self.customer, [BasketItem(self.cons.id, 3)])
|
||||||
assert res.status_code == 302
|
assert res.status_code == 302
|
||||||
assert (
|
assert (
|
||||||
self.updated_amount(self.customer)
|
self.updated_amount(self.customer)
|
||||||
== self.dcons.selling_price * -10 - self.cons.selling_price * 3
|
== self.dcons.amount * -10 - self.cons.amount * 3
|
||||||
)
|
)
|
||||||
|
|
||||||
res = self.submit_basket(self.customer, [BasketItem(self.beer.id, 1)])
|
res = self.submit_basket(self.customer, [BasketItem(self.beer.id, 1)])
|
||||||
assert res.status_code == 302
|
assert res.status_code == 302
|
||||||
assert (
|
assert (
|
||||||
self.updated_amount(self.customer)
|
self.updated_amount(self.customer)
|
||||||
== self.dcons.selling_price * -10
|
== self.dcons.amount * -10 - self.cons.amount * 3 - self.beer.amount
|
||||||
- self.cons.selling_price * 3
|
|
||||||
- self.beer.selling_price
|
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_no_fetch_archived_product(self):
|
def test_no_fetch_archived_product(self):
|
||||||
counter = baker.make(Counter)
|
counter = baker.make(Counter)
|
||||||
|
group = baker.make(Group)
|
||||||
customer = baker.make(Customer)
|
customer = baker.make(Customer)
|
||||||
product_recipe.make(archived=True, counters=[counter])
|
group.users.add(customer.user)
|
||||||
unarchived_products = product_recipe.make(
|
_product_recipe = product_recipe.extend(
|
||||||
archived=False, counters=[counter], _quantity=3
|
counters=[counter], product_type=baker.make(ProductType)
|
||||||
)
|
)
|
||||||
customer_products = counter.get_products_for(customer)
|
price_recipe.make(
|
||||||
assert unarchived_products == customer_products
|
_quantity=2,
|
||||||
|
product=iter(_product_recipe.make(archived=True, _quantity=2)),
|
||||||
|
groups=[group],
|
||||||
|
)
|
||||||
|
unarchived_prices = price_recipe.make(
|
||||||
|
_quantity=2,
|
||||||
|
product=iter(_product_recipe.make(archived=False, _quantity=2)),
|
||||||
|
groups=[group],
|
||||||
|
)
|
||||||
|
customer_prices = counter.get_prices_for(customer)
|
||||||
|
assert unarchived_prices == customer_prices
|
||||||
|
|
||||||
|
|
||||||
class TestCounterStats(TestCase):
|
class TestCounterStats(TestCase):
|
||||||
@classmethod
|
@classmethod
|
||||||
def setUpTestData(cls):
|
def setUpTestData(cls):
|
||||||
cls.users = subscriber_user.make(_quantity=4)
|
cls.users = subscriber_user.make(_quantity=4)
|
||||||
product = product_recipe.make(selling_price=1)
|
product = price_recipe.make(amount=1).product
|
||||||
cls.counter = baker.make(
|
cls.counter = baker.make(
|
||||||
Counter, type=["BAR"], sellers=cls.users[:4], products=[product]
|
Counter, type=["BAR"], sellers=cls.users[:4], products=[product]
|
||||||
)
|
)
|
||||||
@@ -785,9 +803,6 @@ class TestClubCounterClickAccess(TestCase):
|
|||||||
|
|
||||||
cls.user = subscriber_user.make()
|
cls.user = subscriber_user.make()
|
||||||
|
|
||||||
def setUp(self):
|
|
||||||
cache.clear()
|
|
||||||
|
|
||||||
def test_anonymous(self):
|
def test_anonymous(self):
|
||||||
res = self.client.get(self.click_url)
|
res = self.client.get(self.click_url)
|
||||||
assert res.status_code == 403
|
assert res.status_code == 403
|
||||||
|
|||||||
@@ -341,7 +341,7 @@ def test_update_balance():
|
|||||||
def test_update_returnable_balance():
|
def test_update_returnable_balance():
|
||||||
ReturnableProduct.objects.all().delete()
|
ReturnableProduct.objects.all().delete()
|
||||||
customer = baker.make(Customer)
|
customer = baker.make(Customer)
|
||||||
products = product_recipe.make(selling_price=0, _quantity=4, _bulk_create=True)
|
products = product_recipe.make(_quantity=4, _bulk_create=True)
|
||||||
returnables = [
|
returnables = [
|
||||||
baker.make(
|
baker.make(
|
||||||
ReturnableProduct, product=products[0], returned_product=products[1]
|
ReturnableProduct, product=products[0], returned_product=products[1]
|
||||||
|
|||||||
@@ -7,12 +7,7 @@ from counter.forms import ProductFormulaForm
|
|||||||
class TestFormulaForm(TestCase):
|
class TestFormulaForm(TestCase):
|
||||||
@classmethod
|
@classmethod
|
||||||
def setUpTestData(cls):
|
def setUpTestData(cls):
|
||||||
cls.products = product_recipe.make(
|
cls.products = product_recipe.make(_quantity=3, _bulk_create=True)
|
||||||
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(
|
||||||
@@ -26,23 +21,6 @@ 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={
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ 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
|
||||||
|
|
||||||
@@ -16,8 +17,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
|
from counter.forms import ProductForm, ProductPriceFormSet
|
||||||
from counter.models import Product, ProductFormula, ProductType
|
from counter.models import Price, Product, ProductType
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
@@ -81,11 +82,11 @@ def test_fetch_product_access(
|
|||||||
def test_fetch_product_nb_queries(client: Client):
|
def test_fetch_product_nb_queries(client: Client):
|
||||||
client.force_login(baker.make(User, is_superuser=True))
|
client.force_login(baker.make(User, is_superuser=True))
|
||||||
cache.clear()
|
cache.clear()
|
||||||
with assertNumQueries(5):
|
with assertNumQueries(6):
|
||||||
# - 2 for authentication
|
# - 2 for authentication
|
||||||
# - 1 for pagination
|
# - 1 for pagination
|
||||||
# - 1 for the actual request
|
# - 1 for the actual request
|
||||||
# - 1 to prefetch the related buying_groups
|
# - 2 to prefetch the related prices and groups
|
||||||
client.get(reverse("api:search_products_detailed"))
|
client.get(reverse("api:search_products_detailed"))
|
||||||
|
|
||||||
|
|
||||||
@@ -107,48 +108,21 @@ class TestCreateProduct(TestCase):
|
|||||||
"selling_price": 1.0,
|
"selling_price": 1.0,
|
||||||
"special_selling_price": 1.0,
|
"special_selling_price": 1.0,
|
||||||
"limit_age": 0,
|
"limit_age": 0,
|
||||||
"form-TOTAL_FORMS": 0,
|
"price-TOTAL_FORMS": 0,
|
||||||
"form-INITIAL_FORMS": 0,
|
"price-INITIAL_FORMS": 0,
|
||||||
|
"action-TOTAL_FORMS": 0,
|
||||||
|
"action-INITIAL_FORMS": 0,
|
||||||
}
|
}
|
||||||
|
|
||||||
def test_form(self):
|
def test_form_simple(self):
|
||||||
form = ProductForm(data=self.data)
|
form = ProductForm(data=self.data)
|
||||||
assert form.is_valid()
|
assert form.is_valid()
|
||||||
instance = form.save()
|
instance = form.save()
|
||||||
assert instance.club == self.club
|
assert instance.club == self.club
|
||||||
assert instance.product_type == self.product_type
|
assert instance.product_type == self.product_type
|
||||||
assert instance.name == "foo"
|
assert instance.name == "foo"
|
||||||
assert instance.selling_price == 1.0
|
|
||||||
|
|
||||||
def test_form_with_product_from_formula(self):
|
def test_view_simple(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)
|
||||||
@@ -159,3 +133,92 @@ class TestCreateProduct(TestCase):
|
|||||||
assert product.name == "foo"
|
assert product.name == "foo"
|
||||||
assert product.club == self.club
|
assert product.club == self.club
|
||||||
assert product.product_type == self.product_type
|
assert product.product_type == self.product_type
|
||||||
|
|
||||||
|
|
||||||
|
class TestPriceFormSet(TestCase):
|
||||||
|
@classmethod
|
||||||
|
def setUpTestData(cls):
|
||||||
|
cls.product = product_recipe.make()
|
||||||
|
cls.counter_admin = baker.make(
|
||||||
|
User, groups=[Group.objects.get(id=settings.SITH_GROUP_COUNTER_ADMIN_ID)]
|
||||||
|
)
|
||||||
|
cls.groups = baker.make(Group, _quantity=3)
|
||||||
|
|
||||||
|
def test_add_price(self):
|
||||||
|
data = {
|
||||||
|
"prices-0-amount": 2,
|
||||||
|
"prices-0-label": "foo",
|
||||||
|
"prices-0-groups": [self.groups[0].id, self.groups[1].id],
|
||||||
|
"prices-0-is_always_shown": True,
|
||||||
|
"prices-1-amount": 1.5,
|
||||||
|
"prices-1-label": "",
|
||||||
|
"prices-1-groups": [self.groups[1].id, self.groups[2].id],
|
||||||
|
"prices-1-is_always_shown": False,
|
||||||
|
"prices-TOTAL_FORMS": 2,
|
||||||
|
"prices-INITIAL_FORMS": 0,
|
||||||
|
}
|
||||||
|
form = ProductPriceFormSet(instance=self.product, data=data)
|
||||||
|
assert form.is_valid()
|
||||||
|
form.save()
|
||||||
|
prices = list(self.product.prices.order_by("amount"))
|
||||||
|
assert len(prices) == 2
|
||||||
|
assert prices[0].amount == 1.5
|
||||||
|
assert prices[0].label == ""
|
||||||
|
assert prices[0].is_always_shown is False
|
||||||
|
assert set(prices[0].groups.all()) == {self.groups[1], self.groups[2]}
|
||||||
|
assert prices[1].amount == 2
|
||||||
|
assert prices[1].label == "foo"
|
||||||
|
assert prices[1].is_always_shown is True
|
||||||
|
assert set(prices[1].groups.all()) == {self.groups[0], self.groups[1]}
|
||||||
|
|
||||||
|
def test_change_prices(self):
|
||||||
|
price_a = baker.make(
|
||||||
|
Price, product=self.product, amount=1.5, groups=self.groups[:1]
|
||||||
|
)
|
||||||
|
price_b = baker.make(
|
||||||
|
Price, product=self.product, amount=2, groups=self.groups[1:]
|
||||||
|
)
|
||||||
|
data = {
|
||||||
|
"prices-0-id": price_a.id,
|
||||||
|
"prices-0-DELETE": True,
|
||||||
|
"prices-1-id": price_b.id,
|
||||||
|
"prices-1-DELETE": False,
|
||||||
|
"prices-1-amount": 3,
|
||||||
|
"prices-1-label": "foo",
|
||||||
|
"prices-1-groups": [self.groups[1].id],
|
||||||
|
"prices-1-is_always_shown": True,
|
||||||
|
"prices-TOTAL_FORMS": 2,
|
||||||
|
"prices-INITIAL_FORMS": 2,
|
||||||
|
}
|
||||||
|
form = ProductPriceFormSet(instance=self.product, data=data)
|
||||||
|
assert form.is_valid()
|
||||||
|
form.save()
|
||||||
|
prices = list(self.product.prices.order_by("amount"))
|
||||||
|
assert len(prices) == 1
|
||||||
|
assert prices[0].amount == 3
|
||||||
|
assert prices[0].label == "foo"
|
||||||
|
assert prices[0].is_always_shown is True
|
||||||
|
assert set(prices[0].groups.all()) == {self.groups[1]}
|
||||||
|
assert not Price.objects.filter(id=price_a.id).exists()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_price_for_user():
|
||||||
|
groups = baker.make(Group, _quantity=4)
|
||||||
|
users = [
|
||||||
|
baker.make(User, groups=groups[:2]),
|
||||||
|
baker.make(User, groups=groups[1:3]),
|
||||||
|
baker.make(User, groups=[groups[3]]),
|
||||||
|
]
|
||||||
|
recipe = Recipe(Price, product=product_recipe.make())
|
||||||
|
prices = [
|
||||||
|
recipe.make(amount=5, groups=groups, is_always_shown=True),
|
||||||
|
recipe.make(amount=4, groups=[groups[0]], is_always_shown=True),
|
||||||
|
recipe.make(amount=3, groups=[groups[1]], is_always_shown=False),
|
||||||
|
recipe.make(amount=2, groups=[groups[3]], is_always_shown=False),
|
||||||
|
recipe.make(amount=1, groups=[groups[1]], is_always_shown=False),
|
||||||
|
]
|
||||||
|
qs = Price.objects.order_by("-amount")
|
||||||
|
assert set(qs.for_user(users[0])) == {prices[0], prices[1], prices[4]}
|
||||||
|
assert set(qs.for_user(users[1])) == {prices[0], prices[4]}
|
||||||
|
assert set(qs.for_user(users[2])) == {prices[0], prices[3]}
|
||||||
|
|||||||
@@ -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_products": {product.id: product for product in self.products},
|
"allowed_prices": {price.id: price for price in self.prices},
|
||||||
}
|
}
|
||||||
return kwargs
|
return kwargs
|
||||||
|
|
||||||
@@ -103,7 +103,7 @@ class CounterClick(
|
|||||||
):
|
):
|
||||||
return redirect(obj) # Redirect to counter
|
return redirect(obj) # Redirect to counter
|
||||||
|
|
||||||
self.products = obj.get_products_for(self.customer)
|
self.prices = obj.get_prices_for(self.customer)
|
||||||
|
|
||||||
return super().dispatch(request, *args, **kwargs)
|
return super().dispatch(request, *args, **kwargs)
|
||||||
|
|
||||||
@@ -121,32 +121,31 @@ class CounterClick(
|
|||||||
# This is important because some items have a negative price
|
# This is important because some items have a negative price
|
||||||
# Negative priced items gives money to the customer and should
|
# Negative priced items gives money to the customer and should
|
||||||
# be processed first so that we don't throw a not enough money error
|
# be processed first so that we don't throw a not enough money error
|
||||||
for form in sorted(formset, key=lambda form: form.product.price):
|
for form in sorted(formset, key=lambda form: form.price.amount):
|
||||||
self.request.session["last_basket"].append(
|
self.request.session["last_basket"].append(
|
||||||
f"{form.cleaned_data['quantity']} x {form.product.name}"
|
f"{form.cleaned_data['quantity']} x {form.price.full_label}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
common_kwargs = {
|
||||||
|
"product": form.price.product,
|
||||||
|
"club_id": form.price.product.club_id,
|
||||||
|
"counter": self.object,
|
||||||
|
"seller": operator,
|
||||||
|
"customer": self.customer,
|
||||||
|
}
|
||||||
Selling(
|
Selling(
|
||||||
label=form.product.name,
|
**common_kwargs,
|
||||||
product=form.product,
|
label=form.price.full_label,
|
||||||
club=form.product.club,
|
unit_price=form.price.amount,
|
||||||
counter=self.object,
|
|
||||||
unit_price=form.product.price,
|
|
||||||
quantity=form.cleaned_data["quantity"]
|
quantity=form.cleaned_data["quantity"]
|
||||||
- form.cleaned_data["bonus_quantity"],
|
- form.cleaned_data["bonus_quantity"],
|
||||||
seller=operator,
|
|
||||||
customer=self.customer,
|
|
||||||
).save()
|
).save()
|
||||||
if form.cleaned_data["bonus_quantity"] > 0:
|
if form.cleaned_data["bonus_quantity"] > 0:
|
||||||
Selling(
|
Selling(
|
||||||
label=f"{form.product.name} (Plateau)",
|
**common_kwargs,
|
||||||
product=form.product,
|
label=f"{form.price.full_label} (Plateau)",
|
||||||
club=form.product.club,
|
|
||||||
counter=self.object,
|
|
||||||
unit_price=0,
|
unit_price=0,
|
||||||
quantity=form.cleaned_data["bonus_quantity"],
|
quantity=form.cleaned_data["bonus_quantity"],
|
||||||
seller=operator,
|
|
||||||
customer=self.customer,
|
|
||||||
).save()
|
).save()
|
||||||
|
|
||||||
self.customer.update_returnable_balance()
|
self.customer.update_returnable_balance()
|
||||||
@@ -207,14 +206,13 @@ class CounterClick(
|
|||||||
def get_context_data(self, **kwargs):
|
def get_context_data(self, **kwargs):
|
||||||
"""Add customer to the context."""
|
"""Add customer to the context."""
|
||||||
kwargs = super().get_context_data(**kwargs)
|
kwargs = super().get_context_data(**kwargs)
|
||||||
kwargs["products"] = self.products
|
kwargs["prices"] = self.prices
|
||||||
kwargs["formulas"] = ProductFormula.objects.filter(
|
kwargs["formulas"] = ProductFormula.objects.filter(
|
||||||
result__in=self.products
|
result__in=[p.product_id for p in self.prices]
|
||||||
).prefetch_related("products")
|
).prefetch_related("products")
|
||||||
kwargs["categories"] = defaultdict(list)
|
kwargs["categories"] = defaultdict(list)
|
||||||
for product in kwargs["products"]:
|
for price in self.prices:
|
||||||
if product.product_type:
|
kwargs["categories"][price.product.product_type].append(price)
|
||||||
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()
|
||||||
|
|
||||||
|
|||||||
@@ -263,35 +263,3 @@ 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
|
|
||||||
```
|
|
||||||
|
|||||||
@@ -22,23 +22,22 @@ from eboutic.models import Basket, BasketItem, Invoice, InvoiceItem
|
|||||||
class BasketAdmin(admin.ModelAdmin):
|
class BasketAdmin(admin.ModelAdmin):
|
||||||
list_display = ("user", "date", "total")
|
list_display = ("user", "date", "total")
|
||||||
autocomplete_fields = ("user",)
|
autocomplete_fields = ("user",)
|
||||||
|
date_hierarchy = "date"
|
||||||
|
|
||||||
def get_queryset(self, request):
|
def get_queryset(self, request):
|
||||||
return (
|
return (
|
||||||
super()
|
super()
|
||||||
.get_queryset(request)
|
.get_queryset(request)
|
||||||
.annotate(
|
.annotate(
|
||||||
total=Sum(
|
total=Sum(F("items__quantity") * F("items__unit_price"), default=0)
|
||||||
F("items__quantity") * F("items__product_unit_price"), default=0
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@admin.register(BasketItem)
|
@admin.register(BasketItem)
|
||||||
class BasketItemAdmin(admin.ModelAdmin):
|
class BasketItemAdmin(admin.ModelAdmin):
|
||||||
list_display = ("basket", "product_name", "product_unit_price", "quantity")
|
list_display = ("label", "unit_price", "quantity")
|
||||||
search_fields = ("product_name",)
|
search_fields = ("label",)
|
||||||
|
|
||||||
|
|
||||||
@admin.register(Invoice)
|
@admin.register(Invoice)
|
||||||
@@ -50,5 +49,6 @@ class InvoiceAdmin(admin.ModelAdmin):
|
|||||||
|
|
||||||
@admin.register(InvoiceItem)
|
@admin.register(InvoiceItem)
|
||||||
class InvoiceItemAdmin(admin.ModelAdmin):
|
class InvoiceItemAdmin(admin.ModelAdmin):
|
||||||
list_display = ("invoice", "product_name", "product_unit_price", "quantity")
|
list_display = ("label", "unit_price", "quantity")
|
||||||
search_fields = ("product_name",)
|
search_fields = ("label",)
|
||||||
|
list_select_related = ("price",)
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
# Generated by Django 5.2.11 on 2026-02-22 18:13
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [("counter", "0038_price"), ("eboutic", "0002_auto_20221005_2243")]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RenameField(
|
||||||
|
model_name="basketitem", old_name="product_name", new_name="label"
|
||||||
|
),
|
||||||
|
migrations.RenameField(
|
||||||
|
model_name="basketitem",
|
||||||
|
old_name="product_unit_price",
|
||||||
|
new_name="unit_price",
|
||||||
|
),
|
||||||
|
migrations.RenameField(
|
||||||
|
model_name="basketitem", old_name="product_id", new_name="product"
|
||||||
|
),
|
||||||
|
migrations.RenameField(
|
||||||
|
model_name="invoiceitem", old_name="product_name", new_name="label"
|
||||||
|
),
|
||||||
|
migrations.RenameField(
|
||||||
|
model_name="invoiceitem",
|
||||||
|
old_name="product_unit_price",
|
||||||
|
new_name="unit_price",
|
||||||
|
),
|
||||||
|
migrations.RenameField(
|
||||||
|
model_name="invoiceitem", old_name="product_id", new_name="product"
|
||||||
|
),
|
||||||
|
migrations.RemoveField(model_name="basketitem", name="type_id"),
|
||||||
|
migrations.RemoveField(model_name="invoiceitem", name="type_id"),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="basketitem",
|
||||||
|
name="product",
|
||||||
|
field=models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.PROTECT,
|
||||||
|
to="counter.product",
|
||||||
|
verbose_name="product",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="invoiceitem",
|
||||||
|
name="product",
|
||||||
|
field=models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.PROTECT,
|
||||||
|
to="counter.product",
|
||||||
|
verbose_name="product",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -17,7 +17,7 @@ from __future__ import annotations
|
|||||||
import hmac
|
import hmac
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
from typing import Any, Self
|
from typing import Self
|
||||||
|
|
||||||
from dict2xml import dict2xml
|
from dict2xml import dict2xml
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
@@ -30,8 +30,8 @@ from core.models import User
|
|||||||
from counter.fields import CurrencyField
|
from counter.fields import CurrencyField
|
||||||
from counter.models import (
|
from counter.models import (
|
||||||
BillingInfo,
|
BillingInfo,
|
||||||
Counter,
|
|
||||||
Customer,
|
Customer,
|
||||||
|
Price,
|
||||||
Product,
|
Product,
|
||||||
Refilling,
|
Refilling,
|
||||||
Selling,
|
Selling,
|
||||||
@@ -39,22 +39,6 @@ from counter.models import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def get_eboutic_products(user: User) -> list[Product]:
|
|
||||||
products = (
|
|
||||||
get_eboutic()
|
|
||||||
.products.filter(product_type__isnull=False)
|
|
||||||
.filter(archived=False, limit_age__lte=user.age)
|
|
||||||
.annotate(
|
|
||||||
order=F("product_type__order"),
|
|
||||||
category=F("product_type__name"),
|
|
||||||
category_comment=F("product_type__comment"),
|
|
||||||
price=F("selling_price"), # <-- selected price for basket validation
|
|
||||||
)
|
|
||||||
.prefetch_related("buying_groups") # <-- used in `Product.can_be_sold_to`
|
|
||||||
)
|
|
||||||
return [p for p in products if p.can_be_sold_to(user)]
|
|
||||||
|
|
||||||
|
|
||||||
class BillingInfoState(Enum):
|
class BillingInfoState(Enum):
|
||||||
VALID = 1
|
VALID = 1
|
||||||
EMPTY = 2
|
EMPTY = 2
|
||||||
@@ -94,21 +78,21 @@ class Basket(models.Model):
|
|||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f"{self.user}'s basket ({self.items.all().count()} items)"
|
return f"{self.user}'s basket ({self.items.all().count()} items)"
|
||||||
|
|
||||||
def can_be_viewed_by(self, user):
|
def can_be_viewed_by(self, user: User):
|
||||||
return self.user == user
|
return self.user == user
|
||||||
|
|
||||||
@cached_property
|
@cached_property
|
||||||
def contains_refilling_item(self) -> bool:
|
def contains_refilling_item(self) -> bool:
|
||||||
return self.items.filter(
|
return self.items.filter(
|
||||||
type_id=settings.SITH_COUNTER_PRODUCTTYPE_REFILLING
|
product__product_type_id=settings.SITH_COUNTER_PRODUCTTYPE_REFILLING
|
||||||
).exists()
|
).exists()
|
||||||
|
|
||||||
@cached_property
|
@cached_property
|
||||||
def total(self) -> float:
|
def total(self) -> float:
|
||||||
return float(
|
return float(
|
||||||
self.items.aggregate(
|
self.items.aggregate(total=Sum(F("quantity") * F("unit_price"), default=0))[
|
||||||
total=Sum(F("quantity") * F("product_unit_price"), default=0)
|
"total"
|
||||||
)["total"]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
def generate_sales(
|
def generate_sales(
|
||||||
@@ -120,7 +104,8 @@ class Basket(models.Model):
|
|||||||
Example:
|
Example:
|
||||||
```python
|
```python
|
||||||
counter = Counter.objects.get(name="Eboutic")
|
counter = Counter.objects.get(name="Eboutic")
|
||||||
sales = basket.generate_sales(counter, "SITH_ACCOUNT")
|
user = User.objects.get(username="bibou")
|
||||||
|
sales = basket.generate_sales(counter, user, Selling.PaymentMethod.SITH_ACCOUNT)
|
||||||
# here the basket is in the same state as before the method call
|
# here the basket is in the same state as before the method call
|
||||||
|
|
||||||
with transaction.atomic():
|
with transaction.atomic():
|
||||||
@@ -131,31 +116,23 @@ class Basket(models.Model):
|
|||||||
# thus only the sales remain
|
# thus only the sales remain
|
||||||
```
|
```
|
||||||
"""
|
"""
|
||||||
# I must proceed with two distinct requests instead of
|
customer = Customer.get_or_create(self.user)[0]
|
||||||
# only one with a join because the AbstractBaseItem model has been
|
return [
|
||||||
# poorly designed. If you refactor the model, please refactor this too.
|
Selling(
|
||||||
items = self.items.order_by("product_id")
|
label=item.label,
|
||||||
ids = [item.product_id for item in items]
|
counter=counter,
|
||||||
products = Product.objects.filter(id__in=ids).order_by("id")
|
club_id=item.product.club_id,
|
||||||
# items and products are sorted in the same order
|
product=item.product,
|
||||||
sales = []
|
seller=seller,
|
||||||
for item, product in zip(items, products, strict=False):
|
customer=customer,
|
||||||
sales.append(
|
unit_price=item.unit_price,
|
||||||
Selling(
|
quantity=item.quantity,
|
||||||
label=product.name,
|
payment_method=payment_method,
|
||||||
counter=counter,
|
|
||||||
club=product.club,
|
|
||||||
product=product,
|
|
||||||
seller=seller,
|
|
||||||
customer=Customer.get_or_create(self.user)[0],
|
|
||||||
unit_price=item.product_unit_price,
|
|
||||||
quantity=item.quantity,
|
|
||||||
payment_method=payment_method,
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
return sales
|
for item in self.items.select_related("product")
|
||||||
|
]
|
||||||
|
|
||||||
def get_e_transaction_data(self) -> list[tuple[str, Any]]:
|
def get_e_transaction_data(self) -> list[tuple[str, str]]:
|
||||||
user = self.user
|
user = self.user
|
||||||
if not hasattr(user, "customer"):
|
if not hasattr(user, "customer"):
|
||||||
raise Customer.DoesNotExist
|
raise Customer.DoesNotExist
|
||||||
@@ -201,7 +178,7 @@ class InvoiceQueryset(models.QuerySet):
|
|||||||
def annotate_total(self) -> Self:
|
def annotate_total(self) -> Self:
|
||||||
"""Annotate the queryset with the total amount of each invoice.
|
"""Annotate the queryset with the total amount of each invoice.
|
||||||
|
|
||||||
The total amount is the sum of (product_unit_price * quantity)
|
The total amount is the sum of (unit_price * quantity)
|
||||||
for all items related to the invoice.
|
for all items related to the invoice.
|
||||||
"""
|
"""
|
||||||
# aggregates within subqueries require a little bit of black magic,
|
# aggregates within subqueries require a little bit of black magic,
|
||||||
@@ -211,7 +188,7 @@ class InvoiceQueryset(models.QuerySet):
|
|||||||
total=Subquery(
|
total=Subquery(
|
||||||
InvoiceItem.objects.filter(invoice_id=OuterRef("pk"))
|
InvoiceItem.objects.filter(invoice_id=OuterRef("pk"))
|
||||||
.values("invoice_id")
|
.values("invoice_id")
|
||||||
.annotate(total=Sum(F("product_unit_price") * F("quantity")))
|
.annotate(total=Sum(F("unit_price") * F("quantity")))
|
||||||
.values("total")
|
.values("total")
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -221,11 +198,7 @@ class Invoice(models.Model):
|
|||||||
"""Invoices are generated once the payment has been validated."""
|
"""Invoices are generated once the payment has been validated."""
|
||||||
|
|
||||||
user = models.ForeignKey(
|
user = models.ForeignKey(
|
||||||
User,
|
User, related_name="invoices", verbose_name=_("user"), on_delete=models.CASCADE
|
||||||
related_name="invoices",
|
|
||||||
verbose_name=_("user"),
|
|
||||||
blank=False,
|
|
||||||
on_delete=models.CASCADE,
|
|
||||||
)
|
)
|
||||||
date = models.DateTimeField(_("date"), auto_now=True)
|
date = models.DateTimeField(_("date"), auto_now=True)
|
||||||
validated = models.BooleanField(_("validated"), default=False)
|
validated = models.BooleanField(_("validated"), default=False)
|
||||||
@@ -246,53 +219,44 @@ class Invoice(models.Model):
|
|||||||
if self.validated:
|
if self.validated:
|
||||||
raise DataError(_("Invoice already validated"))
|
raise DataError(_("Invoice already validated"))
|
||||||
customer, _created = Customer.get_or_create(user=self.user)
|
customer, _created = Customer.get_or_create(user=self.user)
|
||||||
eboutic = Counter.objects.filter(type="EBOUTIC").first()
|
kwargs = {
|
||||||
for i in self.items.all():
|
"counter": get_eboutic(),
|
||||||
if i.type_id == settings.SITH_COUNTER_PRODUCTTYPE_REFILLING:
|
"customer": customer,
|
||||||
new = Refilling(
|
"date": self.date,
|
||||||
counter=eboutic,
|
"payment_method": Selling.PaymentMethod.CARD,
|
||||||
customer=customer,
|
}
|
||||||
operator=self.user,
|
for i in self.items.select_related("product"):
|
||||||
amount=i.product_unit_price * i.quantity,
|
if i.product.product_type_id == settings.SITH_COUNTER_PRODUCTTYPE_REFILLING:
|
||||||
payment_method=Refilling.PaymentMethod.CARD,
|
Refilling.objects.create(
|
||||||
date=self.date,
|
**kwargs, operator=self.user, amount=i.unit_price * i.quantity
|
||||||
)
|
)
|
||||||
new.save()
|
|
||||||
else:
|
else:
|
||||||
product = Product.objects.filter(id=i.product_id).first()
|
Selling.objects.create(
|
||||||
new = Selling(
|
**kwargs,
|
||||||
label=i.product_name,
|
label=i.label,
|
||||||
counter=eboutic,
|
club_id=i.product.club_id,
|
||||||
club=product.club,
|
product=i.product,
|
||||||
product=product,
|
|
||||||
seller=self.user,
|
seller=self.user,
|
||||||
customer=customer,
|
unit_price=i.unit_price,
|
||||||
unit_price=i.product_unit_price,
|
|
||||||
quantity=i.quantity,
|
quantity=i.quantity,
|
||||||
payment_method=Selling.PaymentMethod.CARD,
|
|
||||||
date=self.date,
|
|
||||||
)
|
)
|
||||||
new.save()
|
|
||||||
self.validated = True
|
self.validated = True
|
||||||
self.save()
|
self.save()
|
||||||
|
|
||||||
|
|
||||||
class AbstractBaseItem(models.Model):
|
class AbstractBaseItem(models.Model):
|
||||||
product_id = models.IntegerField(_("product id"))
|
product = models.ForeignKey(
|
||||||
product_name = models.CharField(_("product name"), max_length=255)
|
Product, verbose_name=_("product"), on_delete=models.PROTECT
|
||||||
type_id = models.IntegerField(_("product type id"))
|
)
|
||||||
product_unit_price = CurrencyField(_("unit price"))
|
label = models.CharField(_("product name"), max_length=255)
|
||||||
|
unit_price = CurrencyField(_("unit price"))
|
||||||
quantity = models.PositiveIntegerField(_("quantity"))
|
quantity = models.PositiveIntegerField(_("quantity"))
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
abstract = True
|
abstract = True
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return "Item: %s (%s) x%d" % (
|
return "Item: %s (%s) x%d" % (self.product.name, self.unit_price, self.quantity)
|
||||||
self.product_name,
|
|
||||||
self.product_unit_price,
|
|
||||||
self.quantity,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class BasketItem(AbstractBaseItem):
|
class BasketItem(AbstractBaseItem):
|
||||||
@@ -301,21 +265,16 @@ class BasketItem(AbstractBaseItem):
|
|||||||
)
|
)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_product(cls, product: Product, quantity: int, basket: Basket):
|
def from_price(cls, price: Price, quantity: int, basket: Basket):
|
||||||
"""Create a BasketItem with the same characteristics as the
|
"""Create a BasketItem with the same characteristics as the
|
||||||
product passed in parameters, with the specified quantity.
|
product price passed in parameters, with the specified quantity.
|
||||||
|
|
||||||
Warning:
|
|
||||||
the basket field is not filled, so you must set
|
|
||||||
it yourself before saving the model.
|
|
||||||
"""
|
"""
|
||||||
return cls(
|
return cls(
|
||||||
basket=basket,
|
basket=basket,
|
||||||
product_id=product.id,
|
label=price.full_label,
|
||||||
product_name=product.name,
|
product_id=price.product_id,
|
||||||
type_id=product.product_type_id,
|
|
||||||
quantity=quantity,
|
quantity=quantity,
|
||||||
product_unit_price=product.selling_price,
|
unit_price=price.amount,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,15 @@
|
|||||||
export {};
|
export {};
|
||||||
|
|
||||||
interface BasketItem {
|
interface BasketItem {
|
||||||
id: number;
|
priceId: number;
|
||||||
name: string;
|
name: string;
|
||||||
quantity: number;
|
quantity: number;
|
||||||
// biome-ignore lint/style/useNamingConvention: the python code is snake_case
|
unitPrice: number;
|
||||||
unit_price: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// increment the key number if the data schema of the cached basket changes
|
||||||
|
const BASKET_CACHE_KEY = "basket1";
|
||||||
|
|
||||||
document.addEventListener("alpine:init", () => {
|
document.addEventListener("alpine:init", () => {
|
||||||
Alpine.data("basket", (lastPurchaseTime?: number) => ({
|
Alpine.data("basket", (lastPurchaseTime?: number) => ({
|
||||||
basket: [] as BasketItem[],
|
basket: [] as BasketItem[],
|
||||||
@@ -30,24 +32,24 @@ document.addEventListener("alpine:init", () => {
|
|||||||
// It's quite tricky to manually apply attributes to the management part
|
// It's quite tricky to manually apply attributes to the management part
|
||||||
// of a formset so we dynamically apply it here
|
// of a formset so we dynamically apply it here
|
||||||
this.$refs.basketManagementForm
|
this.$refs.basketManagementForm
|
||||||
.querySelector("#id_form-TOTAL_FORMS")
|
.getElementById("#id_form-TOTAL_FORMS")
|
||||||
.setAttribute(":value", "basket.length");
|
.setAttribute(":value", "basket.length");
|
||||||
},
|
},
|
||||||
|
|
||||||
loadBasket(): BasketItem[] {
|
loadBasket(): BasketItem[] {
|
||||||
if (localStorage.basket === undefined) {
|
if (localStorage.getItem(BASKET_CACHE_KEY) === null) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
return JSON.parse(localStorage.basket);
|
return JSON.parse(localStorage.getItem(BASKET_CACHE_KEY));
|
||||||
} catch (_err) {
|
} catch (_err) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
saveBasket() {
|
saveBasket() {
|
||||||
localStorage.basket = JSON.stringify(this.basket);
|
localStorage.setItem(BASKET_CACHE_KEY, JSON.stringify(this.basket));
|
||||||
localStorage.basketTimestamp = Date.now();
|
localStorage.setItem("basketTimestamp", Date.now().toString());
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -56,7 +58,7 @@ document.addEventListener("alpine:init", () => {
|
|||||||
*/
|
*/
|
||||||
getTotal() {
|
getTotal() {
|
||||||
return this.basket.reduce(
|
return this.basket.reduce(
|
||||||
(acc: number, item: BasketItem) => acc + item.quantity * item.unit_price,
|
(acc: number, item: BasketItem) => acc + item.quantity * item.unitPrice,
|
||||||
0,
|
0,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -74,7 +76,7 @@ document.addEventListener("alpine:init", () => {
|
|||||||
* @param itemId the id of the item to remove
|
* @param itemId the id of the item to remove
|
||||||
*/
|
*/
|
||||||
remove(itemId: number) {
|
remove(itemId: number) {
|
||||||
const index = this.basket.findIndex((e: BasketItem) => e.id === itemId);
|
const index = this.basket.findIndex((e: BasketItem) => e.priceId === itemId);
|
||||||
|
|
||||||
if (index < 0) {
|
if (index < 0) {
|
||||||
return;
|
return;
|
||||||
@@ -83,7 +85,7 @@ document.addEventListener("alpine:init", () => {
|
|||||||
|
|
||||||
if (this.basket[index].quantity === 0) {
|
if (this.basket[index].quantity === 0) {
|
||||||
this.basket = this.basket.filter(
|
this.basket = this.basket.filter(
|
||||||
(e: BasketItem) => e.id !== this.basket[index].id,
|
(e: BasketItem) => e.priceId !== this.basket[index].id,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -104,11 +106,10 @@ document.addEventListener("alpine:init", () => {
|
|||||||
*/
|
*/
|
||||||
createItem(id: number, name: string, price: number): BasketItem {
|
createItem(id: number, name: string, price: number): BasketItem {
|
||||||
const newItem = {
|
const newItem = {
|
||||||
id,
|
priceId: id,
|
||||||
name,
|
name,
|
||||||
quantity: 0,
|
quantity: 0,
|
||||||
// biome-ignore lint/style/useNamingConvention: the python code is snake_case
|
unitPrice: price,
|
||||||
unit_price: price,
|
|
||||||
} as BasketItem;
|
} as BasketItem;
|
||||||
|
|
||||||
this.basket.push(newItem);
|
this.basket.push(newItem);
|
||||||
@@ -125,7 +126,7 @@ document.addEventListener("alpine:init", () => {
|
|||||||
* @param price The unit price of the product
|
* @param price The unit price of the product
|
||||||
*/
|
*/
|
||||||
addFromCatalog(id: number, name: string, price: number) {
|
addFromCatalog(id: number, name: string, price: number) {
|
||||||
let item = this.basket.find((e: BasketItem) => e.id === id);
|
let item = this.basket.find((e: BasketItem) => e.priceId === id);
|
||||||
|
|
||||||
// if the item is not in the basket, we create it
|
// if the item is not in the basket, we create it
|
||||||
// else we add + 1 to it
|
// else we add + 1 to it
|
||||||
|
|||||||
@@ -32,9 +32,9 @@
|
|||||||
<tbody>
|
<tbody>
|
||||||
{% for item in basket.items.all() %}
|
{% for item in basket.items.all() %}
|
||||||
<tr>
|
<tr>
|
||||||
<td>{{ item.product_name }}</td>
|
<td>{{ item.label }}</td>
|
||||||
<td>{{ item.quantity }}</td>
|
<td>{{ item.quantity }}</td>
|
||||||
<td>{{ item.product_unit_price }} €</td>
|
<td>{{ item.unit_price }} €</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|||||||
@@ -41,7 +41,7 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<ul class="item-list">
|
<ul class="item-list">
|
||||||
{# Starting money #}
|
{# Starting money #}
|
||||||
<li>
|
<li>
|
||||||
<span class="item-name">
|
<span class="item-name">
|
||||||
<strong>{% trans %}Current account amount: {% endtrans %}</strong>
|
<strong>{% trans %}Current account amount: {% endtrans %}</strong>
|
||||||
@@ -51,15 +51,15 @@
|
|||||||
</span>
|
</span>
|
||||||
</li>
|
</li>
|
||||||
|
|
||||||
<template x-for="(item, index) in Object.values(basket)" :key="item.id">
|
<template x-for="(item, index) in Object.values(basket)" :key="item.priceId">
|
||||||
<li class="item-row" x-show="item.quantity > 0">
|
<li class="item-row" x-show="item.quantity > 0">
|
||||||
<div class="item-quantity">
|
<div class="item-quantity">
|
||||||
<i class="fa fa-minus fa-xs" @click="remove(item.id)"></i>
|
<i class="fa fa-minus fa-xs" @click="remove(item.priceId)"></i>
|
||||||
<span x-text="item.quantity"></span>
|
<span x-text="item.quantity"></span>
|
||||||
<i class="fa fa-plus" @click="add(item)"></i>
|
<i class="fa fa-plus" @click="add(item)"></i>
|
||||||
</div>
|
</div>
|
||||||
<span class="item-name" x-text="item.name"></span>
|
<span class="item-name" x-text="item.name"></span>
|
||||||
<span class="item-price" x-text="(item.unit_price * item.quantity).toFixed(2) + ' €'"></span>
|
<span class="item-price" x-text="(item.unitPrice * item.quantity).toFixed(2) + ' €'"></span>
|
||||||
|
|
||||||
<input
|
<input
|
||||||
type="hidden"
|
type="hidden"
|
||||||
@@ -71,16 +71,16 @@
|
|||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
type="hidden"
|
type="hidden"
|
||||||
:value="item.id"
|
:value="item.priceId"
|
||||||
:id="`id_form-${index}-id`"
|
:id="`id_form-${index}-price_id`"
|
||||||
:name="`form-${index}-id`"
|
:name="`form-${index}-price_id`"
|
||||||
required
|
required
|
||||||
readonly
|
readonly
|
||||||
>
|
>
|
||||||
|
|
||||||
</li>
|
</li>
|
||||||
</template>
|
</template>
|
||||||
{# Total price #}
|
{# Total price #}
|
||||||
<li style="margin-top: 20px">
|
<li style="margin-top: 20px">
|
||||||
<span class="item-name"><strong>{% trans %}Basket amount: {% endtrans %}</strong></span>
|
<span class="item-name"><strong>{% trans %}Basket amount: {% endtrans %}</strong></span>
|
||||||
<span x-text="getTotal().toFixed(2) + ' €'" class="item-price"></span>
|
<span x-text="getTotal().toFixed(2) + ' €'" class="item-price"></span>
|
||||||
@@ -116,45 +116,40 @@
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% for priority_groups in products|groupby('order') %}
|
{% for prices in categories %}
|
||||||
{% for category, items in priority_groups.list|groupby('category') %}
|
{% set category = prices[0].product.product_type %}
|
||||||
{% if items|count > 0 %}
|
<section>
|
||||||
<section>
|
<div class="category-header">
|
||||||
{# I would have wholeheartedly directly used the header element instead
|
<h3>{{ category.name }}</h3>
|
||||||
but it has already been made messy in core/style.scss #}
|
{% if category.comment %}
|
||||||
<div class="category-header">
|
<p><i>{{ category.comment }}</i></p>
|
||||||
<h3>{{ category }}</h3>
|
{% endif %}
|
||||||
{% if items[0].category_comment %}
|
</div>
|
||||||
<p><i>{{ items[0].category_comment }}</i></p>
|
<div class="product-group">
|
||||||
{% endif %}
|
{% for price in prices %}
|
||||||
</div>
|
<button
|
||||||
<div class="product-group">
|
id="{{ price.id }}"
|
||||||
{% for p in items %}
|
class="card product-button clickable shadow"
|
||||||
<button
|
:class="{selected: basket.some((i) => i.priceId === {{ price.id }})}"
|
||||||
id="{{ p.id }}"
|
@click='addFromCatalog({{ price.id }}, {{ price.full_label|tojson }}, {{ price.amount }})'
|
||||||
class="card product-button clickable shadow"
|
>
|
||||||
:class="{selected: basket.some((i) => i.id === {{ p.id }})}"
|
{% if price.product.icon %}
|
||||||
@click='addFromCatalog({{ p.id }}, {{ p.name|tojson }}, {{ p.selling_price }})'
|
<img
|
||||||
|
class="card-image"
|
||||||
|
src="{{ price.product.icon.url }}"
|
||||||
|
alt="image de {{ price.full_label }}"
|
||||||
>
|
>
|
||||||
{% if p.icon %}
|
{% else %}
|
||||||
<img
|
<i class="fa-regular fa-image fa-2x card-image"></i>
|
||||||
class="card-image"
|
{% endif %}
|
||||||
src="{{ p.icon.url }}"
|
<div class="card-content">
|
||||||
alt="image de {{ p.name }}"
|
<h4 class="card-title">{{ price.full_label }}</h4>
|
||||||
>
|
<p>{{ price.amount }} €</p>
|
||||||
{% else %}
|
</div>
|
||||||
<i class="fa-regular fa-image fa-2x card-image"></i>
|
</button>
|
||||||
{% endif %}
|
{% endfor %}
|
||||||
<div class="card-content">
|
</div>
|
||||||
<h4 class="card-title">{{ p.name }}</h4>
|
</section>
|
||||||
<p>{{ p.selling_price }} €</p>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
{% endif %}
|
|
||||||
{% endfor %}
|
|
||||||
{% else %}
|
{% else %}
|
||||||
<p>{% trans %}There are no items available for sale{% endtrans %}</p>
|
<p>{% trans %}There are no items available for sale{% endtrans %}</p>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|||||||
@@ -11,7 +11,12 @@ from pytest_django.asserts import assertRedirects
|
|||||||
|
|
||||||
from core.baker_recipes import subscriber_user
|
from core.baker_recipes import subscriber_user
|
||||||
from core.models import Group, User
|
from core.models import Group, User
|
||||||
from counter.baker_recipes import product_recipe, refill_recipe, sale_recipe
|
from counter.baker_recipes import (
|
||||||
|
price_recipe,
|
||||||
|
product_recipe,
|
||||||
|
refill_recipe,
|
||||||
|
sale_recipe,
|
||||||
|
)
|
||||||
from counter.models import (
|
from counter.models import (
|
||||||
Counter,
|
Counter,
|
||||||
Customer,
|
Customer,
|
||||||
@@ -147,29 +152,29 @@ class TestEboutic(TestCase):
|
|||||||
|
|
||||||
product_type = baker.make(ProductType)
|
product_type = baker.make(ProductType)
|
||||||
|
|
||||||
cls.snack = product_recipe.make(
|
cls.snack = price_recipe.make(
|
||||||
selling_price=1.5, special_selling_price=1, product_type=product_type
|
amount=1.5, product=product_recipe.make(product_type=product_type)
|
||||||
)
|
)
|
||||||
cls.beer = product_recipe.make(
|
cls.beer = price_recipe.make(
|
||||||
limit_age=18,
|
product=product_recipe.make(limit_age=18, product_type=product_type),
|
||||||
selling_price=2.5,
|
amount=2.5,
|
||||||
special_selling_price=1,
|
|
||||||
product_type=product_type,
|
|
||||||
)
|
)
|
||||||
cls.not_in_counter = product_recipe.make(
|
cls.not_in_counter = price_recipe.make(
|
||||||
selling_price=3.5, product_type=product_type
|
product=product_recipe.make(product_type=product_type), amount=3.5
|
||||||
|
)
|
||||||
|
cls.cotiz = price_recipe.make(
|
||||||
|
amount=10, product=product_recipe.make(product_type=product_type)
|
||||||
)
|
)
|
||||||
cls.cotiz = product_recipe.make(selling_price=10, product_type=product_type)
|
|
||||||
|
|
||||||
cls.group_public.products.add(cls.snack, cls.beer, cls.not_in_counter)
|
cls.group_public.prices.add(cls.snack, cls.beer, cls.not_in_counter)
|
||||||
cls.group_cotiz.products.add(cls.cotiz)
|
cls.group_cotiz.prices.add(cls.cotiz)
|
||||||
|
|
||||||
cls.subscriber.groups.add(cls.group_cotiz, cls.group_public)
|
cls.subscriber.groups.add(cls.group_cotiz, cls.group_public)
|
||||||
cls.new_customer.groups.add(cls.group_public)
|
cls.new_customer.groups.add(cls.group_public)
|
||||||
cls.new_customer_adult.groups.add(cls.group_public)
|
cls.new_customer_adult.groups.add(cls.group_public)
|
||||||
|
|
||||||
cls.eboutic = get_eboutic()
|
cls.eboutic = get_eboutic()
|
||||||
cls.eboutic.products.add(cls.cotiz, cls.beer, cls.snack)
|
cls.eboutic.products.add(cls.cotiz.product, cls.beer.product, cls.snack.product)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def set_age(cls, user: User, age: int):
|
def set_age(cls, user: User, age: int):
|
||||||
@@ -253,7 +258,7 @@ class TestEboutic(TestCase):
|
|||||||
self.submit_basket([BasketItem(self.snack.id, 2)]),
|
self.submit_basket([BasketItem(self.snack.id, 2)]),
|
||||||
reverse("eboutic:checkout", kwargs={"basket_id": 1}),
|
reverse("eboutic:checkout", kwargs={"basket_id": 1}),
|
||||||
)
|
)
|
||||||
assert Basket.objects.get(id=1).total == self.snack.selling_price * 2
|
assert Basket.objects.get(id=1).total == self.snack.amount * 2
|
||||||
|
|
||||||
self.client.force_login(self.new_customer_adult)
|
self.client.force_login(self.new_customer_adult)
|
||||||
assertRedirects(
|
assertRedirects(
|
||||||
@@ -263,8 +268,7 @@ class TestEboutic(TestCase):
|
|||||||
reverse("eboutic:checkout", kwargs={"basket_id": 2}),
|
reverse("eboutic:checkout", kwargs={"basket_id": 2}),
|
||||||
)
|
)
|
||||||
assert (
|
assert (
|
||||||
Basket.objects.get(id=2).total
|
Basket.objects.get(id=2).total == self.snack.amount * 2 + self.beer.amount
|
||||||
== self.snack.selling_price * 2 + self.beer.selling_price
|
|
||||||
)
|
)
|
||||||
|
|
||||||
self.client.force_login(self.subscriber)
|
self.client.force_login(self.subscriber)
|
||||||
@@ -280,7 +284,5 @@ class TestEboutic(TestCase):
|
|||||||
)
|
)
|
||||||
assert (
|
assert (
|
||||||
Basket.objects.get(id=3).total
|
Basket.objects.get(id=3).total
|
||||||
== self.snack.selling_price * 2
|
== self.snack.amount * 2 + self.beer.amount + self.cotiz.amount
|
||||||
+ self.beer.selling_price
|
|
||||||
+ self.cotiz.selling_price
|
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ from model_bakery import baker
|
|||||||
from pytest_django.asserts import assertRedirects
|
from pytest_django.asserts import assertRedirects
|
||||||
|
|
||||||
from core.baker_recipes import old_subscriber_user, subscriber_user
|
from core.baker_recipes import old_subscriber_user, subscriber_user
|
||||||
from counter.baker_recipes import product_recipe
|
from counter.baker_recipes import price_recipe, product_recipe
|
||||||
from counter.models import Product, ProductType, Selling
|
from counter.models import Product, ProductType, Selling
|
||||||
from counter.tests.test_counter import force_refill_user
|
from counter.tests.test_counter import force_refill_user
|
||||||
from eboutic.models import Basket, BasketItem
|
from eboutic.models import Basket, BasketItem
|
||||||
@@ -32,23 +32,22 @@ class TestPaymentBase(TestCase):
|
|||||||
cls.basket = baker.make(Basket, user=cls.customer)
|
cls.basket = baker.make(Basket, user=cls.customer)
|
||||||
cls.refilling = product_recipe.make(
|
cls.refilling = product_recipe.make(
|
||||||
product_type_id=settings.SITH_COUNTER_PRODUCTTYPE_REFILLING,
|
product_type_id=settings.SITH_COUNTER_PRODUCTTYPE_REFILLING,
|
||||||
selling_price=15,
|
prices=[price_recipe.make(amount=15)],
|
||||||
)
|
)
|
||||||
|
|
||||||
product_type = baker.make(ProductType)
|
product_type = baker.make(ProductType)
|
||||||
|
|
||||||
cls.snack = product_recipe.make(
|
cls.snack = product_recipe.make(
|
||||||
selling_price=1.5, special_selling_price=1, product_type=product_type
|
product_type=product_type, prices=[price_recipe.make(amount=1.5)]
|
||||||
)
|
)
|
||||||
cls.beer = product_recipe.make(
|
cls.beer = product_recipe.make(
|
||||||
limit_age=18,
|
limit_age=18,
|
||||||
selling_price=2.5,
|
|
||||||
special_selling_price=1,
|
|
||||||
product_type=product_type,
|
product_type=product_type,
|
||||||
|
prices=[price_recipe.make(amount=2.5)],
|
||||||
)
|
)
|
||||||
|
|
||||||
BasketItem.from_product(cls.snack, 1, cls.basket).save()
|
BasketItem.from_price(cls.snack.prices.first(), 1, cls.basket).save()
|
||||||
BasketItem.from_product(cls.beer, 2, cls.basket).save()
|
BasketItem.from_price(cls.beer.prices.first(), 2, cls.basket).save()
|
||||||
|
|
||||||
|
|
||||||
class TestPaymentSith(TestPaymentBase):
|
class TestPaymentSith(TestPaymentBase):
|
||||||
@@ -116,13 +115,13 @@ class TestPaymentSith(TestPaymentBase):
|
|||||||
assert len(sellings) == 2
|
assert len(sellings) == 2
|
||||||
assert sellings[0].payment_method == Selling.PaymentMethod.SITH_ACCOUNT
|
assert sellings[0].payment_method == Selling.PaymentMethod.SITH_ACCOUNT
|
||||||
assert sellings[0].quantity == 1
|
assert sellings[0].quantity == 1
|
||||||
assert sellings[0].unit_price == self.snack.selling_price
|
assert sellings[0].unit_price == self.snack.prices.first().amount
|
||||||
assert sellings[0].counter.type == "EBOUTIC"
|
assert sellings[0].counter.type == "EBOUTIC"
|
||||||
assert sellings[0].product == self.snack
|
assert sellings[0].product == self.snack
|
||||||
|
|
||||||
assert sellings[1].payment_method == Selling.PaymentMethod.SITH_ACCOUNT
|
assert sellings[1].payment_method == Selling.PaymentMethod.SITH_ACCOUNT
|
||||||
assert sellings[1].quantity == 2
|
assert sellings[1].quantity == 2
|
||||||
assert sellings[1].unit_price == self.beer.selling_price
|
assert sellings[1].unit_price == self.beer.prices.first().amount
|
||||||
assert sellings[1].counter.type == "EBOUTIC"
|
assert sellings[1].counter.type == "EBOUTIC"
|
||||||
assert sellings[1].product == self.beer
|
assert sellings[1].product == self.beer
|
||||||
|
|
||||||
@@ -146,7 +145,7 @@ class TestPaymentSith(TestPaymentBase):
|
|||||||
)
|
)
|
||||||
|
|
||||||
def test_refilling_in_basket(self):
|
def test_refilling_in_basket(self):
|
||||||
BasketItem.from_product(self.refilling, 1, self.basket).save()
|
BasketItem.from_price(self.refilling.prices.first(), 1, self.basket).save()
|
||||||
self.client.force_login(self.customer)
|
self.client.force_login(self.customer)
|
||||||
force_refill_user(self.customer, self.basket.total + 1)
|
force_refill_user(self.customer, self.basket.total + 1)
|
||||||
self.customer.customer.refresh_from_db()
|
self.customer.customer.refresh_from_db()
|
||||||
@@ -191,8 +190,8 @@ class TestPaymentCard(TestPaymentBase):
|
|||||||
def test_buy_success(self):
|
def test_buy_success(self):
|
||||||
response = self.client.get(self.generate_bank_valid_answer(self.basket))
|
response = self.client.get(self.generate_bank_valid_answer(self.basket))
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert response.content.decode("utf-8") == "Payment successful"
|
assert response.content.decode() == "Payment successful"
|
||||||
assert Basket.objects.filter(id=self.basket.id).first() is None
|
assert not Basket.objects.filter(id=self.basket.id).exists()
|
||||||
|
|
||||||
sellings = Selling.objects.filter(customer=self.customer.customer).order_by(
|
sellings = Selling.objects.filter(customer=self.customer.customer).order_by(
|
||||||
"quantity"
|
"quantity"
|
||||||
@@ -200,13 +199,13 @@ class TestPaymentCard(TestPaymentBase):
|
|||||||
assert len(sellings) == 2
|
assert len(sellings) == 2
|
||||||
assert sellings[0].payment_method == Selling.PaymentMethod.CARD
|
assert sellings[0].payment_method == Selling.PaymentMethod.CARD
|
||||||
assert sellings[0].quantity == 1
|
assert sellings[0].quantity == 1
|
||||||
assert sellings[0].unit_price == self.snack.selling_price
|
assert sellings[0].unit_price == self.snack.prices.first().amount
|
||||||
assert sellings[0].counter.type == "EBOUTIC"
|
assert sellings[0].counter.type == "EBOUTIC"
|
||||||
assert sellings[0].product == self.snack
|
assert sellings[0].product == self.snack
|
||||||
|
|
||||||
assert sellings[1].payment_method == Selling.PaymentMethod.CARD
|
assert sellings[1].payment_method == Selling.PaymentMethod.CARD
|
||||||
assert sellings[1].quantity == 2
|
assert sellings[1].quantity == 2
|
||||||
assert sellings[1].unit_price == self.beer.selling_price
|
assert sellings[1].unit_price == self.beer.prices.first().amount
|
||||||
assert sellings[1].counter.type == "EBOUTIC"
|
assert sellings[1].counter.type == "EBOUTIC"
|
||||||
assert sellings[1].product == self.beer
|
assert sellings[1].product == self.beer
|
||||||
|
|
||||||
@@ -216,7 +215,9 @@ class TestPaymentCard(TestPaymentBase):
|
|||||||
assert not customer.subscriptions.first().is_valid_now()
|
assert not customer.subscriptions.first().is_valid_now()
|
||||||
|
|
||||||
basket = baker.make(Basket, user=customer)
|
basket = baker.make(Basket, user=customer)
|
||||||
BasketItem.from_product(Product.objects.get(code="2SCOTIZ"), 1, basket).save()
|
BasketItem.from_price(
|
||||||
|
Product.objects.get(code="2SCOTIZ").prices.first(), 1, basket
|
||||||
|
).save()
|
||||||
response = self.client.get(self.generate_bank_valid_answer(basket))
|
response = self.client.get(self.generate_bank_valid_answer(basket))
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
|
|
||||||
@@ -228,12 +229,13 @@ class TestPaymentCard(TestPaymentBase):
|
|||||||
assert subscription.location == "EBOUTIC"
|
assert subscription.location == "EBOUTIC"
|
||||||
|
|
||||||
def test_buy_refilling(self):
|
def test_buy_refilling(self):
|
||||||
BasketItem.from_product(self.refilling, 2, self.basket).save()
|
price = self.refilling.prices.first()
|
||||||
|
BasketItem.from_price(price, 2, self.basket).save()
|
||||||
response = self.client.get(self.generate_bank_valid_answer(self.basket))
|
response = self.client.get(self.generate_bank_valid_answer(self.basket))
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
|
|
||||||
self.customer.customer.refresh_from_db()
|
self.customer.customer.refresh_from_db()
|
||||||
assert self.customer.customer.amount == self.refilling.selling_price * 2
|
assert self.customer.customer.amount == price.amount * 2
|
||||||
|
|
||||||
def test_multiple_responses(self):
|
def test_multiple_responses(self):
|
||||||
bank_response = self.generate_bank_valid_answer(self.basket)
|
bank_response = self.generate_bank_valid_answer(self.basket)
|
||||||
@@ -253,17 +255,17 @@ class TestPaymentCard(TestPaymentBase):
|
|||||||
self.basket.delete()
|
self.basket.delete()
|
||||||
response = self.client.get(bank_response)
|
response = self.client.get(bank_response)
|
||||||
assert response.status_code == 500
|
assert response.status_code == 500
|
||||||
assert (
|
assert response.text == (
|
||||||
response.text
|
"Basket processing failed with error: "
|
||||||
== "Basket processing failed with error: SuspiciousOperation('Basket does not exists')"
|
"SuspiciousOperation('Basket does not exists')"
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_altered_basket(self):
|
def test_altered_basket(self):
|
||||||
bank_response = self.generate_bank_valid_answer(self.basket)
|
bank_response = self.generate_bank_valid_answer(self.basket)
|
||||||
BasketItem.from_product(self.snack, 1, self.basket).save()
|
BasketItem.from_price(self.snack.prices.first(), 1, self.basket).save()
|
||||||
response = self.client.get(bank_response)
|
response = self.client.get(bank_response)
|
||||||
assert response.status_code == 500
|
assert response.status_code == 500
|
||||||
assert (
|
assert response.text == (
|
||||||
response.text == "Basket processing failed with error: "
|
"Basket processing failed with error: "
|
||||||
"SuspiciousOperation('Basket total and amount do not match')"
|
"SuspiciousOperation('Basket total and amount do not match')"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import base64
|
import base64
|
||||||
import contextlib
|
import contextlib
|
||||||
|
import itertools
|
||||||
import json
|
import json
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
@@ -28,9 +29,7 @@ from cryptography.hazmat.primitives.serialization import load_pem_public_key
|
|||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.contrib import messages
|
from django.contrib import messages
|
||||||
from django.contrib.auth.decorators import login_required
|
from django.contrib.auth.decorators import login_required
|
||||||
from django.contrib.auth.mixins import (
|
from django.contrib.auth.mixins import LoginRequiredMixin
|
||||||
LoginRequiredMixin,
|
|
||||||
)
|
|
||||||
from django.contrib.messages.views import SuccessMessageMixin
|
from django.contrib.messages.views import SuccessMessageMixin
|
||||||
from django.core.exceptions import SuspiciousOperation, ValidationError
|
from django.core.exceptions import SuspiciousOperation, ValidationError
|
||||||
from django.db import DatabaseError, transaction
|
from django.db import DatabaseError, transaction
|
||||||
@@ -48,23 +47,16 @@ from django_countries.fields import Country
|
|||||||
|
|
||||||
from core.auth.mixins import CanViewMixin
|
from core.auth.mixins import CanViewMixin
|
||||||
from core.views.mixins import FragmentMixin, UseFragmentsMixin
|
from core.views.mixins import FragmentMixin, UseFragmentsMixin
|
||||||
from counter.forms import BaseBasketForm, BasketProductForm, BillingInfoForm
|
from counter.forms import BaseBasketForm, BasketItemForm, BillingInfoForm
|
||||||
from counter.models import (
|
from counter.models import (
|
||||||
BillingInfo,
|
BillingInfo,
|
||||||
Customer,
|
Customer,
|
||||||
Product,
|
Price,
|
||||||
Refilling,
|
Refilling,
|
||||||
Selling,
|
Selling,
|
||||||
get_eboutic,
|
get_eboutic,
|
||||||
)
|
)
|
||||||
from eboutic.models import (
|
from eboutic.models import Basket, BasketItem, BillingInfoState, Invoice, InvoiceItem
|
||||||
Basket,
|
|
||||||
BasketItem,
|
|
||||||
BillingInfoState,
|
|
||||||
Invoice,
|
|
||||||
InvoiceItem,
|
|
||||||
get_eboutic_products,
|
|
||||||
)
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from cryptography.hazmat.primitives.asymmetric.rsa import RSAPublicKey
|
from cryptography.hazmat.primitives.asymmetric.rsa import RSAPublicKey
|
||||||
@@ -78,7 +70,7 @@ class BaseEbouticBasketForm(BaseBasketForm):
|
|||||||
|
|
||||||
|
|
||||||
EbouticBasketForm = forms.formset_factory(
|
EbouticBasketForm = forms.formset_factory(
|
||||||
BasketProductForm, formset=BaseEbouticBasketForm, absolute_max=None, min_num=1
|
BasketItemForm, formset=BaseEbouticBasketForm, absolute_max=None, min_num=1
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -88,7 +80,6 @@ class EbouticMainView(LoginRequiredMixin, FormView):
|
|||||||
The purchasable products are those of the eboutic which
|
The purchasable products are those of the eboutic which
|
||||||
belong to a category of products of a product category
|
belong to a category of products of a product category
|
||||||
(orphan products are inaccessible).
|
(orphan products are inaccessible).
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
template_name = "eboutic/eboutic_main.jinja"
|
template_name = "eboutic/eboutic_main.jinja"
|
||||||
@@ -99,7 +90,7 @@ class EbouticMainView(LoginRequiredMixin, FormView):
|
|||||||
kwargs["form_kwargs"] = {
|
kwargs["form_kwargs"] = {
|
||||||
"customer": self.customer,
|
"customer": self.customer,
|
||||||
"counter": get_eboutic(),
|
"counter": get_eboutic(),
|
||||||
"allowed_products": {product.id: product for product in self.products},
|
"allowed_prices": {price.id: price for price in self.prices},
|
||||||
}
|
}
|
||||||
return kwargs
|
return kwargs
|
||||||
|
|
||||||
@@ -110,19 +101,25 @@ class EbouticMainView(LoginRequiredMixin, FormView):
|
|||||||
|
|
||||||
with transaction.atomic():
|
with transaction.atomic():
|
||||||
self.basket = Basket.objects.create(user=self.request.user)
|
self.basket = Basket.objects.create(user=self.request.user)
|
||||||
for form in formset:
|
BasketItem.objects.bulk_create(
|
||||||
BasketItem.from_product(
|
[
|
||||||
form.product, form.cleaned_data["quantity"], self.basket
|
BasketItem.from_price(
|
||||||
).save()
|
form.price, form.cleaned_data["quantity"], self.basket
|
||||||
self.basket.save()
|
)
|
||||||
|
for form in formset
|
||||||
|
]
|
||||||
|
)
|
||||||
return super().form_valid(formset)
|
return super().form_valid(formset)
|
||||||
|
|
||||||
def get_success_url(self):
|
def get_success_url(self):
|
||||||
return reverse("eboutic:checkout", kwargs={"basket_id": self.basket.id})
|
return reverse("eboutic:checkout", kwargs={"basket_id": self.basket.id})
|
||||||
|
|
||||||
@cached_property
|
@cached_property
|
||||||
def products(self) -> list[Product]:
|
def prices(self) -> list[Price]:
|
||||||
return get_eboutic_products(self.request.user)
|
return get_eboutic().get_prices_for(
|
||||||
|
self.customer,
|
||||||
|
order_by=["product__product_type__order", "product_id", "amount"],
|
||||||
|
)
|
||||||
|
|
||||||
@cached_property
|
@cached_property
|
||||||
def customer(self) -> Customer:
|
def customer(self) -> Customer:
|
||||||
@@ -130,7 +127,12 @@ class EbouticMainView(LoginRequiredMixin, FormView):
|
|||||||
|
|
||||||
def get_context_data(self, **kwargs):
|
def get_context_data(self, **kwargs):
|
||||||
context = super().get_context_data(**kwargs)
|
context = super().get_context_data(**kwargs)
|
||||||
context["products"] = self.products
|
context["categories"] = [
|
||||||
|
list(i[1])
|
||||||
|
for i in itertools.groupby(
|
||||||
|
self.prices, key=lambda p: p.product.product_type_id
|
||||||
|
)
|
||||||
|
]
|
||||||
context["customer_amount"] = self.request.user.account_balance
|
context["customer_amount"] = self.request.user.account_balance
|
||||||
|
|
||||||
purchases = (
|
purchases = (
|
||||||
@@ -267,11 +269,8 @@ class EbouticPayWithSith(CanViewMixin, SingleObjectMixin, View):
|
|||||||
def post(self, request, *args, **kwargs):
|
def post(self, request, *args, **kwargs):
|
||||||
basket = self.get_object()
|
basket = self.get_object()
|
||||||
refilling = settings.SITH_COUNTER_PRODUCTTYPE_REFILLING
|
refilling = settings.SITH_COUNTER_PRODUCTTYPE_REFILLING
|
||||||
if basket.items.filter(type_id=refilling).exists():
|
if basket.items.filter(product__product_type_id=refilling).exists():
|
||||||
messages.error(
|
messages.error(self.request, _("You can't buy a refilling with sith money"))
|
||||||
self.request,
|
|
||||||
_("You can't buy a refilling with sith money"),
|
|
||||||
)
|
|
||||||
return redirect("eboutic:payment_result", "failure")
|
return redirect("eboutic:payment_result", "failure")
|
||||||
|
|
||||||
eboutic = get_eboutic()
|
eboutic = get_eboutic()
|
||||||
@@ -326,22 +325,23 @@ class EtransactionAutoAnswer(View):
|
|||||||
raise SuspiciousOperation(
|
raise SuspiciousOperation(
|
||||||
"Basket total and amount do not match"
|
"Basket total and amount do not match"
|
||||||
)
|
)
|
||||||
i = Invoice()
|
i = Invoice.objects.create(user=b.user)
|
||||||
i.user = b.user
|
InvoiceItem.objects.bulk_create(
|
||||||
i.payment_method = "CARD"
|
[
|
||||||
i.save()
|
InvoiceItem(
|
||||||
for it in b.items.all():
|
invoice=i,
|
||||||
InvoiceItem(
|
product_id=item.product_id,
|
||||||
invoice=i,
|
label=item.label,
|
||||||
product_id=it.product_id,
|
unit_price=item.unit_price,
|
||||||
product_name=it.product_name,
|
quantity=item.quantity,
|
||||||
type_id=it.type_id,
|
)
|
||||||
product_unit_price=it.product_unit_price,
|
for item in b.items.all()
|
||||||
quantity=it.quantity,
|
]
|
||||||
).save()
|
)
|
||||||
i.validate()
|
i.validate()
|
||||||
b.delete()
|
b.delete()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
sentry_sdk.capture_exception(e)
|
||||||
return HttpResponse(
|
return HttpResponse(
|
||||||
"Basket processing failed with error: " + repr(e), status=500
|
"Basket processing failed with error: " + repr(e), status=500
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
from cryptography.utils import cached_property
|
from cryptography.utils import cached_property
|
||||||
from django.conf import settings
|
|
||||||
from django.contrib import messages
|
from django.contrib import messages
|
||||||
from django.contrib.auth.mixins import (
|
from django.contrib.auth.mixins import (
|
||||||
LoginRequiredMixin,
|
LoginRequiredMixin,
|
||||||
@@ -115,16 +114,9 @@ class VoteFormView(LoginRequiredMixin, UserPassesTestMixin, FormView):
|
|||||||
def test_func(self):
|
def test_func(self):
|
||||||
if not self.election.can_vote(self.request.user):
|
if not self.election.can_vote(self.request.user):
|
||||||
return False
|
return False
|
||||||
|
return self.election.vote_groups.filter(
|
||||||
groups = set(self.election.vote_groups.values_list("id", flat=True))
|
id__in=self.request.user.all_groups
|
||||||
if (
|
).exists()
|
||||||
settings.SITH_GROUP_SUBSCRIBERS_ID in groups
|
|
||||||
and self.request.user.is_subscribed
|
|
||||||
):
|
|
||||||
# the subscriber group isn't truly attached to users,
|
|
||||||
# so it must be dealt with separately
|
|
||||||
return True
|
|
||||||
return self.request.user.groups.filter(id__in=groups).exists()
|
|
||||||
|
|
||||||
def vote(self, election_data):
|
def vote(self, election_data):
|
||||||
with transaction.atomic():
|
with transaction.atomic():
|
||||||
@@ -238,15 +230,9 @@ class RoleCreateView(LoginRequiredMixin, UserPassesTestMixin, CreateView):
|
|||||||
return False
|
return False
|
||||||
if self.request.user.has_perm("election.add_role"):
|
if self.request.user.has_perm("election.add_role"):
|
||||||
return True
|
return True
|
||||||
groups = set(self.election.edit_groups.values_list("id", flat=True))
|
return self.election.edit_groups.filter(
|
||||||
if (
|
id__in=self.request.user.all_groups
|
||||||
settings.SITH_GROUP_SUBSCRIBERS_ID in groups
|
).exists()
|
||||||
and self.request.user.is_subscribed
|
|
||||||
):
|
|
||||||
# the subscriber group isn't truly attached to users,
|
|
||||||
# so it must be dealt with separately
|
|
||||||
return True
|
|
||||||
return self.request.user.groups.filter(id__in=groups).exists()
|
|
||||||
|
|
||||||
def get_initial(self):
|
def get_initial(self):
|
||||||
return {"election": self.election}
|
return {"election": self.election}
|
||||||
@@ -279,14 +265,7 @@ class ElectionListCreateView(LoginRequiredMixin, UserPassesTestMixin, CreateView
|
|||||||
.union(self.election.edit_groups.values("id"))
|
.union(self.election.edit_groups.values("id"))
|
||||||
.values_list("id", flat=True)
|
.values_list("id", flat=True)
|
||||||
)
|
)
|
||||||
if (
|
return not groups.isdisjoint(self.request.user.all_groups.keys())
|
||||||
settings.SITH_GROUP_SUBSCRIBERS_ID in groups
|
|
||||||
and self.request.user.is_subscribed
|
|
||||||
):
|
|
||||||
# the subscriber group isn't truly attached to users,
|
|
||||||
# so it must be dealt with separately
|
|
||||||
return True
|
|
||||||
return self.request.user.groups.filter(id__in=groups).exists()
|
|
||||||
|
|
||||||
def get_initial(self):
|
def get_initial(self):
|
||||||
return {"election": self.election}
|
return {"election": self.election}
|
||||||
|
|||||||
@@ -25,12 +25,13 @@ 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, User
|
from core.models import Group, Page, SithFile, 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
|
||||||
@@ -90,8 +91,13 @@ 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", owner=root, is_moderated=True
|
name="galaxy-register-file",
|
||||||
|
owner=root,
|
||||||
|
is_moderated=True,
|
||||||
|
is_in_sas=True,
|
||||||
|
parent=sas,
|
||||||
)
|
)
|
||||||
|
|
||||||
self.make_clubs()
|
self.make_clubs()
|
||||||
@@ -279,10 +285,14 @@ 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,
|
||||||
original=ContentFile(RED_PIXEL_PNG),
|
is_in_sas=True,
|
||||||
|
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
@@ -33,8 +33,6 @@ class TestMergeUser(TestCase):
|
|||||||
cls.club = baker.make(Club)
|
cls.club = baker.make(Club)
|
||||||
cls.eboutic = Counter.objects.get(name="Eboutic")
|
cls.eboutic = Counter.objects.get(name="Eboutic")
|
||||||
cls.barbar = Product.objects.get(code="BARB")
|
cls.barbar = Product.objects.get(code="BARB")
|
||||||
cls.barbar.selling_price = 2
|
|
||||||
cls.barbar.save()
|
|
||||||
cls.root = User.objects.get(username="root")
|
cls.root = User.objects.get(username="root")
|
||||||
cls.to_keep = User.objects.create(
|
cls.to_keep = User.objects.create(
|
||||||
username="to_keep", password="plop", email="u.1@utbm.fr"
|
username="to_keep", password="plop", email="u.1@utbm.fr"
|
||||||
|
|||||||
@@ -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", "is_moderated")
|
list_display = ("name", "parent", "date", "size", "is_moderated")
|
||||||
search_fields = ("name",)
|
search_fields = ("name",)
|
||||||
autocomplete_fields = ("owner", "parent", "moderator")
|
autocomplete_fields = ("owner", "parent", "edit_groups", "view_groups", "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")
|
list_display = ("name", "parent", "date", "owner", "is_moderated")
|
||||||
search_fields = ("name",)
|
search_fields = ("name",)
|
||||||
autocomplete_fields = ("parent", "edit_groups", "view_groups")
|
autocomplete_fields = ("owner", "parent", "edit_groups", "view_groups")
|
||||||
|
|
||||||
|
|
||||||
@admin.register(PictureModerationRequest)
|
@admin.register(PictureModerationRequest)
|
||||||
|
|||||||
65
sas/api.py
65
sas/api.py
@@ -3,8 +3,7 @@ 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, Query, UploadedFile
|
from ninja import Body, File, Query
|
||||||
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
|
||||||
@@ -17,12 +16,11 @@ 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.utils import get_list_exact_or_404
|
from core.schemas import UploadedImage
|
||||||
from sas.models import Album, PeoplePictureRelation, Picture
|
from sas.models import Album, PeoplePictureRelation, Picture
|
||||||
from sas.schemas import (
|
from sas.schemas import (
|
||||||
AlbumAutocompleteSchema,
|
AlbumAutocompleteSchema,
|
||||||
@@ -30,7 +28,6 @@ from sas.schemas import (
|
|||||||
AlbumSchema,
|
AlbumSchema,
|
||||||
IdentifiedUserSchema,
|
IdentifiedUserSchema,
|
||||||
ModerationRequestSchema,
|
ModerationRequestSchema,
|
||||||
MoveAlbumSchema,
|
|
||||||
PictureFilterSchema,
|
PictureFilterSchema,
|
||||||
PictureSchema,
|
PictureSchema,
|
||||||
)
|
)
|
||||||
@@ -72,44 +69,6 @@ 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):
|
||||||
@@ -137,7 +96,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__event_date", "created_at")
|
.order_by("-parent__date", "date")
|
||||||
.select_related("owner", "parent")
|
.select_related("owner", "parent")
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -151,25 +110,27 @@ class PicturesController(ControllerBase):
|
|||||||
},
|
},
|
||||||
url_name="upload_picture",
|
url_name="upload_picture",
|
||||||
)
|
)
|
||||||
def upload_picture(self, album_id: Body[int], picture: UploadedFile):
|
def upload_picture(self, album_id: Body[int], picture: File[UploadedImage]):
|
||||||
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,
|
||||||
original=picture,
|
file=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:
|
||||||
raise HttpError(status_code=409, message=str(e)) from e
|
return self.create_response({"detail": dict(e)}, status_code=409)
|
||||||
new.save()
|
|
||||||
|
|
||||||
@route.get(
|
@route.get(
|
||||||
"/{picture_id}/identified",
|
"/{picture_id}/identified",
|
||||||
@@ -254,9 +215,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()
|
||||||
|
|||||||
@@ -1,35 +1,18 @@
|
|||||||
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 core.utils import RED_PIXEL_PNG
|
from sas.models import Picture
|
||||||
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
|
||||||
|
"""
|
||||||
|
|||||||
@@ -48,12 +48,13 @@ class PictureEditForm(forms.ModelForm):
|
|||||||
class AlbumEditForm(forms.ModelForm):
|
class AlbumEditForm(forms.ModelForm):
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Album
|
model = Album
|
||||||
fields = ["name", "date", "thumbnail", "parent", "edit_groups"]
|
fields = ["name", "date", "file", "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)
|
||||||
|
|
||||||
|
|||||||
@@ -1,357 +0,0 @@
|
|||||||
# 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,
|
|
||||||
),
|
|
||||||
]
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
# 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",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
408
sas/models.py
408
sas/models.py
@@ -18,57 +18,29 @@ 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 TYPE_CHECKING, ClassVar, Self
|
from typing import 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 Group, Notification, User
|
from core.models import Notification, SithFile, 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.
|
||||||
|
|
||||||
def get_directory(instance: SasFile, filename: str):
|
May be used to have logic that should be shared by both
|
||||||
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:
|
||||||
abstract = True
|
proxy = 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"),
|
||||||
@@ -93,169 +65,6 @@ class SasFile(models.Model):
|
|||||||
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:
|
||||||
@@ -271,65 +80,23 @@ 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:
|
||||||
verbose_name = _("picture")
|
proxy = True
|
||||||
constraints = [
|
|
||||||
models.UniqueConstraint(
|
|
||||||
fields=["name", "parent"], name="sas_picture_unique_per_album"
|
|
||||||
)
|
|
||||||
]
|
|
||||||
|
|
||||||
def __str__(self):
|
objects = SASPictureManager.from_queryset(PictureQuerySet)()
|
||||||
return self.name
|
|
||||||
|
|
||||||
def get_absolute_url(self):
|
@property
|
||||||
return reverse("sas:picture", kwargs={"picture_id": self.id})
|
def is_vertical(self):
|
||||||
|
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})
|
||||||
@@ -340,34 +107,41 @@ 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})
|
||||||
|
|
||||||
@property
|
def get_absolute_url(self):
|
||||||
def is_vertical(self):
|
return reverse("sas:picture", kwargs={"picture_id": self.id})
|
||||||
# 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):
|
def generate_thumbnails(self, *, overwrite=False):
|
||||||
im = Image.open(self.original)
|
im = Image.open(BytesIO(self.file.read()))
|
||||||
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 of time, which greatly hinders the UX
|
# - optimizing large images takes a lot 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")
|
||||||
new_extension_name = str(Path(self.original.name).with_suffix(".webp"))
|
if overwrite:
|
||||||
|
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 field in self.original, self.compressed, self.thumbnail:
|
for attr in ["file", "compressed", "thumbnail"]:
|
||||||
with open(field.file, "r+b") as file:
|
name = self.__getattribute__(attr).name
|
||||||
|
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)
|
||||||
@@ -380,6 +154,110 @@ 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()
|
||||||
|
|||||||
@@ -26,10 +26,19 @@ class SimpleAlbumSchema(ModelSchema):
|
|||||||
class AlbumSchema(ModelSchema):
|
class AlbumSchema(ModelSchema):
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Album
|
model = Album
|
||||||
fields = ["id", "name", "is_moderated", "thumbnail"]
|
fields = ["id", "name", "is_moderated"]
|
||||||
|
|
||||||
|
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()
|
||||||
@@ -46,12 +55,7 @@ class AlbumAutocompleteSchema(ModelSchema):
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def resolve_path(obj: Album) -> str:
|
def resolve_path(obj: Album) -> str:
|
||||||
return str(Path(obj.parent_path) / obj.name)
|
return str(Path(obj.get_parent_path()) / obj.name)
|
||||||
|
|
||||||
|
|
||||||
class MoveAlbumSchema(Schema):
|
|
||||||
id: int
|
|
||||||
new_parent_id: int
|
|
||||||
|
|
||||||
|
|
||||||
class PictureFilterSchema(FilterSchema):
|
class PictureFilterSchema(FilterSchema):
|
||||||
@@ -66,7 +70,7 @@ class PictureFilterSchema(FilterSchema):
|
|||||||
class PictureSchema(ModelSchema):
|
class PictureSchema(ModelSchema):
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Picture
|
model = Picture
|
||||||
fields = ["id", "name", "created_at", "is_moderated", "asked_for_removal"]
|
fields = ["id", "name", "date", "size", "is_moderated", "asked_for_removal"]
|
||||||
|
|
||||||
owner: UserProfileSchema
|
owner: UserProfileSchema
|
||||||
sas_url: str
|
sas_url: str
|
||||||
|
|||||||
@@ -128,108 +128,3 @@ 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();
|
|
||||||
// }
|
|
||||||
// });
|
|
||||||
|
|||||||
@@ -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.created_at.replace(/[:-]/g, "_")}${p.name.slice(p.name.lastIndexOf("."))}`;
|
const imgName = `${p.album.name}/IMG_${p.id}_${p.date.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.created_at),
|
lastModDate: new Date(p.date),
|
||||||
onstart: incrementProgressBar,
|
onstart: incrementProgressBar,
|
||||||
});
|
});
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -109,233 +109,225 @@ interface ViewerConfig {
|
|||||||
/** id of the first picture to load on the page */
|
/** id of the first picture to load on the page */
|
||||||
firstPictureId: number;
|
firstPictureId: number;
|
||||||
/** if the user is sas admin */
|
/** if the user is sas admin */
|
||||||
userIsSasAdmin: boolean;
|
userCanModerate: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Load user picture page with a nice download bar
|
* Load user picture page with a nice download bar
|
||||||
**/
|
**/
|
||||||
exportToHtml("loadViewer", (config: ViewerConfig) => {
|
document.addEventListener("alpine:init", () => {
|
||||||
document.addEventListener("alpine:init", () => {
|
Alpine.data("picture_viewer", (config: ViewerConfig) => ({
|
||||||
Alpine.data("picture_viewer", () => ({
|
/**
|
||||||
/**
|
* All the pictures that can be displayed on this picture viewer
|
||||||
* All the pictures that can be displayed on this picture viewer
|
**/
|
||||||
**/
|
pictures: [] as PictureWithIdentifications[],
|
||||||
pictures: [] as PictureWithIdentifications[],
|
/**
|
||||||
/**
|
* The currently displayed picture
|
||||||
* The currently displayed picture
|
* Default dummy data are pre-loaded to avoid javascript error
|
||||||
* Default dummy data are pre-loaded to avoid javascript error
|
* when loading the page at the beginning
|
||||||
* when loading the page at the beginning
|
* @type PictureWithIdentifications
|
||||||
* @type PictureWithIdentifications
|
**/
|
||||||
**/
|
currentPicture: {
|
||||||
currentPicture: {
|
// biome-ignore lint/style/useNamingConvention: api is in snake_case
|
||||||
// biome-ignore lint/style/useNamingConvention: api is in snake_case
|
is_moderated: true,
|
||||||
is_moderated: true,
|
id: null as number,
|
||||||
id: null as number,
|
name: "",
|
||||||
name: "",
|
// biome-ignore lint/style/useNamingConvention: api is in snake_case
|
||||||
// biome-ignore lint/style/useNamingConvention: api is in snake_case
|
display_name: "",
|
||||||
display_name: "",
|
// biome-ignore lint/style/useNamingConvention: api is in snake_case
|
||||||
// biome-ignore lint/style/useNamingConvention: api is in snake_case
|
compressed_url: "",
|
||||||
compressed_url: "",
|
// biome-ignore lint/style/useNamingConvention: api is in snake_case
|
||||||
// biome-ignore lint/style/useNamingConvention: api is in snake_case
|
profile_url: "",
|
||||||
profile_url: "",
|
// biome-ignore lint/style/useNamingConvention: api is in snake_case
|
||||||
// biome-ignore lint/style/useNamingConvention: api is in snake_case
|
full_size_url: "",
|
||||||
full_size_url: "",
|
owner: "",
|
||||||
owner: "",
|
date: new Date(),
|
||||||
// biome-ignore lint/style/useNamingConvention: api is in snake_case
|
identifications: [] as IdentifiedUserSchema[],
|
||||||
created_at: new Date(),
|
},
|
||||||
identifications: [] as IdentifiedUserSchema[],
|
/**
|
||||||
},
|
* The picture which will be displayed next if the user press the "next" button
|
||||||
/**
|
**/
|
||||||
* The picture which will be displayed next if the user press the "next" button
|
nextPicture: null as PictureWithIdentifications,
|
||||||
**/
|
/**
|
||||||
nextPicture: null as PictureWithIdentifications,
|
* The picture which will be displayed next if the user press the "previous" button
|
||||||
/**
|
**/
|
||||||
* The picture which will be displayed next if the user press the "previous" button
|
previousPicture: null as PictureWithIdentifications,
|
||||||
**/
|
/**
|
||||||
previousPicture: null as PictureWithIdentifications,
|
* The select2 component used to identify users
|
||||||
/**
|
**/
|
||||||
* The select2 component used to identify users
|
selector: undefined as UserAjaxSelect,
|
||||||
**/
|
/**
|
||||||
selector: undefined as UserAjaxSelect,
|
* Error message when a moderation operation fails
|
||||||
/**
|
**/
|
||||||
* Error message when a moderation operation fails
|
moderationError: "",
|
||||||
**/
|
/**
|
||||||
moderationError: "",
|
* Method of pushing new url to the browser history
|
||||||
/**
|
* Used by popstate event and always reset to it's default value when used
|
||||||
* Method of pushing new url to the browser history
|
**/
|
||||||
* Used by popstate event and always reset to it's default value when used
|
pushstate: History.Push,
|
||||||
**/
|
|
||||||
pushstate: History.Push,
|
|
||||||
|
|
||||||
async init() {
|
async init() {
|
||||||
this.pictures = (
|
this.pictures = (
|
||||||
await paginated(picturesFetchPictures, {
|
await paginated(picturesFetchPictures, {
|
||||||
// biome-ignore lint/style/useNamingConvention: api is in snake_case
|
// biome-ignore lint/style/useNamingConvention: api is in snake_case
|
||||||
query: { album_id: config.albumId },
|
query: { album_id: config.albumId },
|
||||||
} as PicturesFetchPicturesData)
|
} as PicturesFetchPicturesData)
|
||||||
).map(PictureWithIdentifications.fromPicture);
|
).map(PictureWithIdentifications.fromPicture);
|
||||||
this.selector = this.$refs.search;
|
this.selector = this.$refs.search;
|
||||||
this.selector.setFilter((users: UserProfileSchema[]) => {
|
this.selector.setFilter((users: UserProfileSchema[]) => {
|
||||||
const resp: UserProfileSchema[] = [];
|
const resp: UserProfileSchema[] = [];
|
||||||
const ids = [
|
const ids = [
|
||||||
...(this.currentPicture.identifications || []).map(
|
...(this.currentPicture.identifications || []).map(
|
||||||
(i: IdentifiedUserSchema) => i.user.id,
|
(i: IdentifiedUserSchema) => i.user.id,
|
||||||
),
|
),
|
||||||
];
|
];
|
||||||
for (const user of users) {
|
for (const user of users) {
|
||||||
if (!ids.includes(user.id)) {
|
if (!ids.includes(user.id)) {
|
||||||
resp.push(user);
|
resp.push(user);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return resp;
|
}
|
||||||
});
|
return resp;
|
||||||
this.currentPicture = this.pictures.find(
|
});
|
||||||
(i: PictureSchema) => i.id === config.firstPictureId,
|
this.currentPicture = this.pictures.find(
|
||||||
);
|
(i: PictureSchema) => i.id === config.firstPictureId,
|
||||||
this.$watch(
|
);
|
||||||
"currentPicture",
|
this.$watch(
|
||||||
(current: PictureSchema, previous: PictureSchema) => {
|
"currentPicture",
|
||||||
if (current === previous) {
|
(current: PictureSchema, previous: PictureSchema) => {
|
||||||
/* Avoid recursive updates */
|
if (current === previous) {
|
||||||
return;
|
/* Avoid recursive updates */
|
||||||
}
|
|
||||||
this.updatePicture();
|
|
||||||
},
|
|
||||||
);
|
|
||||||
window.addEventListener("popstate", async (event) => {
|
|
||||||
if (!event.state || event.state.sasPictureId === undefined) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.pushstate = History.Replace;
|
this.updatePicture();
|
||||||
this.currentPicture = this.pictures.find(
|
},
|
||||||
(i: PictureSchema) =>
|
);
|
||||||
i.id === Number.parseInt(event.state.sasPictureId, 10),
|
window.addEventListener("popstate", async (event) => {
|
||||||
);
|
if (!event.state || event.state.sasPictureId === undefined) {
|
||||||
});
|
|
||||||
this.pushstate = History.Replace; /* Avoid first url push */
|
|
||||||
await this.updatePicture();
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Update the page.
|
|
||||||
* Called when the `currentPicture` property changes.
|
|
||||||
*
|
|
||||||
* The url is modified without reloading the page,
|
|
||||||
* and the previous picture, the next picture and
|
|
||||||
* the list of identified users are updated.
|
|
||||||
*/
|
|
||||||
async updatePicture(): Promise<void> {
|
|
||||||
const updateArgs = {
|
|
||||||
data: { sasPictureId: this.currentPicture.id },
|
|
||||||
unused: "",
|
|
||||||
url: this.currentPicture.sas_url,
|
|
||||||
};
|
|
||||||
if (this.pushstate === History.Replace) {
|
|
||||||
window.history.replaceState(
|
|
||||||
updateArgs.data,
|
|
||||||
updateArgs.unused,
|
|
||||||
updateArgs.url,
|
|
||||||
);
|
|
||||||
this.pushstate = History.Push;
|
|
||||||
} else {
|
|
||||||
window.history.pushState(updateArgs.data, updateArgs.unused, updateArgs.url);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.moderationError = "";
|
|
||||||
const index: number = this.pictures.indexOf(this.currentPicture);
|
|
||||||
this.previousPicture = this.pictures[index - 1] || null;
|
|
||||||
this.nextPicture = this.pictures[index + 1] || null;
|
|
||||||
this.$refs.mainPicture?.addEventListener("load", () => {
|
|
||||||
// once the current picture is loaded,
|
|
||||||
// start preloading the next and previous pictures
|
|
||||||
this.nextPicture?.preload();
|
|
||||||
this.previousPicture?.preload();
|
|
||||||
});
|
|
||||||
if (this.currentPicture.asked_for_removal && config.userIsSasAdmin) {
|
|
||||||
await Promise.all([
|
|
||||||
this.currentPicture.loadIdentifications(),
|
|
||||||
this.currentPicture.loadModeration(),
|
|
||||||
]);
|
|
||||||
} else {
|
|
||||||
await this.currentPicture.loadIdentifications();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
async moderatePicture() {
|
|
||||||
const res = await picturesModeratePicture({
|
|
||||||
// biome-ignore lint/style/useNamingConvention: api is in snake_case
|
|
||||||
path: { picture_id: this.currentPicture.id },
|
|
||||||
});
|
|
||||||
if (res.error) {
|
|
||||||
this.moderationError = `${gettext("Couldn't moderate picture")} : ${(res.error as { detail: string }).detail}`;
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.currentPicture.is_moderated = true;
|
this.pushstate = History.Replace;
|
||||||
this.currentPicture.asked_for_removal = false;
|
this.currentPicture = this.pictures.find(
|
||||||
},
|
(i: PictureSchema) => i.id === Number.parseInt(event.state.sasPictureId, 10),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
this.pushstate = History.Replace; /* Avoid first url push */
|
||||||
|
await this.updatePicture();
|
||||||
|
},
|
||||||
|
|
||||||
async deletePicture() {
|
/**
|
||||||
const res = await picturesDeletePicture({
|
* Update the page.
|
||||||
|
* Called when the `currentPicture` property changes.
|
||||||
|
*
|
||||||
|
* The url is modified without reloading the page,
|
||||||
|
* and the previous picture, the next picture and
|
||||||
|
* the list of identified users are updated.
|
||||||
|
*/
|
||||||
|
async updatePicture(): Promise<void> {
|
||||||
|
const updateArgs = {
|
||||||
|
data: { sasPictureId: this.currentPicture.id },
|
||||||
|
unused: "",
|
||||||
|
url: this.currentPicture.sas_url,
|
||||||
|
};
|
||||||
|
if (this.pushstate === History.Replace) {
|
||||||
|
window.history.replaceState(updateArgs.data, updateArgs.unused, updateArgs.url);
|
||||||
|
this.pushstate = History.Push;
|
||||||
|
} else {
|
||||||
|
window.history.pushState(updateArgs.data, updateArgs.unused, updateArgs.url);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.moderationError = "";
|
||||||
|
const index: number = this.pictures.indexOf(this.currentPicture);
|
||||||
|
this.previousPicture = this.pictures[index - 1] || null;
|
||||||
|
this.nextPicture = this.pictures[index + 1] || null;
|
||||||
|
this.$refs.mainPicture?.addEventListener("load", () => {
|
||||||
|
// once the current picture is loaded,
|
||||||
|
// start preloading the next and previous pictures
|
||||||
|
this.nextPicture?.preload();
|
||||||
|
this.previousPicture?.preload();
|
||||||
|
});
|
||||||
|
if (this.currentPicture.asked_for_removal && config.userCanModerate) {
|
||||||
|
await Promise.all([
|
||||||
|
this.currentPicture.loadIdentifications(),
|
||||||
|
this.currentPicture.loadModeration(),
|
||||||
|
]);
|
||||||
|
} else {
|
||||||
|
await this.currentPicture.loadIdentifications();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async moderatePicture() {
|
||||||
|
const res = await picturesModeratePicture({
|
||||||
|
// biome-ignore lint/style/useNamingConvention: api is in snake_case
|
||||||
|
path: { picture_id: this.currentPicture.id },
|
||||||
|
});
|
||||||
|
if (res.error) {
|
||||||
|
this.moderationError = `${gettext("Couldn't moderate picture")} : ${(res.error as { detail: string }).detail}`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.currentPicture.is_moderated = true;
|
||||||
|
this.currentPicture.asked_for_removal = false;
|
||||||
|
},
|
||||||
|
|
||||||
|
async deletePicture() {
|
||||||
|
const res = await picturesDeletePicture({
|
||||||
|
// biome-ignore lint/style/useNamingConvention: api is in snake_case
|
||||||
|
path: { picture_id: this.currentPicture.id },
|
||||||
|
});
|
||||||
|
if (res.error) {
|
||||||
|
this.moderationError = `${gettext("Couldn't delete picture")} : ${(res.error as { detail: string }).detail}`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.pictures.splice(this.pictures.indexOf(this.currentPicture), 1);
|
||||||
|
if (this.pictures.length === 0) {
|
||||||
|
// The deleted picture was the only one in the list.
|
||||||
|
// As the album is now empty, go back to the parent page
|
||||||
|
document.location.href = config.albumUrl;
|
||||||
|
}
|
||||||
|
this.currentPicture = this.nextPicture || this.previousPicture;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send the identification request and update the list of identified users.
|
||||||
|
*/
|
||||||
|
async submitIdentification(): Promise<void> {
|
||||||
|
const widget: TomSelect = this.selector.widget;
|
||||||
|
await picturesIdentifyUsers({
|
||||||
|
path: {
|
||||||
// biome-ignore lint/style/useNamingConvention: api is in snake_case
|
// biome-ignore lint/style/useNamingConvention: api is in snake_case
|
||||||
path: { picture_id: this.currentPicture.id },
|
picture_id: this.currentPicture.id,
|
||||||
});
|
},
|
||||||
if (res.error) {
|
body: widget.items.map((i: string) => Number.parseInt(i, 10)),
|
||||||
this.moderationError = `${gettext("Couldn't delete picture")} : ${(res.error as { detail: string }).detail}`;
|
});
|
||||||
return;
|
// refresh the identified users list
|
||||||
}
|
await this.currentPicture.loadIdentifications({ forceReload: true });
|
||||||
this.pictures.splice(this.pictures.indexOf(this.currentPicture), 1);
|
|
||||||
if (this.pictures.length === 0) {
|
|
||||||
// The deleted picture was the only one in the list.
|
|
||||||
// As the album is now empty, go back to the parent page
|
|
||||||
document.location.href = config.albumUrl;
|
|
||||||
}
|
|
||||||
this.currentPicture = this.nextPicture || this.previousPicture;
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
// Clear selection and cache of retrieved user so they can be filtered again
|
||||||
* Send the identification request and update the list of identified users.
|
widget.clear(false);
|
||||||
*/
|
widget.clearOptions();
|
||||||
async submitIdentification(): Promise<void> {
|
widget.setTextboxValue("");
|
||||||
const widget: TomSelect = this.selector.widget;
|
},
|
||||||
await picturesIdentifyUsers({
|
|
||||||
path: {
|
|
||||||
// biome-ignore lint/style/useNamingConvention: api is in snake_case
|
|
||||||
picture_id: this.currentPicture.id,
|
|
||||||
},
|
|
||||||
body: widget.items.map((i: string) => Number.parseInt(i, 10)),
|
|
||||||
});
|
|
||||||
// refresh the identified users list
|
|
||||||
await this.currentPicture.loadIdentifications({ forceReload: true });
|
|
||||||
|
|
||||||
// Clear selection and cache of retrieved user so they can be filtered again
|
/**
|
||||||
widget.clear(false);
|
* Check if an identification can be removed by the currently logged user
|
||||||
widget.clearOptions();
|
*/
|
||||||
widget.setTextboxValue("");
|
canBeRemoved(identification: IdentifiedUserSchema): boolean {
|
||||||
},
|
return config.userCanModerate || identification.user.id === config.userId;
|
||||||
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if an identification can be removed by the currently logged user
|
* Untag a user from the current picture
|
||||||
*/
|
*/
|
||||||
canBeRemoved(identification: IdentifiedUserSchema): boolean {
|
async removeIdentification(identification: IdentifiedUserSchema): Promise<void> {
|
||||||
return config.userIsSasAdmin || identification.user.id === config.userId;
|
const res = await usersidentifiedDeleteRelation({
|
||||||
},
|
// biome-ignore lint/style/useNamingConvention: api is in snake_case
|
||||||
|
path: { relation_id: identification.id },
|
||||||
/**
|
});
|
||||||
* Untag a user from the current picture
|
if (!res.error && Array.isArray(this.currentPicture.identifications)) {
|
||||||
*/
|
this.currentPicture.identifications =
|
||||||
async removeIdentification(identification: IdentifiedUserSchema): Promise<void> {
|
this.currentPicture.identifications.filter(
|
||||||
const res = await usersidentifiedDeleteRelation({
|
(i: IdentifiedUserSchema) => i.id !== identification.id,
|
||||||
// biome-ignore lint/style/useNamingConvention: api is in snake_case
|
);
|
||||||
path: { relation_id: identification.id },
|
}
|
||||||
});
|
},
|
||||||
if (!res.error && Array.isArray(this.currentPicture.identifications)) {
|
}));
|
||||||
this.currentPicture.identifications =
|
|
||||||
this.currentPicture.identifications.filter(
|
|
||||||
(i: IdentifiedUserSchema) => i.id !== identification.id,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -20,7 +20,7 @@
|
|||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<code>
|
<code>
|
||||||
<a href="{{ url('sas:main') }}">SAS</a> / {{ print_path(album.parent) }} {{ album.name }}
|
<a href="{{ url('sas:main') }}">SAS</a> / {{ print_path(album.parent) }} {{ album.get_display_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.name }}</h3>
|
<h3>{{ album.get_display_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["albums"] %}#}
|
{% for f in clipboard %}
|
||||||
{# <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 edit_mode %}
|
{% if is_sas_admin %}
|
||||||
<input type="checkbox" name="album_list" :value="album.id">
|
<input type="checkbox" name="file_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="picture_list" :value="picture.id">
|
<input type="checkbox" name="file_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="{{ form.images.id_for_label }}">{{ form.images.label }} :</label>
|
<label for="{{ upload_form.images.id_for_label }}">{{ upload_form.images.label }} :</label>
|
||||||
{{ form.images|add_attr("x-ref=pictures") }}
|
{{ upload_form.images|add_attr("x-ref=pictures") }}
|
||||||
<span class="helptext">{{ form.images.help_text }}</span>
|
<span class="helptext">{{ upload_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>
|
||||||
|
|||||||
@@ -1,13 +1,19 @@
|
|||||||
{% 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.thumbnail %}
|
{% if a.file %}
|
||||||
{% 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 class="album{% if not a.is_moderated %} not_moderated{% endif %}">
|
<div
|
||||||
|
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"> </div>
|
<div class="overlay"> </div>
|
||||||
@@ -25,7 +31,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.name }}</a> /
|
<a href="{{ url('sas:album', album_id=file.id) }}">{{ file.get_display_name() }}</a> /
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endmacro %}
|
{% endmacro %}
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
{% extends "core/base.jinja" %}
|
{% extends "core/base.jinja" %}
|
||||||
|
|
||||||
{%- block additional_css -%}
|
{%- block additional_css -%}
|
||||||
<link rel="stylesheet" href="{{ static('bundled/core/components/ajax-select-index.css') }}">
|
<link defer rel="stylesheet" href="{{ static('bundled/core/components/ajax-select-index.css') }}">
|
||||||
<link rel="stylesheet" href="{{ static('core/components/ajax-select.scss') }}">
|
<link defer rel="stylesheet" href="{{ static('core/components/ajax-select.scss') }}">
|
||||||
<link rel="stylesheet" href="{{ static('sas/css/picture.scss') }}">
|
<link defer rel="stylesheet" href="{{ static('sas/css/picture.scss') }}">
|
||||||
{%- endblock -%}
|
{%- endblock -%}
|
||||||
|
|
||||||
{%- block additional_js -%}
|
{%- block additional_js -%}
|
||||||
@@ -17,10 +17,8 @@
|
|||||||
|
|
||||||
{% from "sas/macros.jinja" import print_path %}
|
{% from "sas/macros.jinja" import print_path %}
|
||||||
|
|
||||||
{% set user_is_sas_admin = user.is_root or user.is_in_group(pk = settings.SITH_GROUP_SAS_ADMIN_ID) %}
|
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<main x-data="picture_viewer">
|
<main x-data="picture_viewer(config)">
|
||||||
<code>
|
<code>
|
||||||
<a href="{{ url('sas:main') }}">SAS</a> / {{ print_path(album) }} <span x-text="currentPicture.name"></span>
|
<a href="{{ url('sas:main') }}">SAS</a> / {{ print_path(album) }} <span x-text="currentPicture.name"></span>
|
||||||
</code>
|
</code>
|
||||||
@@ -50,15 +48,13 @@
|
|||||||
It will be hidden to other users until it has been moderated.
|
It will be hidden to other users until it has been moderated.
|
||||||
{% endtrans %}
|
{% endtrans %}
|
||||||
</p>
|
</p>
|
||||||
{% if user_is_sas_admin %}
|
{% if user.has_perm("sas.moderate_sasfile") %}
|
||||||
<template x-if="currentPicture.asked_for_removal">
|
<template x-if="currentPicture.asked_for_removal">
|
||||||
<div>
|
<div>
|
||||||
<h5>{% trans %}The following issues have been raised:{% endtrans %}</h5>
|
<h5>{% trans %}The following issues have been raised:{% endtrans %}</h5>
|
||||||
<template x-for="req in (currentPicture.moderationRequests ?? [])" :key="req.id">
|
<template x-for="req in (currentPicture.moderationRequests ?? [])" :key="req.id">
|
||||||
<div>
|
<div>
|
||||||
<h6
|
<h6 x-text="`${req.author.first_name} ${req.author.last_name}`"></h6>
|
||||||
x-text="`${req.author.first_name} ${req.author.last_name}`"
|
|
||||||
></h6>
|
|
||||||
<i x-text="Intl.DateTimeFormat(
|
<i x-text="Intl.DateTimeFormat(
|
||||||
'{{ LANGUAGE_CODE }}',
|
'{{ LANGUAGE_CODE }}',
|
||||||
{dateStyle: 'long', timeStyle: 'short'}
|
{dateStyle: 'long', timeStyle: 'short'}
|
||||||
@@ -70,7 +66,7 @@
|
|||||||
</template>
|
</template>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
{% if user_is_sas_admin %}
|
{% if user.has_perm("sas.moderate_sasfile") %}
|
||||||
<div class="alert-aside">
|
<div class="alert-aside">
|
||||||
<button class="btn btn-blue" @click="moderatePicture()">
|
<button class="btn btn-blue" @click="moderatePicture()">
|
||||||
{% trans %}Moderate{% endtrans %}
|
{% trans %}Moderate{% endtrans %}
|
||||||
@@ -104,7 +100,7 @@
|
|||||||
<span
|
<span
|
||||||
x-text="Intl.DateTimeFormat(
|
x-text="Intl.DateTimeFormat(
|
||||||
'{{ LANGUAGE_CODE }}', {dateStyle: 'long'}
|
'{{ LANGUAGE_CODE }}', {dateStyle: 'long'}
|
||||||
).format(new Date(currentPicture.created_at))"
|
).format(new Date(currentPicture.date))"
|
||||||
>
|
>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -204,16 +200,13 @@
|
|||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block script %}
|
{% block script %}
|
||||||
{{ super() }}
|
|
||||||
<script>
|
<script>
|
||||||
window.addEventListener("DOMContentLoaded", () => {
|
const config = {
|
||||||
loadViewer({
|
albumId: {{ album.id }},
|
||||||
albumId: {{ album.id }} ,
|
albumUrl: "{{ album.get_absolute_url() }}",
|
||||||
albumUrl: "{{ album.get_absolute_url() }}",
|
firstPictureId: {{ picture.id }}, {# id of the first picture to show after page load #}
|
||||||
firstPictureId: {{ picture.id }}, {# id of the first picture to show after page load #}
|
userId: {{ user.id }},
|
||||||
userId: {{ user.id }},
|
userCanModerate: {{ user.has_perm("sas.moderate_sasfile")|tojson }}
|
||||||
userIsSasAdmin: {{ user_is_sas_admin|tojson }}
|
}
|
||||||
});
|
|
||||||
})
|
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -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)
|
cls.album_a = baker.make(Album, is_in_sas=True, parent=sas)
|
||||||
cls.album_b = baker.make(Album)
|
cls.album_b = baker.make(Album, is_in_sas=True, parent=sas)
|
||||||
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.pictures.values_list("id", flat=True))
|
expected = list(self.album_a.children_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__event_date", "picture__created_at"
|
"-picture__parent__date", "picture__date"
|
||||||
).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__event_date", "picture__created_at")
|
.order_by("-picture__parent__date", "picture__date")
|
||||||
.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__event_date", "picture__created_at"
|
"-picture__parent__date", "picture__date"
|
||||||
).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__event_date", "picture__created_at")
|
.order_by("-picture__parent__date", "picture__date")
|
||||||
.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
|
||||||
|
|||||||
@@ -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 album_recipe, picture_recipe
|
from sas.baker_recipes import picture_recipe
|
||||||
from sas.models import Album, PeoplePictureRelation, Picture
|
from sas.models import PeoplePictureRelation, Picture
|
||||||
|
|
||||||
|
|
||||||
class TestPictureQuerySet(TestCase):
|
class TestPictureQuerySet(TestCase):
|
||||||
@@ -67,22 +67,3 @@ 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()
|
|
||||||
|
|||||||
@@ -136,7 +136,9 @@ class TestAlbumUpload:
|
|||||||
class TestSasModeration(TestCase):
|
class TestSasModeration(TestCase):
|
||||||
@classmethod
|
@classmethod
|
||||||
def setUpTestData(cls):
|
def setUpTestData(cls):
|
||||||
album = baker.make(Album)
|
album = baker.make(
|
||||||
|
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
|
||||||
)
|
)
|
||||||
@@ -159,16 +161,22 @@ class TestSasModeration(TestCase):
|
|||||||
assert len(res.context_data["pictures"]) == 1
|
assert len(res.context_data["pictures"]) == 1
|
||||||
assert res.context_data["pictures"][0] == self.to_moderate
|
assert res.context_data["pictures"][0] == self.to_moderate
|
||||||
|
|
||||||
res = self.client.post(
|
|
||||||
reverse("sas:moderation"),
|
|
||||||
data={"album_id": self.to_moderate.id, "picture_id": self.to_moderate.id},
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_moderation_page_forbidden(self):
|
def test_moderation_page_forbidden(self):
|
||||||
self.client.force_login(self.simple_user)
|
self.client.force_login(self.simple_user)
|
||||||
res = self.client.get(reverse("sas:moderation"))
|
res = self.client.get(reverse("sas:moderation"))
|
||||||
assert res.status_code == 403
|
assert res.status_code == 403
|
||||||
|
|
||||||
|
def test_moderate_album(self):
|
||||||
|
self.client.force_login(self.moderator)
|
||||||
|
url = reverse("sas:moderation")
|
||||||
|
album = baker.make(
|
||||||
|
Album, is_moderated=False, parent_id=settings.SITH_SAS_ROOT_DIR_ID
|
||||||
|
)
|
||||||
|
res = self.client.post(url, data={"album_id": album.id, "moderate": ""})
|
||||||
|
assertRedirects(res, url)
|
||||||
|
album.refresh_from_db()
|
||||||
|
assert album.is_moderated
|
||||||
|
|
||||||
def test_moderate_picture(self):
|
def test_moderate_picture(self):
|
||||||
self.client.force_login(self.moderator)
|
self.client.force_login(self.moderator)
|
||||||
res = self.client.get(
|
res = self.client.get(
|
||||||
|
|||||||
116
sas/views.py
116
sas/views.py
@@ -12,23 +12,22 @@
|
|||||||
# 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.core.exceptions import PermissionDenied
|
from django.contrib.auth.mixins import PermissionRequiredMixin
|
||||||
from django.db.models import Count, OuterRef, Subquery
|
from django.db.models import Count, OuterRef, Subquery
|
||||||
from django.http import Http404, HttpResponseRedirect
|
from django.http import Http404, HttpResponseRedirect
|
||||||
from django.shortcuts import get_object_or_404
|
from django.shortcuts import get_object_or_404, redirect
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django.utils.safestring import SafeString
|
from django.utils.safestring import SafeString
|
||||||
from django.views.generic import CreateView, DetailView, TemplateView
|
from django.views.generic import CreateView, DetailView, TemplateView
|
||||||
from django.views.generic.edit import FormMixin, FormView, UpdateView
|
from django.views.generic.edit import 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 FileView, UseFragmentsMixin
|
from core.views import UseFragmentsMixin
|
||||||
from core.views.files import send_raw_file
|
from core.views.files import FileView, send_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 (
|
||||||
@@ -64,7 +63,6 @@ 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]:
|
||||||
@@ -81,26 +79,12 @@ 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(albums_qs.filter(parent=None).order_by("id"))
|
kwargs["categories"] = list(
|
||||||
|
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
|
||||||
|
|
||||||
@@ -110,9 +94,6 @@ 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:
|
||||||
@@ -122,42 +103,31 @@ 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) | {"album": self.object.parent}
|
return super().get_context_data(**kwargs) | {
|
||||||
|
"album": Album.objects.get(children=self.object)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def send_album(request, album_id):
|
def send_album(request, album_id):
|
||||||
album = get_object_or_404(Album, id=album_id)
|
return send_file(request, album_id, Album)
|
||||||
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):
|
||||||
picture = get_object_or_404(Picture, id=picture_id)
|
return send_file(request, picture_id, Picture)
|
||||||
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):
|
||||||
picture = get_object_or_404(Picture, id=picture_id)
|
return send_file(request, picture_id, Picture, "compressed")
|
||||||
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):
|
||||||
picture = get_object_or_404(Picture, id=picture_id)
|
return send_file(request, picture_id, Picture, "thumbnail")
|
||||||
if not picture.can_be_viewed_by(request.user):
|
|
||||||
raise PermissionDenied
|
|
||||||
return send_raw_file(Path(picture.thumbnail.path))
|
|
||||||
|
|
||||||
|
|
||||||
class AlbumView(CanViewMixin, UseFragmentsMixin, FormMixin, DetailView):
|
class AlbumView(CanViewMixin, UseFragmentsMixin, 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 {
|
||||||
@@ -172,32 +142,27 @@ class AlbumView(CanViewMixin, UseFragmentsMixin, FormMixin, 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"] = {"albums": [], "pictures": []}
|
request.session["clipboard"] = []
|
||||||
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()
|
||||||
form = self.get_form()
|
if not self.object.file:
|
||||||
if not form:
|
self.object.generate_thumbnail()
|
||||||
# the form is reserved for users that can edit this album.
|
if request.user.can_edit(self.object): # Handle the copy-paste functions
|
||||||
# If there is no form, it means the user has no right to do a POST
|
FileView.handle_clipboard(request, self.object)
|
||||||
raise PermissionDenied
|
return HttpResponseRedirect(self.request.path)
|
||||||
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)
|
||||||
kwargs["clipboard"] = {}
|
if ids := self.request.session.get("clipboard", None):
|
||||||
|
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)
|
||||||
@@ -226,31 +191,26 @@ class UserPicturesView(UserTabsMixin, CanViewMixin, DetailView):
|
|||||||
# Admin views
|
# Admin views
|
||||||
|
|
||||||
|
|
||||||
class ModerationView(TemplateView):
|
class ModerationView(PermissionRequiredMixin, TemplateView):
|
||||||
template_name = "sas/moderation.jinja"
|
template_name = "sas/moderation.jinja"
|
||||||
|
permission_required = "sas.moderate_sasfile"
|
||||||
def get(self, request, *args, **kwargs):
|
|
||||||
if request.user.is_in_group(pk=settings.SITH_GROUP_SAS_ADMIN_ID):
|
|
||||||
return super().get(request, *args, **kwargs)
|
|
||||||
raise PermissionDenied
|
|
||||||
|
|
||||||
def post(self, request, *args, **kwargs):
|
def post(self, request, *args, **kwargs):
|
||||||
if "album_id" not in request.POST:
|
if "album_id" not in request.POST:
|
||||||
raise Http404
|
raise Http404
|
||||||
if request.user.is_in_group(pk=settings.SITH_GROUP_SAS_ADMIN_ID):
|
album = get_object_or_404(Album, pk=request.POST["album_id"])
|
||||||
album = get_object_or_404(Album, pk=request.POST["album_id"])
|
if "moderate" in request.POST:
|
||||||
if "moderate" in request.POST:
|
album.moderator = request.user
|
||||||
album.moderator = request.user
|
album.is_moderated = True
|
||||||
album.is_moderated = True
|
album.save()
|
||||||
album.save()
|
elif "delete" in request.POST:
|
||||||
elif "delete" in request.POST:
|
album.delete()
|
||||||
album.delete()
|
return redirect(self.request.path)
|
||||||
return super().get(request, *args, **kwargs)
|
|
||||||
|
|
||||||
def get_context_data(self, **kwargs):
|
def get_context_data(self, **kwargs):
|
||||||
kwargs = super().get_context_data(**kwargs)
|
kwargs = super().get_context_data(**kwargs)
|
||||||
kwargs["albums_to_moderate"] = Album.objects.filter(
|
kwargs["albums_to_moderate"] = Album.objects.filter(
|
||||||
is_moderated=False
|
is_moderated=False, is_in_sas=True, is_folder=True
|
||||||
).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
|
||||||
|
|||||||
@@ -551,27 +551,27 @@ SITH_SUBSCRIPTIONS = {
|
|||||||
# Discount subscriptions
|
# Discount subscriptions
|
||||||
"un-semestre-reduction": {
|
"un-semestre-reduction": {
|
||||||
"name": _("One semester (-20%)"),
|
"name": _("One semester (-20%)"),
|
||||||
"price": 12,
|
"price": 16,
|
||||||
"duration": 1,
|
"duration": 1,
|
||||||
},
|
},
|
||||||
"deux-semestres-reduction": {
|
"deux-semestres-reduction": {
|
||||||
"name": _("Two semesters (-20%)"),
|
"name": _("Two semesters (-20%)"),
|
||||||
"price": 22,
|
"price": 28,
|
||||||
"duration": 2,
|
"duration": 2,
|
||||||
},
|
},
|
||||||
"cursus-tronc-commun-reduction": {
|
"cursus-tronc-commun-reduction": {
|
||||||
"name": _("Common core cursus (-20%)"),
|
"name": _("Common core cursus (-20%)"),
|
||||||
"price": 36,
|
"price": 48,
|
||||||
"duration": 4,
|
"duration": 4,
|
||||||
},
|
},
|
||||||
"cursus-branche-reduction": {
|
"cursus-branche-reduction": {
|
||||||
"name": _("Branch cursus (-20%)"),
|
"name": _("Branch cursus (-20%)"),
|
||||||
"price": 36,
|
"price": 48,
|
||||||
"duration": 6,
|
"duration": 6,
|
||||||
},
|
},
|
||||||
"cursus-alternant-reduction": {
|
"cursus-alternant-reduction": {
|
||||||
"name": _("Alternating cursus (-20%)"),
|
"name": _("Alternating cursus (-20%)"),
|
||||||
"price": 24,
|
"price": 28,
|
||||||
"duration": 6,
|
"duration": 6,
|
||||||
},
|
},
|
||||||
# CA special offer
|
# CA special offer
|
||||||
|
|||||||
@@ -182,12 +182,13 @@ 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
|
||||||
|
|
||||||
out.write_text(schema)
|
with open(out, "w") as f:
|
||||||
|
_ = f.write(schema)
|
||||||
|
|
||||||
return subprocess.Popen(["npm", "run", "openapi"])
|
return subprocess.Popen(["npm", "run", "openapi"])
|
||||||
|
|||||||
Reference in New Issue
Block a user