Merge branch 'taiste' into counter-activity-stats

This commit is contained in:
NaNoMelo 2024-10-22 13:30:14 +02:00 committed by GitHub
commit aa3123f02a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
113 changed files with 2733 additions and 2043 deletions

View File

@ -15,7 +15,16 @@
from django.contrib import admin
from accounting.models import *
from accounting.models import (
AccountingType,
BankAccount,
ClubAccount,
Company,
GeneralJournal,
Label,
Operation,
SimplifiedAccountingType,
)
admin.site.register(BankAccount)
admin.site.register(ClubAccount)

View File

@ -82,9 +82,7 @@ class Company(models.Model):
def is_owned_by(self, user):
"""Check if that object can be edited by the given user."""
if user.is_in_group(pk=settings.SITH_GROUP_ACCOUNTING_ADMIN_ID):
return True
return False
return user.is_in_group(pk=settings.SITH_GROUP_ACCOUNTING_ADMIN_ID)
def can_be_edited_by(self, user):
"""Check if that object can be edited by the given user."""
@ -127,9 +125,7 @@ class BankAccount(models.Model):
if user.is_in_group(pk=settings.SITH_GROUP_ACCOUNTING_ADMIN_ID):
return True
m = self.club.get_membership_for(user)
if m is not None and m.role >= settings.SITH_CLUB_ROLES_ID["Treasurer"]:
return True
return False
return m is not None and m.role >= settings.SITH_CLUB_ROLES_ID["Treasurer"]
class ClubAccount(models.Model):
@ -161,29 +157,20 @@ class ClubAccount(models.Model):
"""Check if that object can be edited by the given user."""
if user.is_anonymous:
return False
if user.is_in_group(pk=settings.SITH_GROUP_ACCOUNTING_ADMIN_ID):
return True
return False
return user.is_in_group(pk=settings.SITH_GROUP_ACCOUNTING_ADMIN_ID)
def can_be_edited_by(self, user):
"""Check if that object can be edited by the given user."""
m = self.club.get_membership_for(user)
if m and m.role == settings.SITH_CLUB_ROLES_ID["Treasurer"]:
return True
return False
return m and m.role == settings.SITH_CLUB_ROLES_ID["Treasurer"]
def can_be_viewed_by(self, user):
"""Check if that object can be viewed by the given user."""
m = self.club.get_membership_for(user)
if m and m.role >= settings.SITH_CLUB_ROLES_ID["Treasurer"]:
return True
return False
return m and m.role >= settings.SITH_CLUB_ROLES_ID["Treasurer"]
def has_open_journal(self):
for j in self.journals.all():
if not j.closed:
return True
return False
return self.journals.filter(closed=False).exists()
def get_open_journal(self):
return self.journals.filter(closed=False).first()
@ -228,17 +215,13 @@ class GeneralJournal(models.Model):
return False
if user.is_in_group(pk=settings.SITH_GROUP_ACCOUNTING_ADMIN_ID):
return True
if self.club_account.can_be_edited_by(user):
return True
return False
return self.club_account.can_be_edited_by(user)
def can_be_edited_by(self, user):
"""Check if that object can be edited by the given user."""
if user.is_in_group(pk=settings.SITH_GROUP_ACCOUNTING_ADMIN_ID):
return True
if self.club_account.can_be_edited_by(user):
return True
return False
return self.club_account.can_be_edited_by(user)
def can_be_viewed_by(self, user):
return self.club_account.can_be_viewed_by(user)
@ -416,9 +399,7 @@ class Operation(models.Model):
if self.journal.closed:
return False
m = self.journal.club_account.club.get_membership_for(user)
if m is not None and m.role >= settings.SITH_CLUB_ROLES_ID["Treasurer"]:
return True
return False
return m is not None and m.role >= settings.SITH_CLUB_ROLES_ID["Treasurer"]
def can_be_edited_by(self, user):
"""Check if that object can be edited by the given user."""
@ -427,9 +408,7 @@ class Operation(models.Model):
if self.journal.closed:
return False
m = self.journal.club_account.club.get_membership_for(user)
if m is not None and m.role == settings.SITH_CLUB_ROLES_ID["Treasurer"]:
return True
return False
return m is not None and m.role == settings.SITH_CLUB_ROLES_ID["Treasurer"]
class AccountingType(models.Model):
@ -472,9 +451,7 @@ class AccountingType(models.Model):
"""Check if that object can be edited by the given user."""
if user.is_anonymous:
return False
if user.is_in_group(pk=settings.SITH_GROUP_ACCOUNTING_ADMIN_ID):
return True
return False
return user.is_in_group(pk=settings.SITH_GROUP_ACCOUNTING_ADMIN_ID)
class SimplifiedAccountingType(models.Model):

View File

@ -102,7 +102,7 @@ class TestOperation(TestCase):
code="443", label="Ce code n'existe pas", movement_type="CREDIT"
)
at.save()
l = Label.objects.create(club_account=self.journal.club_account, name="bob")
label = Label.objects.create(club_account=self.journal.club_account, name="bob")
self.client.force_login(User.objects.get(username="comptable"))
self.op1 = Operation(
journal=self.journal,
@ -111,7 +111,7 @@ class TestOperation(TestCase):
remark="Test bilan",
mode="CASH",
done=True,
label=l,
label=label,
accounting_type=at,
target_type="USER",
target_id=self.skia.id,
@ -124,7 +124,7 @@ class TestOperation(TestCase):
remark="Test bilan",
mode="CASH",
done=True,
label=l,
label=label,
accounting_type=at,
target_type="USER",
target_id=self.skia.id,

View File

@ -15,7 +15,41 @@
from django.urls import path
from accounting.views import *
from accounting.views import (
AccountingTypeCreateView,
AccountingTypeEditView,
AccountingTypeListView,
BankAccountCreateView,
BankAccountDeleteView,
BankAccountDetailView,
BankAccountEditView,
BankAccountListView,
ClubAccountCreateView,
ClubAccountDeleteView,
ClubAccountDetailView,
ClubAccountEditView,
CompanyCreateView,
CompanyEditView,
CompanyListView,
JournalAccountingStatementView,
JournalCreateView,
JournalDeleteView,
JournalDetailView,
JournalEditView,
JournalNatureStatementView,
JournalPersonStatementView,
LabelCreateView,
LabelDeleteView,
LabelEditView,
LabelListView,
OperationCreateView,
OperationEditView,
OperationPDFView,
RefoundAccountView,
SimplifiedAccountingTypeCreateView,
SimplifiedAccountingTypeEditView,
SimplifiedAccountingTypeListView,
)
urlpatterns = [
# Accounting types

View File

@ -182,7 +182,7 @@ class ClubAccountCreateView(CanCreateMixin, CreateView):
def get_initial(self):
ret = super().get_initial()
if "parent" in self.request.GET.keys():
if "parent" in self.request.GET:
obj = BankAccount.objects.filter(id=int(self.request.GET["parent"])).first()
if obj is not None:
ret["bank_account"] = obj.id
@ -264,7 +264,7 @@ class JournalCreateView(CanCreateMixin, CreateView):
def get_initial(self):
ret = super().get_initial()
if "parent" in self.request.GET.keys():
if "parent" in self.request.GET:
obj = ClubAccount.objects.filter(id=int(self.request.GET["parent"])).first()
if obj is not None:
ret["club_account"] = obj.id
@ -362,7 +362,7 @@ class OperationForm(forms.ModelForm):
def clean(self):
self.cleaned_data = super().clean()
if "target_type" in self.cleaned_data.keys():
if "target_type" in self.cleaned_data:
if (
self.cleaned_data.get("user") is None
and self.cleaned_data.get("club") is None
@ -633,19 +633,17 @@ class JournalNatureStatementView(JournalTabsMixin, CanViewMixin, DetailView):
ret = collections.OrderedDict()
statement = collections.OrderedDict()
total_sum = 0
for sat in [None] + list(
SimplifiedAccountingType.objects.order_by("label").all()
):
for sat in [
None,
*list(SimplifiedAccountingType.objects.order_by("label")),
]:
amount = queryset.filter(
accounting_type__movement_type=movement_type, simpleaccounting_type=sat
).aggregate(amount_sum=Sum("amount"))["amount_sum"]
if sat:
sat = sat.label
else:
sat = ""
label = sat.label if sat is not None else ""
if amount:
total_sum += amount
statement[sat] = amount
statement[label] = amount
ret[movement_type] = statement
ret[movement_type + "_sum"] = total_sum
return ret
@ -668,15 +666,12 @@ class JournalNatureStatementView(JournalTabsMixin, CanViewMixin, DetailView):
self.statement(self.object.operations.filter(label=None).all(), "DEBIT")
)
statement[_("No label operations")] = no_label_statement
for l in labels:
for label in labels:
l_stmt = collections.OrderedDict()
l_stmt.update(
self.statement(self.object.operations.filter(label=l).all(), "CREDIT")
)
l_stmt.update(
self.statement(self.object.operations.filter(label=l).all(), "DEBIT")
)
statement[l] = l_stmt
journals = self.object.operations.filter(label=label).all()
l_stmt.update(self.statement(journals, "CREDIT"))
l_stmt.update(self.statement(journals, "DEBIT"))
statement[label] = l_stmt
return statement
def get_context_data(self, **kwargs):
@ -798,7 +793,7 @@ class LabelCreateView(
def get_initial(self):
ret = super().get_initial()
if "parent" in self.request.GET.keys():
if "parent" in self.request.GET:
obj = ClubAccount.objects.filter(id=int(self.request.GET["parent"])).first()
if obj is not None:
ret["club_account"] = obj.id

View File

@ -111,8 +111,8 @@ class MailingForm(forms.Form):
"""Convert given users into real users and check their validity."""
cleaned_data = super().clean()
users = []
for user in cleaned_data["subscription_users"]:
user = User.objects.filter(id=user).first()
for user_id in cleaned_data["subscription_users"]:
user = User.objects.filter(id=user_id).first()
if not user:
raise forms.ValidationError(
_("One of the selected users doesn't exist"), code="invalid"
@ -128,7 +128,7 @@ class MailingForm(forms.Form):
def clean(self):
cleaned_data = super().clean()
if not "action" in cleaned_data:
if "action" not in cleaned_data:
# If there is no action provided, we can stop here
raise forms.ValidationError(_("An action is required"), code="invalid")

View File

@ -389,9 +389,7 @@ class Membership(models.Model):
if user.is_root or user.is_board_member:
return True
membership = self.club.get_membership_for(user)
if membership is not None and membership.role >= self.role:
return True
return False
return membership is not None and membership.role >= self.role
def delete(self, *args, **kwargs):
super().delete(*args, **kwargs)

View File

@ -24,7 +24,32 @@
from django.urls import path
from club.views import *
from club.views import (
ClubCreateView,
ClubEditPropView,
ClubEditView,
ClubListView,
ClubMailingView,
ClubMembersView,
ClubOldMembersView,
ClubPageEditView,
ClubPageHistView,
ClubRevView,
ClubSellingCSVView,
ClubSellingView,
ClubStatView,
ClubToolsView,
ClubView,
MailingAutoGenerationView,
MailingDeleteView,
MailingSubscriptionDeleteView,
MembershipDeleteView,
MembershipSetOldView,
PosterCreateView,
PosterDeleteView,
PosterEditView,
PosterListView,
)
urlpatterns = [
path("", ClubListView.as_view(), name="club_list"),
@ -32,32 +57,20 @@ urlpatterns = [
path("stats/", ClubStatView.as_view(), name="club_stats"),
path("<int:club_id>/", ClubView.as_view(), name="club_view"),
path(
"<int:club_id>/rev/<int:rev_id>/",
ClubRevView.as_view(),
name="club_view_rev",
"<int:club_id>/rev/<int:rev_id>/", ClubRevView.as_view(), name="club_view_rev"
),
path("<int:club_id>/hist/", ClubPageHistView.as_view(), name="club_hist"),
path("<int:club_id>/edit/", ClubEditView.as_view(), name="club_edit"),
path(
"<int:club_id>/edit/page/",
ClubPageEditView.as_view(),
name="club_edit_page",
),
path("<int:club_id>/edit/page/", ClubPageEditView.as_view(), name="club_edit_page"),
path("<int:club_id>/members/", ClubMembersView.as_view(), name="club_members"),
path(
"<int:club_id>/elderlies/",
ClubOldMembersView.as_view(),
name="club_old_members",
),
path("<int:club_id>/sellings/", ClubSellingView.as_view(), name="club_sellings"),
path(
"<int:club_id>/sellings/",
ClubSellingView.as_view(),
name="club_sellings",
),
path(
"<int:club_id>/sellings/csv/",
ClubSellingCSVView.as_view(),
name="sellings_csv",
"<int:club_id>/sellings/csv/", ClubSellingCSVView.as_view(), name="sellings_csv"
),
path("<int:club_id>/prop/", ClubEditPropView.as_view(), name="club_prop"),
path("<int:club_id>/tools/", ClubToolsView.as_view(), name="tools"),
@ -89,9 +102,7 @@ urlpatterns = [
),
path("<int:club_id>/poster/", PosterListView.as_view(), name="poster_list"),
path(
"<int:club_id>/poster/create/",
PosterCreateView.as_view(),
name="poster_create",
"<int:club_id>/poster/create/", PosterCreateView.as_view(), name="poster_create"
),
path(
"<int:club_id>/poster/<int:poster_id>/edit/",

View File

@ -397,7 +397,8 @@ class ClubSellingCSVView(ClubSellingView):
row.append(selling.customer.user.get_display_name())
else:
row.append("")
row = row + [
row = [
*row,
selling.label,
selling.quantity,
selling.quantity * selling.unit_price,
@ -408,7 +409,7 @@ class ClubSellingCSVView(ClubSellingView):
row.append(selling.product.purchase_price)
row.append(selling.product.selling_price - selling.product.purchase_price)
else:
row = row + ["", "", ""]
row = [*row, "", "", ""]
return row
def get(self, request, *args, **kwargs):
@ -622,9 +623,7 @@ class ClubMailingView(ClubTabsMixin, CanEditMixin, DetailFormView):
def remove_subscription(self, cleaned_data):
"""Remove specified users from a mailing list."""
fields = [
cleaned_data[key]
for key in cleaned_data.keys()
if key.startswith("removal_")
val for key, val in cleaned_data.items() if key.startswith("removal_")
]
for field in fields:
for sub in field:

View File

@ -15,7 +15,7 @@
from django.contrib import admin
from haystack.admin import SearchModelAdmin
from com.models import *
from com.models import News, Poster, Screen, Sith, Weekmail
@admin.register(News)

View File

@ -16,7 +16,36 @@
from django.urls import path
from club.views import MailingDeleteView
from com.views import *
from com.views import (
AlertMsgEditView,
InfoMsgEditView,
MailingListAdminView,
MailingModerateView,
NewsAdminListView,
NewsCreateView,
NewsDeleteView,
NewsDetailView,
NewsEditView,
NewsListView,
NewsModerateView,
PosterCreateView,
PosterDeleteView,
PosterEditView,
PosterListView,
PosterModerateListView,
PosterModerateView,
ScreenCreateView,
ScreenDeleteView,
ScreenEditView,
ScreenListView,
ScreenSlideshowView,
WeekmailArticleCreateView,
WeekmailArticleDeleteView,
WeekmailArticleEditView,
WeekmailDestinationEditView,
WeekmailEditView,
WeekmailPreviewView,
)
urlpatterns = [
path("sith/edit/alert/", AlertMsgEditView.as_view(), name="alert_edit"),
@ -46,15 +75,9 @@ urlpatterns = [
path("news/", NewsListView.as_view(), name="news_list"),
path("news/admin/", NewsAdminListView.as_view(), name="news_admin_list"),
path("news/create/", NewsCreateView.as_view(), name="news_new"),
path("news/<int:news_id>/delete/", NewsDeleteView.as_view(), name="news_delete"),
path(
"news/<int:news_id>/delete/",
NewsDeleteView.as_view(),
name="news_delete",
),
path(
"news/<int:news_id>/moderate/",
NewsModerateView.as_view(),
name="news_moderate",
"news/<int:news_id>/moderate/", NewsModerateView.as_view(), name="news_moderate"
),
path("news/<int:news_id>/edit/", NewsEditView.as_view(), name="news_edit"),
path("news/<int:news_id>/", NewsDetailView.as_view(), name="news_detail"),
@ -71,11 +94,7 @@ urlpatterns = [
),
path("poster/", PosterListView.as_view(), name="poster_list"),
path("poster/create/", PosterCreateView.as_view(), name="poster_create"),
path(
"poster/<int:poster_id>/edit/",
PosterEditView.as_view(),
name="poster_edit",
),
path("poster/<int:poster_id>/edit/", PosterEditView.as_view(), name="poster_edit"),
path(
"poster/<int:poster_id>/delete/",
PosterDeleteView.as_view(),
@ -98,11 +117,7 @@ urlpatterns = [
ScreenSlideshowView.as_view(),
name="screen_slideshow",
),
path(
"screen/<int:screen_id>/edit/",
ScreenEditView.as_view(),
name="screen_edit",
),
path("screen/<int:screen_id>/edit/", ScreenEditView.as_view(), name="screen_edit"),
path(
"screen/<int:screen_id>/delete/",
ScreenDeleteView.as_view(),

View File

@ -86,12 +86,11 @@ class PosterForm(forms.ModelForm):
def __init__(self, *args, **kwargs):
self.user = kwargs.pop("user", None)
super().__init__(*args, **kwargs)
if self.user:
if not self.user.is_com_admin:
self.fields["club"].queryset = Club.objects.filter(
id__in=self.user.clubs_with_rights
)
self.fields.pop("display_time")
if self.user and not self.user.is_com_admin:
self.fields["club"].queryset = Club.objects.filter(
id__in=self.user.clubs_with_rights
)
self.fields.pop("display_time")
class ComTabsMixin(TabedViewMixin):
@ -312,7 +311,7 @@ class NewsCreateView(CanCreateMixin, CreateView):
def post(self, request, *args, **kwargs):
form = self.get_form()
if form.is_valid() and "preview" not in request.POST.keys():
if form.is_valid() and "preview" not in request.POST:
return self.form_valid(form)
else:
self.object = form.instance
@ -354,13 +353,13 @@ class NewsModerateView(CanEditMixin, SingleObjectMixin):
def get(self, request, *args, **kwargs):
self.object = self.get_object()
if "remove" in request.GET.keys():
if "remove" in request.GET:
self.object.is_moderated = False
else:
self.object.is_moderated = True
self.object.moderator = request.user
self.object.save()
if "next" in self.request.GET.keys():
if "next" in self.request.GET:
return redirect(self.request.GET["next"])
return redirect("com:news_admin_list")
@ -424,7 +423,7 @@ class WeekmailPreviewView(ComTabsMixin, QuickNotifMixin, CanEditPropMixin, Detai
try:
self.object.send() # This should fail
except SMTPRecipientsRefused as e:
users = User.objects.filter(email__in=e.recipients.keys())
users = User.objects.filter(email__in=e.recipients)
for u in users:
u.preferences.receive_weekmail = False
u.preferences.save()
@ -471,7 +470,7 @@ class WeekmailEditView(ComTabsMixin, QuickNotifMixin, CanEditPropMixin, UpdateVi
def get(self, request, *args, **kwargs):
self.object = self.get_object()
if "up_article" in request.GET.keys():
if "up_article" in request.GET:
art = get_object_or_404(
WeekmailArticle, id=request.GET["up_article"], weekmail=self.object
)
@ -483,7 +482,7 @@ class WeekmailEditView(ComTabsMixin, QuickNotifMixin, CanEditPropMixin, UpdateVi
art.save()
prev_art.save()
self.quick_notif_list += ["qn_success"]
if "down_article" in request.GET.keys():
if "down_article" in request.GET:
art = get_object_or_404(
WeekmailArticle, id=request.GET["down_article"], weekmail=self.object
)
@ -495,7 +494,7 @@ class WeekmailEditView(ComTabsMixin, QuickNotifMixin, CanEditPropMixin, UpdateVi
art.save()
next_art.save()
self.quick_notif_list += ["qn_success"]
if "add_article" in request.GET.keys():
if "add_article" in request.GET:
art = get_object_or_404(
WeekmailArticle, id=request.GET["add_article"], weekmail=None
)
@ -504,7 +503,7 @@ class WeekmailEditView(ComTabsMixin, QuickNotifMixin, CanEditPropMixin, UpdateVi
art.rank += 1
art.save()
self.quick_notif_list += ["qn_success"]
if "del_article" in request.GET.keys():
if "del_article" in request.GET:
art = get_object_or_404(
WeekmailArticle, id=request.GET["del_article"], weekmail=self.object
)
@ -571,7 +570,7 @@ class WeekmailArticleCreateView(QuickNotifMixin, CreateView):
)
),
)
if form.is_valid() and not "preview" in request.POST.keys():
if form.is_valid() and "preview" not in request.POST:
return self.form_valid(form)
else:
return self.form_invalid(form)
@ -689,19 +688,13 @@ class PosterEditBaseView(UpdateView):
template_name = "com/poster_edit.jinja"
def get_initial(self):
init = {}
try:
init["date_begin"] = self.object.date_begin.strftime("%Y-%m-%d %H:%M:%S")
except Exception:
pass
try:
init["date_end"] = self.object.date_end.strftime("%Y-%m-%d %H:%M:%S")
except Exception:
pass
return init
return {
"date_begin": self.object.date_begin.strftime("%Y-%m-%d %H:%M:%S"),
"date_end": self.object.date_end.strftime("%Y-%m-%d %H:%M:%S"),
}
def dispatch(self, request, *args, **kwargs):
if "club_id" in kwargs and kwargs["club_id"]:
if kwargs.get("club_id"):
try:
self.club = Club.objects.get(pk=kwargs["club_id"])
except Club.DoesNotExist as e:
@ -737,7 +730,7 @@ class PosterDeleteBaseView(DeleteView):
template_name = "core/delete_confirm.jinja"
def dispatch(self, request, *args, **kwargs):
if "club_id" in kwargs and kwargs["club_id"]:
if kwargs.get("club_id"):
try:
self.club = Club.objects.get(pk=kwargs["club_id"])
except Club.DoesNotExist as e:

View File

@ -67,5 +67,6 @@ class Command(BaseCommand):
subprocess.run(
[str(Path(__file__).parent / "install_xapian.sh"), desired],
env=dict(os.environ),
).check_returncode()
check=True,
)
self.stdout.write("Installation success")

View File

@ -934,7 +934,7 @@ Welcome to the wiki page!
# Adding subscription for sli
s = Subscription(
member=User.objects.filter(pk=sli.pk).first(),
subscription_type=list(settings.SITH_SUBSCRIPTIONS.keys())[0],
subscription_type=next(iter(settings.SITH_SUBSCRIPTIONS.keys())),
payment_method=settings.SITH_SUBSCRIPTION_PAYMENT_METHOD[0][0],
)
s.subscription_start = s.compute_start()
@ -947,7 +947,7 @@ Welcome to the wiki page!
# Adding subscription for Krophil
s = Subscription(
member=User.objects.filter(pk=krophil.pk).first(),
subscription_type=list(settings.SITH_SUBSCRIPTIONS.keys())[0],
subscription_type=next(iter(settings.SITH_SUBSCRIPTIONS.keys())),
payment_method=settings.SITH_SUBSCRIPTION_PAYMENT_METHOD[0][0],
)
s.subscription_start = s.compute_start()

View File

@ -217,9 +217,9 @@ class Command(BaseCommand):
UV.objects.bulk_create(uvs, ignore_conflicts=True)
def create_products(self):
categories = []
for _ in range(10):
categories.append(ProductType(name=self.faker.text(max_nb_chars=30)))
categories = [
ProductType(name=self.faker.text(max_nb_chars=30)) for _ in range(10)
]
ProductType.objects.bulk_create(categories)
categories = list(
ProductType.objects.filter(name__in=[c.name for c in categories])
@ -254,16 +254,16 @@ class Command(BaseCommand):
archived=bool(random.random() > 0.7),
)
products.append(product)
for group in random.sample(groups, k=random.randint(0, 3)):
# there will be products without buying groups
# but there are also such products in the real database
buying_groups.append(
Product.buying_groups.through(product=product, group=group)
)
for counter in random.sample(counters, random.randint(0, 4)):
selling_places.append(
Counter.products.through(counter=counter, product=product)
)
# there will be products without buying groups
# but there are also such products in the real database
buying_groups.extend(
Product.buying_groups.through(product=product, group=group)
for group in random.sample(groups, k=random.randint(0, 3))
)
selling_places.extend(
Counter.products.through(counter=counter, product=product)
for counter in random.sample(counters, random.randint(0, 4))
)
Product.objects.bulk_create(products)
Product.buying_groups.through.objects.bulk_create(buying_groups)
Counter.products.through.objects.bulk_create(selling_places)

View File

@ -174,7 +174,7 @@ def validate_promo(value: int) -> None:
)
def get_group(*, pk: int = None, name: str = None) -> Group | None:
def get_group(*, pk: int | None = None, name: str | None = None) -> Group | None:
"""Search for a group by its primary key or its name.
Either one of the two must be set.
@ -445,7 +445,7 @@ class User(AbstractBaseUser):
else:
return 0
def is_in_group(self, *, pk: int = None, name: str = None) -> bool:
def is_in_group(self, *, pk: int | None = None, name: str | None = None) -> bool:
"""Check if this user is in the given group.
Either a group id or a group name must be provided.
If both are passed, only the id will be considered.
@ -649,7 +649,7 @@ class User(AbstractBaseUser):
continue
links = list(User.godfathers.through.objects.filter(**{key: self.id}))
res.extend(links)
for _ in range(1, depth):
for _ in range(1, depth): # noqa: F402 we don't care about gettext here
ids = [getattr(c, reverse_key) for c in links]
links = list(
User.godfathers.through.objects.filter(
@ -703,9 +703,7 @@ class User(AbstractBaseUser):
return True
if hasattr(obj, "owner_group") and self.is_in_group(pk=obj.owner_group.id):
return True
if self.is_root:
return True
return False
return self.is_root
def can_edit(self, obj):
"""Determine if the object can be edited by the user."""
@ -717,9 +715,7 @@ class User(AbstractBaseUser):
return True
if isinstance(obj, User) and obj == self:
return True
if self.is_owner(obj):
return True
return False
return self.is_owner(obj)
def can_view(self, obj):
"""Determine if the object can be viewed by the user."""
@ -729,9 +725,7 @@ class User(AbstractBaseUser):
for pk in obj.view_groups.values_list("pk", flat=True):
if self.is_in_group(pk=pk):
return True
if self.can_edit(obj):
return True
return False
return self.can_edit(obj)
def can_be_edited_by(self, user):
return user.is_root or user.is_board_member
@ -759,23 +753,17 @@ class User(AbstractBaseUser):
@cached_property
def preferences(self):
try:
if hasattr(self, "_preferences"):
return self._preferences
except:
prefs = Preferences(user=self)
prefs.save()
return prefs
return Preferences.objects.create(user=self)
@cached_property
def forum_infos(self):
try:
if hasattr(self, "_forum_infos"):
return self._forum_infos
except:
from forum.models import ForumUserInfo
from forum.models import ForumUserInfo
infos = ForumUserInfo(user=self)
infos.save()
return infos
return ForumUserInfo.objects.create(user=self)
@cached_property
def clubs_with_rights(self) -> list[Club]:
@ -840,7 +828,7 @@ class AnonymousUser(AuthAnonymousUser):
def favorite_topics(self):
raise PermissionDenied
def is_in_group(self, *, pk: int = None, name: str = None) -> bool:
def is_in_group(self, *, pk: int | None = None, name: str | None = None) -> bool:
"""The anonymous user is only in the public group."""
allowed_id = settings.SITH_GROUP_PUBLIC_ID
if pk is not None:
@ -867,9 +855,7 @@ class AnonymousUser(AuthAnonymousUser):
and obj.view_groups.filter(id=settings.SITH_GROUP_PUBLIC_ID).exists()
):
return True
if hasattr(obj, "can_be_viewed_by") and obj.can_be_viewed_by(self):
return True
return False
return hasattr(obj, "can_be_viewed_by") and obj.can_be_viewed_by(self)
def get_display_name(self):
return _("Visitor")
@ -1070,7 +1056,7 @@ class SithFile(models.Model):
]:
self.file.delete()
self.file = None
except:
except: # noqa E722 I don't know the exception that can be raised
self.file = None
self.mime_type = "inode/directory"
if self.is_file and (self.file is None or self.file == ""):
@ -1196,12 +1182,12 @@ class SithFile(models.Model):
return Album.objects.filter(id=self.id).first()
def get_parent_list(self):
l = []
p = self.parent
while p is not None:
l.append(p)
p = p.parent
return l
parents = []
current = self.parent
while current is not None:
parents.append(current)
current = current.parent
return parents
def get_parent_path(self):
return "/" + "/".join([p.name for p in self.get_parent_list()[::-1]])
@ -1359,22 +1345,18 @@ class Page(models.Model):
if hasattr(self, "club") and self.club.can_be_edited_by(user):
# Override normal behavior for clubs
return True
if self.name == settings.SITH_CLUB_ROOT_PAGE and user.is_board_member:
return True
return False
return self.name == settings.SITH_CLUB_ROOT_PAGE and user.is_board_member
def can_be_viewed_by(self, user):
if self.is_club_page:
return True
return False
return self.is_club_page
def get_parent_list(self):
l = []
p = self.parent
while p is not None:
l.append(p)
p = p.parent
return l
parents = []
current = self.parent
while current is not None:
parents.append(current)
current = current.parent
return parents
def is_locked(self):
"""Is True if the page is locked, False otherwise.
@ -1386,7 +1368,6 @@ class Page(models.Model):
if self.lock_timeout and (
timezone.now() - self.lock_timeout > timedelta(minutes=5)
):
# print("Lock timed out")
self.unset_lock()
return (
self.lock_user
@ -1401,7 +1382,6 @@ class Page(models.Model):
self.lock_user = user
self.lock_timeout = timezone.now()
super().save()
# print("Locking page")
def set_lock_recursive(self, user):
"""Locks recursively all the child pages for editing properties."""
@ -1420,7 +1400,6 @@ class Page(models.Model):
self.lock_user = None
self.lock_timeout = None
super().save()
# print("Unlocking page")
def get_lock(self):
"""Returns the page's mutex containing the time and the user in a dict."""
@ -1435,13 +1414,11 @@ class Page(models.Model):
"""
if self.parent is None:
return self.name
return "/".join([self.parent.get_full_name(), self.name])
return f"{self.parent.get_full_name()}/{self.name}"
def get_display_name(self):
try:
return self.revisions.last().title
except:
return self.name
rev = self.revisions.last()
return rev.title if rev is not None else self.name
@cached_property
def is_club_page(self):

View File

@ -28,6 +28,7 @@ input[type="file"] {
font-size: 1.2em;
border-radius: 5px;
color: black;
&:hover {
background: hsl(0, 0%, 83%);
}
@ -63,6 +64,7 @@ textarea[type="text"],
border-radius: 5px;
max-width: 95%;
}
textarea {
border: none;
text-decoration: none;
@ -72,6 +74,7 @@ textarea {
border-radius: 5px;
font-family: sans-serif;
}
select {
border: none;
text-decoration: none;
@ -85,9 +88,11 @@ select {
a:not(.button) {
text-decoration: none;
color: $primary-dark-color;
&:hover {
color: $primary-light-color;
}
&:active {
color: $primary-color;
}
@ -116,7 +121,9 @@ a:not(.button) {
}
@keyframes rotate {
100% { transform: rotate(360deg); }
100% {
transform: rotate(360deg);
}
}
.ib {
@ -143,11 +150,13 @@ a:not(.button) {
.collapse-header-icon {
transition: all ease-in-out 150ms;
&.reverse {
transform: rotate(180deg);
}
}
}
.collapse-body {
padding: 10px;
}
@ -202,9 +211,11 @@ a:not(.button) {
font-size: 0.9em;
margin: 0.2em;
border-radius: 0.6em;
.markdown {
margin: 0.5em;
}
&:before {
font-family: FontAwesome;
font-size: 4em;
@ -212,15 +223,19 @@ a:not(.button) {
margin: 0.2em;
}
}
#info_box {
background: $primary-neutral-light-color;
&:before {
content: "\f05a";
color: hsl(210, 100%, 56%);
}
}
#alert_box {
background: $second-color;
&:before {
content: "\f06a";
color: $white-color;
@ -240,7 +255,8 @@ a:not(.button) {
#page {
width: 90%;
margin: 20px auto 0;
/*---------------------------------NAV---------------------------------*/
/*---------------------------------NAV---------------------------------*/
.btn {
font-size: 15px;
font-weight: normal;
@ -252,9 +268,11 @@ a:not(.button) {
&.btn-blue {
background-color: $deepblue;
&:not(:disabled):hover {
background-color: darken($deepblue, 10%);
}
&:disabled {
background-color: rgba(70, 90, 126, 0.4);
}
@ -262,9 +280,11 @@ a:not(.button) {
&.btn-grey {
background-color: grey;
&:not(:disabled):hover {
background-color: darken(gray, 15%);
}
&:disabled {
background-color: lighten(gray, 15%);
}
@ -273,9 +293,11 @@ a:not(.button) {
&.btn-red {
background-color: #fc8181;
color: black;
&:not(:disabled):hover {
background-color: darken(#fc8181, 15%);
}
&:disabled {
background-color: lighten(#fc8181, 15%);
color: grey;
@ -287,12 +309,13 @@ a:not(.button) {
}
}
/*--------------------------------CONTENT------------------------------*/
/*--------------------------------CONTENT------------------------------*/
#quick_notif {
width: 100%;
margin: 0 auto;
list-style-type: none;
background: $second-color;
li {
padding: 10px;
}
@ -333,14 +356,24 @@ a:not(.button) {
border: #fc8181 1px solid;
}
.alert-title {
margin-top: 0;
}
.alert-main {
flex: 2;
}
.alert-aside {
display: flex;
flex-direction: column;
}
}
.tool_bar {
overflow: auto;
padding: 4px;
.tools {
display: flex;
flex-wrap: wrap;
@ -349,6 +382,7 @@ a:not(.button) {
padding: 5px;
border-radius: 6px;
text-align: center;
a {
padding: 7px;
display: inline-block;
@ -358,11 +392,13 @@ a:not(.button) {
flex: 1;
flex-wrap: nowrap;
white-space: nowrap;
&.selected_tab {
background: $primary-color;
color: $white-color;
border-radius: 6px;
}
&:hover {
background: $primary-color;
color: $white-color;
@ -372,7 +408,7 @@ a:not(.button) {
}
}
/*---------------------------------NEWS--------------------------------*/
/*---------------------------------NEWS--------------------------------*/
#news {
display: flex;
@ -385,17 +421,21 @@ a:not(.button) {
margin: 0;
vertical-align: top;
}
#news_admin {
margin-bottom: 1em;
}
#right_column {
flex: 20%;
float: right;
margin: 0.2em;
}
#left_column {
flex: 79%;
margin: 0.2em;
h3 {
background: $second-color;
box-shadow: $shadow-color 1px 1px 1px;
@ -403,19 +443,22 @@ a:not(.button) {
margin: 0 0 0.5em 0;
text-transform: uppercase;
font-size: 1.1em;
&:not(:first-of-type) {
margin: 2em 0 1em 0;
}
}
}
@media screen and (max-width: $small-devices) {
#left_column,
#right_column {
flex: 100%;
}
}
/* AGENDA/BIRTHDAYS */
/* AGENDA/BIRTHDAYS */
#agenda,
#birthdays {
display: block;
@ -423,6 +466,7 @@ a:not(.button) {
background: white;
font-size: 70%;
margin-bottom: 1em;
#agenda_title,
#birthdays_title {
margin: 0;
@ -435,39 +479,48 @@ a:not(.button) {
text-transform: uppercase;
background: $second-color;
}
#agenda_content {
overflow: auto;
box-shadow: $shadow-color 1px 1px 1px;
height: 20em;
}
#agenda_content,
#birthdays_content {
.agenda_item {
padding: 0.5em;
margin-bottom: 0.5em;
&:nth-of-type(even) {
background: $secondary-neutral-light-color;
}
.agenda_time {
font-size: 90%;
color: grey;
}
.agenda_item_content {
p {
margin-top: 0.2em;
}
}
}
ul.birthdays_year {
margin: 0;
list-style-type: none;
font-weight: bold;
> li {
>li {
padding: 0.5em;
&:nth-child(even) {
background: $secondary-neutral-light-color;
}
}
ul {
margin: 0;
margin-left: 1em;
@ -478,13 +531,15 @@ a:not(.button) {
}
}
}
/* END AGENDA/BIRTHDAYS */
/* EVENTS TODAY AND NEXT FEW DAYS */
/* END AGENDA/BIRTHDAYS */
/* EVENTS TODAY AND NEXT FEW DAYS */
.news_events_group {
box-shadow: $shadow-color 1px 1px 1px;
margin-left: 1em;
margin-bottom: 0.5em;
.news_events_group_date {
display: table-cell;
padding: 0.6em;
@ -500,33 +555,42 @@ a:not(.button) {
div {
margin: 0 auto;
.day {
font-size: 1.5em;
}
}
}
.news_events_group_items {
display: table-cell;
width: 100%;
.news_event:nth-of-type(odd) {
background: white;
}
.news_event:nth-of-type(even) {
background: $primary-neutral-light-color;
}
.news_event {
display: block;
padding: 0.4em;
&:not(:last-child) {
border-bottom: 1px solid grey;
}
div {
margin: 0.2em;
}
h4 {
margin-top: 1em;
text-transform: uppercase;
}
.club_logo {
float: left;
min-width: 7em;
@ -534,6 +598,7 @@ a:not(.button) {
margin: 0;
margin-right: 1em;
margin-top: 0.8em;
img {
max-height: 6em;
max-width: 8em;
@ -541,16 +606,21 @@ a:not(.button) {
margin: 0 auto;
}
}
.news_date {
font-size: 100%;
}
.news_content {
clear: left;
.button_bar {
text-align: right;
.fb {
color: $faceblue;
}
.twitter {
color: $twitblue;
}
@ -559,26 +629,30 @@ a:not(.button) {
}
}
}
/* END EVENTS TODAY AND NEXT FEW DAYS */
/* COMING SOON */
/* END EVENTS TODAY AND NEXT FEW DAYS */
/* COMING SOON */
.news_coming_soon {
display: list-item;
list-style-type: square;
list-style-position: inside;
margin-left: 1em;
padding-left: 0;
a {
font-weight: bold;
text-transform: uppercase;
}
.news_date {
font-size: 0.9em;
}
}
/* END COMING SOON */
/* NOTICES */
/* END COMING SOON */
/* NOTICES */
.news_notice {
margin: 0 0 1em 1em;
padding: 0.4em;
@ -586,16 +660,19 @@ a:not(.button) {
background: $secondary-neutral-light-color;
box-shadow: $shadow-color 0 0 2px;
border-radius: 18px 5px 18px 5px;
h4 {
margin: 0;
}
.news_content {
margin-left: 1em;
}
}
/* END NOTICES */
/* CALLS */
/* END NOTICES */
/* CALLS */
.news_call {
margin: 0 0 1em 1em;
padding: 0.4em;
@ -603,21 +680,26 @@ a:not(.button) {
background: $secondary-neutral-light-color;
border: 1px solid grey;
box-shadow: $shadow-color 1px 1px 1px;
h4 {
margin: 0;
}
.news_date {
font-size: 0.9em;
}
.news_content {
margin-left: 1em;
}
}
/* END CALLS */
/* END CALLS */
.news_empty {
margin-left: 1em;
}
.news_date {
color: grey;
}
@ -631,8 +713,8 @@ a:not(.button) {
}
.select2 {
margin: 10px 0!important;
.tomselected {
margin: 10px 0 !important;
max-width: 100%;
min-width: 100%;
@ -648,7 +730,9 @@ a:not(.button) {
color: black;
}
}
.select2-results {
.ts-dropdown {
.select-item {
display: flex;
flex-direction: row;
@ -664,16 +748,39 @@ a:not(.button) {
}
}
.ts-control {
.item {
.fa-times {
margin-left: 5px;
margin-right: 5px;
}
cursor: pointer;
background-color: #e4e4e4;
border: 1px solid #aaa;
border-radius: 4px;
display: inline-block;
margin-left: 5px;
margin-top: 5px;
margin-bottom: 5px;
padding-right: 10px;
}
}
#news_details {
display: inline-block;
margin-top: 20px;
padding: 0.4em;
width: 80%;
background: $white-color;
h4 {
margin-top: 1em;
text-transform: uppercase;
}
.club_logo {
display: inline-block;
text-align: center;
@ -681,6 +788,7 @@ a:not(.button) {
float: left;
min-width: 15em;
margin: 0;
img {
max-height: 15em;
max-width: 12em;
@ -689,6 +797,7 @@ a:not(.button) {
margin-bottom: 10px;
}
}
.share_button {
border: none;
color: white;
@ -700,6 +809,7 @@ a:not(.button) {
float: right;
display: block;
margin-left: 0.3em;
&:hover {
color: lightgrey;
}
@ -731,26 +841,32 @@ a:not(.button) {
#poster_edit,
#screen_edit {
position: relative;
#title {
position: relative;
padding: 10px;
margin: 10px;
border-bottom: 2px solid black;
h3 {
display: flex;
justify-content: center;
align-items: center;
}
#links {
position: absolute;
display: flex;
bottom: 5px;
&.left {
left: 0;
}
&.right {
right: 0;
}
.link {
padding: 5px;
padding-left: 20px;
@ -759,27 +875,32 @@ a:not(.button) {
border-radius: 20px;
background-color: hsl(40, 100%, 50%);
color: black;
&:hover {
color: black;
background-color: hsl(40, 58%, 50%);
}
&.delete {
background-color: hsl(0, 100%, 40%);
}
}
}
}
#posters,
#screens {
position: relative;
display: flex;
flex-wrap: wrap;
#no-posters,
#no-screens {
display: flex;
justify-content: center;
align-items: center;
}
.poster,
.screen {
min-width: 10%;
@ -791,26 +912,31 @@ a:not(.button) {
border-radius: 4px;
padding: 10px;
background-color: lightgrey;
* {
display: flex;
justify-content: center;
align-items: center;
}
.name {
padding-bottom: 5px;
margin-bottom: 5px;
border-bottom: 1px solid whitesmoke;
}
.image {
flex-grow: 1;
position: relative;
padding-bottom: 5px;
margin-bottom: 5px;
border-bottom: 1px solid whitesmoke;
img {
max-height: 20vw;
max-width: 100%;
}
&:hover {
&::before {
position: absolute;
@ -829,10 +955,12 @@ a:not(.button) {
}
}
}
.dates {
padding-bottom: 5px;
margin-bottom: 5px;
border-bottom: 1px solid whitesmoke;
* {
display: flex;
justify-content: center;
@ -841,15 +969,18 @@ a:not(.button) {
margin-left: 5px;
margin-right: 5px;
}
.begin,
.end {
width: 48%;
}
.begin {
border-right: 1px solid whitesmoke;
padding-right: 2%;
}
}
.edit,
.moderate,
.slideshow {
@ -857,15 +988,18 @@ a:not(.button) {
border-radius: 20px;
background-color: hsl(40, 100%, 50%);
color: black;
&:hover {
color: black;
background-color: hsl(40, 58%, 50%);
}
&:nth-child(2n) {
margin-top: 5px;
margin-bottom: 5px;
}
}
.tooltip {
visibility: hidden;
width: 120px;
@ -876,23 +1010,28 @@ a:not(.button) {
border-radius: 6px;
position: absolute;
z-index: 10;
ul {
margin-left: 0;
display: inline-block;
li {
display: list-item;
list-style-type: none;
}
}
}
&.not_moderated {
border: 1px solid red;
}
&:hover .tooltip {
visibility: visible;
}
}
}
#view {
position: fixed;
width: 100vw;
@ -906,9 +1045,11 @@ a:not(.button) {
visibility: hidden;
background-color: rgba(10, 10, 10, 0.9);
overflow: hidden;
&.active {
visibility: visible;
}
#placeholder {
width: 80vw;
height: 80vh;
@ -917,6 +1058,7 @@ a:not(.button) {
align-items: center;
top: 0;
left: 0;
img {
max-width: 100%;
max-height: 100%;
@ -931,14 +1073,17 @@ a:not(.button) {
tbody {
.neg-amount {
color: red;
&:before {
font-family: FontAwesome;
font-size: 1em;
content: "\f063";
}
}
.pos-amount {
color: green;
&:before {
font-family: FontAwesome;
font-size: 1em;
@ -1005,6 +1150,7 @@ dt {
.edit-bar {
display: block;
margin: 4px;
a {
display: inline-block;
margin: 4px;
@ -1044,7 +1190,8 @@ th {
vertical-align: middle;
text-align: center;
padding: 5px 10px;
> ul {
>ul {
margin-top: 0;
}
}
@ -1055,7 +1202,8 @@ td {
vertical-align: top;
overflow: hidden;
text-overflow: ellipsis;
> ul {
>ul {
margin-top: 0;
}
}
@ -1071,15 +1219,17 @@ thead {
color: white;
}
tbody > tr {
tbody>tr {
&:nth-child(even):not(.highlight) {
background: $primary-neutral-light-color;
}
&.clickable:hover {
cursor: pointer;
background: $secondary-neutral-light-color;
width: 100%;
}
&.highlight {
color: $primary-dark-color;
font-style: italic;
@ -1139,9 +1289,11 @@ u,
margin: 0.2em;
height: 100%;
background: $secondary-neutral-light-color;
img {
max-width: 70%;
}
input {
background: white;
}
@ -1153,10 +1305,12 @@ u,
.user_mini_profile {
height: 100%;
width: 100%;
img {
max-width: 100%;
max-height: 100%;
}
.user_mini_profile_infos {
padding: 0.2em;
height: 20%;
@ -1164,16 +1318,20 @@ u,
flex-wrap: nowrap;
justify-content: space-around;
font-size: 0.9em;
div {
max-height: 100%;
}
.user_mini_profile_infos_text {
text-align: center;
.user_mini_profile_nick {
font-style: italic;
}
}
}
.user_mini_profile_picture {
height: 80%;
display: flex;
@ -1185,14 +1343,17 @@ u,
.mini_profile_link {
display: block;
text-decoration: none;
span {
display: inline-block;
width: 50px;
vertical-align: middle;
}
em {
vertical-align: middle;
}
img {
max-width: 40px;
max-height: 60px;
@ -1214,6 +1375,7 @@ u,
border: solid 1px red;
text-align: center;
}
img {
width: 500px;
}
@ -1223,6 +1385,7 @@ u,
.matmat_results {
display: flex;
flex-wrap: wrap;
.matmat_user {
flex-basis: 14em;
align-self: flex-start;
@ -1231,10 +1394,12 @@ u,
overflow: hidden;
border: 1px solid black;
box-shadow: $shadow-color 1px 1px 1px;
&:hover {
box-shadow: 1px 1px 5px $second-color;
}
}
.matmat_user a {
color: $primary-neutral-dark-color;
height: 100%;
@ -1274,6 +1439,7 @@ footer {
font-size: 90%;
text-align: center;
vertical-align: middle;
div {
margin: 0.6em 0;
color: $white-color;
@ -1283,18 +1449,20 @@ footer {
align-items: center;
background-color: $primary-neutral-dark-color;
box-shadow: $shadow-color 0 0 15px;
a {
padding: 0.8em;
flex: 1;
font-weight: bold;
color: $white-color !important;
&:hover {
color: $primary-dark-color;
}
}
}
> .version {
>.version {
margin-top: 3px;
color: rgba(0, 0, 0, 0.3);
}
@ -1326,6 +1494,7 @@ label {
* {
text-align: center;
}
img {
width: 100px;
}
@ -1342,19 +1511,23 @@ label {
padding: 2px;
display: inline-block;
font-size: 0.8em;
span {
width: 70px;
float: right;
}
img {
max-width: 50px;
max-height: 50px;
float: left;
}
strong {
font-weight: bold;
font-size: 1.2em;
}
button {
vertical-align: middle;
}
@ -1371,6 +1544,7 @@ a.ui-button:active,
background: $primary-color;
border-color: $primary-color;
}
.ui-corner-all,
.ui-corner-bottom,
.ui-corner-right,
@ -1382,10 +1556,11 @@ a.ui-button:active,
#club_detail {
.club_logo {
float: right;
img {
display: block;
max-height: 10em;
max-width: 10em;
}
}
}
}

View File

@ -0,0 +1,93 @@
import "tom-select/dist/css/tom-select.css";
import { inheritHtmlElement, registerComponent } from "#core:utils/web-components";
import TomSelect from "tom-select";
import type { TomItem, TomLoadCallback, TomOption } from "tom-select/dist/types/types";
import type { escape_html } from "tom-select/dist/types/utils";
import { type UserProfileSchema, userSearchUsers } from "#openapi";
@registerComponent("ajax-select")
export class AjaxSelect extends inheritHtmlElement("select") {
public widget: TomSelect;
public filter?: <T>(items: T[]) => T[];
constructor() {
super();
window.addEventListener("DOMContentLoaded", () => {
this.loadTomSelect();
});
}
loadTomSelect() {
const minCharNumberForSearch = 2;
let maxItems = 1;
if (this.node.multiple) {
maxItems = Number.parseInt(this.node.dataset.max) ?? null;
}
this.widget = new TomSelect(this.node, {
hideSelected: true,
diacritics: true,
duplicates: false,
maxItems: maxItems,
loadThrottle: Number.parseInt(this.node.dataset.delay) ?? null,
valueField: "id",
labelField: "display_name",
searchField: ["display_name", "nick_name", "first_name", "last_name"],
placeholder: this.node.dataset.placeholder ?? "",
shouldLoad: (query: string) => {
return query.length >= minCharNumberForSearch; // Avoid launching search with less than 2 characters
},
load: (query: string, callback: TomLoadCallback) => {
userSearchUsers({
query: {
search: query,
},
}).then((response) => {
if (response.data) {
if (this.filter) {
callback(this.filter(response.data.results), []);
} else {
callback(response.data.results, []);
}
return;
}
callback([], []);
});
},
render: {
option: (item: UserProfileSchema, sanitize: typeof escape_html) => {
return `<div class="select-item">
<img
src="${sanitize(item.profile_pict)}"
alt="${sanitize(item.display_name)}"
onerror="this.src = '/static/core/img/unknown.jpg'"
/>
<span class="select-item-text">${sanitize(item.display_name)}</span>
</div>`;
},
item: (item: UserProfileSchema, sanitize: typeof escape_html) => {
return `<span><i class="fa fa-times"></i>${sanitize(item.display_name)}</span>`;
},
// biome-ignore lint/style/useNamingConvention: that's how it's defined
not_loading: (data: TomOption, _sanitize: typeof escape_html) => {
return `<div class="no-results">${interpolate(gettext("You need to type %(number)s more characters"), { number: minCharNumberForSearch - data.input.length }, true)}</div>`;
},
// biome-ignore lint/style/useNamingConvention: that's how it's defined
no_results: (_data: TomOption, _sanitize: typeof escape_html) => {
return `<div class="no-results">${gettext("No results found")}</div>`;
},
},
});
// Allow removing selected items by clicking on them
this.widget.on("item_select", (item: TomItem) => {
this.widget.removeItem(item);
});
// Remove typed text once an item has been selected
this.widget.on("item_add", () => {
this.widget.setTextboxValue("");
});
}
}

View File

@ -1,53 +1,57 @@
// biome-ignore lint/correctness/noUndeclaredDependencies: shipped by easymde
import "codemirror/lib/codemirror.css";
import "easymde/src/css/easymde.css";
import easyMde from "easymde";
import { inheritHtmlElement, registerComponent } from "#core:utils/web-components";
// biome-ignore lint/correctness/noUndeclaredDependencies: Imported by EasyMDE
import type CodeMirror from "codemirror";
// biome-ignore lint/style/useNamingConvention: This is how they called their namespace
import EasyMDE from "easymde";
import { markdownRenderMarkdown } from "#openapi";
/**
* Create a new easymde based textarea
* @param {HTMLTextAreaElement} textarea to use
**/
window.easymdeFactory = (textarea) => {
const easymde = new easyMde({
const loadEasyMde = (textarea: HTMLTextAreaElement) => {
new EasyMDE({
element: textarea,
spellChecker: false,
autoDownloadFontAwesome: false,
previewRender: Alpine.debounce(async (plainText, preview) => {
preview.innerHTML = (
await markdownRenderMarkdown({ body: { text: plainText } })
).data;
previewRender: Alpine.debounce((plainText: string, preview: MarkdownInput) => {
const func = async (plainText: string, preview: MarkdownInput): Promise<null> => {
preview.innerHTML = (
await markdownRenderMarkdown({ body: { text: plainText } })
).data as string;
return null;
};
func(plainText, preview);
return null;
}, 300),
forceSync: true, // Avoid validation error on generic create view
toolbar: [
{
name: "heading-smaller",
action: easyMde.toggleHeadingSmaller,
action: EasyMDE.toggleHeadingSmaller,
className: "fa fa-header",
title: gettext("Heading"),
},
{
name: "italic",
action: easyMde.toggleItalic,
action: EasyMDE.toggleItalic,
className: "fa fa-italic",
title: gettext("Italic"),
},
{
name: "bold",
action: easyMde.toggleBold,
action: EasyMDE.toggleBold,
className: "fa fa-bold",
title: gettext("Bold"),
},
{
name: "strikethrough",
action: easyMde.toggleStrikethrough,
action: EasyMDE.toggleStrikethrough,
className: "fa fa-strikethrough",
title: gettext("Strikethrough"),
},
{
name: "underline",
action: function customFunction(editor) {
action: function customFunction(editor: { codemirror: CodeMirror.Editor }) {
const cm = editor.codemirror;
cm.replaceSelection(`__${cm.getSelection()}__`);
},
@ -56,7 +60,7 @@ window.easymdeFactory = (textarea) => {
},
{
name: "superscript",
action: function customFunction(editor) {
action: function customFunction(editor: { codemirror: CodeMirror.Editor }) {
const cm = editor.codemirror;
cm.replaceSelection(`^${cm.getSelection()}^`);
},
@ -65,7 +69,7 @@ window.easymdeFactory = (textarea) => {
},
{
name: "subscript",
action: function customFunction(editor) {
action: function customFunction(editor: { codemirror: CodeMirror.Editor }) {
const cm = editor.codemirror;
cm.replaceSelection(`~${cm.getSelection()}~`);
},
@ -74,71 +78,71 @@ window.easymdeFactory = (textarea) => {
},
{
name: "code",
action: easyMde.toggleCodeBlock,
action: EasyMDE.toggleCodeBlock,
className: "fa fa-code",
title: gettext("Code"),
},
"|",
{
name: "quote",
action: easyMde.toggleBlockquote,
action: EasyMDE.toggleBlockquote,
className: "fa fa-quote-left",
title: gettext("Quote"),
},
{
name: "unordered-list",
action: easyMde.toggleUnorderedList,
action: EasyMDE.toggleUnorderedList,
className: "fa fa-list-ul",
title: gettext("Unordered list"),
},
{
name: "ordered-list",
action: easyMde.toggleOrderedList,
action: EasyMDE.toggleOrderedList,
className: "fa fa-list-ol",
title: gettext("Ordered list"),
},
"|",
{
name: "link",
action: easyMde.drawLink,
action: EasyMDE.drawLink,
className: "fa fa-link",
title: gettext("Insert link"),
},
{
name: "image",
action: easyMde.drawImage,
action: EasyMDE.drawImage,
className: "fa-regular fa-image",
title: gettext("Insert image"),
},
{
name: "table",
action: easyMde.drawTable,
action: EasyMDE.drawTable,
className: "fa fa-table",
title: gettext("Insert table"),
},
"|",
{
name: "clean-block",
action: easyMde.cleanBlock,
action: EasyMDE.cleanBlock,
className: "fa fa-eraser fa-clean-block",
title: gettext("Clean block"),
},
"|",
{
name: "preview",
action: easyMde.togglePreview,
action: EasyMDE.togglePreview,
className: "fa fa-eye no-disable",
title: gettext("Toggle preview"),
},
{
name: "side-by-side",
action: easyMde.toggleSideBySide,
action: EasyMDE.toggleSideBySide,
className: "fa fa-columns no-disable no-mobile",
title: gettext("Toggle side by side"),
},
{
name: "fullscreen",
action: easyMde.toggleFullScreen,
action: EasyMDE.toggleFullScreen,
className: "fa fa-expand no-mobile",
title: gettext("Toggle fullscreen"),
},
@ -152,27 +156,25 @@ window.easymdeFactory = (textarea) => {
],
});
const submits = textarea.closest("form").querySelectorAll('input[type="submit"]');
const parentDiv = textarea.parentElement;
let submitPressed = false;
const submits: HTMLInputElement[] = Array.from(
textarea.closest("form").querySelectorAll('input[type="submit"]'),
);
const parentDiv = textarea.parentElement.parentElement;
function checkMarkdownInput() {
function checkMarkdownInput(event: Event) {
// an attribute is null if it does not exist, else a string
const required = textarea.getAttribute("required") != null;
const length = textarea.value.trim().length;
if (required && length === 0) {
parentDiv.style.boxShadow = "red 0px 0px 1.5px 1px";
event.preventDefault();
} else {
parentDiv.style.boxShadow = "";
}
}
function onSubmitClick(e) {
if (!submitPressed) {
easymde.codemirror.on("change", checkMarkdownInput);
}
submitPressed = true;
function onSubmitClick(e: Event) {
checkMarkdownInput(e);
}
@ -180,3 +182,11 @@ window.easymdeFactory = (textarea) => {
submit.addEventListener("click", onSubmitClick);
}
};
@registerComponent("markdown-input")
class MarkdownInput extends inheritHtmlElement("textarea") {
constructor() {
super();
window.addEventListener("DOMContentLoaded", () => loadEasyMde(this.node));
}
}

View File

@ -13,9 +13,6 @@ require("jquery-ui/ui/widgets/tabs.js");
require("jquery-ui/themes/base/all.css");
// We ship select2 here, otherwise it will duplicate jquery everywhere we load it
import "select2";
/**
* Simple wrapper to solve shorten not being able on legacy pages
* @param {string} selector to be passed to jQuery

View File

@ -3,6 +3,7 @@ import type { Alpine as AlpineType } from "alpinejs";
declare global {
const Alpine: AlpineType;
const gettext: (text: string) => string;
const interpolate: <T>(fmt: string, args: string[] | T, isNamed?: boolean) => string;
}
/**

View File

@ -1,282 +0,0 @@
/**
* Builders to use Select2 in our templates.
*
* This comes with two flavours : local data or remote data.
*
* # Local data source
*
* To use local data source, you must define an array
* in your JS code, having the fields `id` and `text`.
*
* ```js
* const data = [
* {id: 1, text: "foo"},
* {id: 2, text: "bar"},
* ];
* document.addEventListener("DOMContentLoaded", () => sithSelect2({
* element: document.getElementById("select2-input"),
* dataSource: localDataSource(data)
* }));
* ```
*
* You can also define a callback that return ids to exclude :
*
* ```js
* const data = [
* {id: 1, text: "foo"},
* {id: 2, text: "bar"},
* {id: 3, text: "to exclude"},
* ];
* document.addEventListener("DOMContentLoaded", () => sithSelect2({
* element: document.getElementById("select2-input"),
* dataSource: localDataSource(data, {
* excluded: () => data.filter((i) => i.text === "to exclude").map((i) => parseInt(i))
* })
* }));
* ```
*
* # Remote data source
*
* Select2 with remote data sources are similar to those with local
* data, but with some more parameters, like `resultConverter`,
* which takes a callback that must return a `Select2Object`.
*
* ```js
* import { makeUrl } from "#core:utils/api";
* import {userSearchUsers } from "#openapi"
* document.addEventListener("DOMContentLoaded", () => sithSelect2({
* element: document.getElementById("select2-input"),
* dataSource: remoteDataSource(await makeUrl(userSearchUsers), {
* excluded: () => [1, 2], // exclude users 1 and 2 from the search
* resultConverter: (user: AjaxResponse) => {id: user.id, text: (user.firstName as UserType)}
* })
* }));
* ```
*
* # Overrides
*
* Dealing with a select2 may be complex.
* That's why, when defining a select,
* you may add an override parameter,
* in which you can declare any parameter defined in the
* Select2 documentation.
*
* ```js
* import { makeUrl } from "#core:utils/api";
* import {userSearchUsers } from "#openapi"
* document.addEventListener("DOMContentLoaded", () => sithSelect2({
* element: document.getElementById("select2-input"),
* dataSource: remoteDataSource(await makeUrl(userSearchUsers), {
* resultConverter: (user: AjaxResponse) => {id: user.id, text: (user.firstName as UserType)}
* overrides: {
* delay: 500
* }
* })
* }));
* ```
*
* # Caveats with exclude
*
* With local data source, select2 evaluates the data only once.
* Thus, modify the exclude after the initialisation is a no-op.
*
* With remote data source, the exclude list will be evaluated
* after each api response.
* It makes it possible to bind the data returned by the callback
* to some reactive data, thus making the exclude list dynamic.
*
* # Images
*
* Sometimes, you would like to display an image besides
* the text on the select items.
* In this case, fill the `pictureGetter` option :
*
* ```js
* import { makeUrl } from "#core:utils/api";
* import {userSearchUsers } from "#openapi"
* document.addEventListener("DOMContentLoaded", () => sithSelect2({
* element: document.getElementById("select2-input"),
* dataSource: remoteDataSource(await makeUrl(userSearchUsers), {
* resultConverter: (user: AjaxResponse) => {id: user.id, text: (user.firstName as UserType)}
* })
* pictureGetter: (user) => user.profilePict,
* }));
* ```
*
* # Binding with alpine
*
* You can declare your select2 component in an Alpine data.
*
* ```html
* <body>
* <div x-data="select2_test">
* <select x-ref="search" x-ref="select"></select>
* <p x-text="currentSelection.id"></p>
* <p x-text="currentSelection.text"></p>
* </div>
* </body>
*
* <script>
* document.addEventListener("alpine:init", () => {
* Alpine.data("select2_test", () => ({
* selector: undefined,
* currentSelect: {id: "", text: ""},
*
* init() {
* this.selector = sithSelect2({
* element: $(this.$refs.select),
* dataSource: localDataSource(
* [{id: 1, text: "foo"}, {id: 2, text: "bar"}]
* ),
* });
* this.selector.on("select2:select", (event) => {
* // select2 => Alpine signals here
* this.currentSelect = this.selector.select2("data")
* });
* this.$watch("currentSelected" (value) => {
* // Alpine => select2 signals here
* });
* },
* }));
* })
* </script>
*/
import type {
AjaxOptions,
DataFormat,
GroupedDataFormat,
LoadingData,
Options,
PlainObject,
} from "select2";
import "select2/dist/css/select2.css";
export interface Select2Object {
id: number;
text: string;
}
// biome-ignore lint/suspicious/noExplicitAny: You have to do it at some point
export type RemoteResult = any;
export type AjaxResponse = AjaxOptions<DataFormat | GroupedDataFormat, RemoteResult>;
interface DataSource {
ajax?: AjaxResponse | undefined;
data?: RemoteResult | DataFormat[] | GroupedDataFormat[] | undefined;
}
interface Select2Options {
element: Element;
/** the data source, built with `localDataSource` or `remoteDataSource` */
dataSource: DataSource;
excluded?: number[];
/** A callback to get the picture field from the API response */
pictureGetter?: (element: LoadingData | DataFormat | GroupedDataFormat) => string;
/** Any other select2 parameter to apply on the config */
overrides?: Options;
}
/**
* Create a new select2 with sith presets
*/
export function sithSelect2(options: Select2Options) {
const elem: PlainObject = $(options.element);
return elem.select2({
theme: elem[0].multiple ? "classic" : "default",
minimumInputLength: 2,
templateResult: selectItemBuilder(options.pictureGetter),
...options.dataSource,
...(options.overrides ?? {}),
});
}
interface LocalSourceOptions {
excluded: () => number[];
}
/**
* Build a data source for a Select2 from a local array
*/
export function localDataSource(
source: Select2Object[] /** Array containing the data */,
options: LocalSourceOptions,
): DataSource {
if (options.excluded) {
const ids = options.excluded();
return { data: source.filter((i) => !ids.includes(i.id)) };
}
return { data: source };
}
interface RemoteSourceOptions {
/** A callback to the ids to exclude from the search */
excluded?: () => number[];
/** A converter for a value coming from the remote api */
resultConverter?: ((obj: RemoteResult) => DataFormat | GroupedDataFormat) | undefined;
/** Any other select2 parameter to apply on the config */
overrides?: AjaxOptions;
}
/**
* Build a data source for a Select2 from a remote url
*/
export function remoteDataSource(
source: string /** url of the endpoint */,
options: RemoteSourceOptions,
): DataSource {
$.ajaxSetup({
traditional: true,
});
const params: AjaxOptions = {
url: source,
dataType: "json",
cache: true,
delay: 250,
data: function (params) {
return {
search: params.term,
exclude: [
...(this.val() || []).map((i: string) => Number.parseInt(i)),
...(options.excluded ? options.excluded() : []),
],
};
},
};
if (options.resultConverter) {
params.processResults = (data) => ({
results: data.results.map(options.resultConverter),
});
}
if (options.overrides) {
Object.assign(params, options.overrides);
}
return { ajax: params };
}
export function itemFormatter(user: { loading: boolean; text: string }) {
if (user.loading) {
return user.text;
}
}
/**
* Build a function to display the results
*/
export function selectItemBuilder(pictureGetter?: (item: RemoteResult) => string) {
return (item: RemoteResult) => {
const picture = typeof pictureGetter === "function" ? pictureGetter(item) : null;
const imgHtml = picture
? `<img
src="${pictureGetter(item)}"
alt="${item.text}"
onerror="this.src = '/static/core/img/unknown.jpg'"
/>`
: "";
return $(`<div class="select-item">
${imgHtml}
<span class="select-item-text">${item.text}</span>
</div>`);
};
}

View File

@ -0,0 +1,50 @@
/**
* Class decorator to register components easily
* It's a wrapper around window.customElements.define
* What's nice about it is that you don't separate the component registration
* and the class definition
**/
export function registerComponent(name: string, options?: ElementDefinitionOptions) {
return (component: CustomElementConstructor) => {
window.customElements.define(name, component, options);
};
}
/**
* Safari doesn't support inheriting from HTML tags on web components
* The technique is to:
* create a new web component
* create the desired type inside
* pass all attributes to the child component
* store is at as `node` inside the parent
*
* Since we can't use the generic type to instantiate the node, we create a generator function
*
* ```js
* class MyClass extends inheritHtmlElement("select") {
* // do whatever
* }
* ```
**/
export function inheritHtmlElement<K extends keyof HTMLElementTagNameMap>(tagName: K) {
return class Inherited extends HTMLElement {
protected node: HTMLElementTagNameMap[K];
constructor() {
super();
this.node = document.createElement(tagName);
const attributes: Attr[] = []; // We need to make a copy to delete while iterating
for (const attr of this.attributes) {
if (attr.name in this.node) {
attributes.push(attr);
}
}
for (const attr of attributes) {
this.removeAttributeNode(attr);
this.node.setAttributeNode(attr);
}
this.appendChild(this.node);
}
};
}

View File

@ -5,6 +5,7 @@
<title>{% block title %}{% trans %}Welcome!{% endtrans %}{% endblock %} - Association des Étudiants UTBM</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="shortcut icon" href="{{ static('core/img/favicon.ico') }}">
<link rel="stylesheet" href="{{ static('user/user_stats.scss') }}">
<link rel="stylesheet" href="{{ static('core/base.css') }}">
<link rel="stylesheet" href="{{ static('ajax_select/css/ajax_select.css') }}">
<link rel="stylesheet" href="{{ static('core/style.scss') }}">

View File

@ -1,13 +1,7 @@
<div>
<textarea name="{{ widget.name }}"{% include "django/forms/widgets/attrs.html" %}>{% if widget.value %}{{ widget.value }}{% endif %}</textarea>
<markdown-input name="{{ widget.name }}"{% include "django/forms/widgets/attrs.html" %}>{% if widget.value %}{{ widget.value }}{% endif %}</markdown-input>
{# The easymde script can be included twice, it's safe in the code #}
<script src="{{ statics.js }}" defer> </script>
<link rel="stylesheet" type="text/css" href="{{ statics.css }}" defer>
<script type="text/javascript">
addEventListener("DOMContentLoaded", (event) => {
easymdeFactory(
document.getElementById("{{ widget.attrs.id }}"));
})
</script>
</div>

View File

@ -50,7 +50,7 @@ def phonenumber(
try:
parsed = phonenumbers.parse(value, country)
return phonenumbers.format_number(parsed, number_format)
except phonenumbers.NumberParseException as e:
except phonenumbers.NumberParseException:
return value

View File

@ -343,7 +343,7 @@ class TestUserTools:
response = client.get(reverse("core:user_tools"))
assertRedirects(
response,
expected_url=f"/login?next=%2Fuser%2Ftools%2F",
expected_url="/login?next=%2Fuser%2Ftools%2F",
target_status_code=301,
)

View File

@ -73,7 +73,7 @@ class TestFetchFamilyApi(TestCase):
self.client.force_login(self.main_user)
response = self.client.get(
reverse("api:family_graph", args=[self.main_user.id])
+ f"?godfathers_depth=0&godchildren_depth=0"
+ "?godfathers_depth=0&godchildren_depth=0"
)
assert response.status_code == 200
assert [u["id"] for u in response.json()["users"]] == [self.main_user.id]
@ -91,7 +91,7 @@ class TestFetchFamilyApi(TestCase):
self.client.force_login(self.main_user)
response = self.client.get(
reverse("api:family_graph", args=[self.main_user.id])
+ f"?godfathers_depth=10&godchildren_depth=10"
+ "?godfathers_depth=10&godchildren_depth=10"
)
assert response.status_code == 200
assert [u["id"] for u in response.json()["users"]] == [
@ -126,7 +126,7 @@ class TestFetchFamilyApi(TestCase):
self.client.force_login(self.main_user)
response = self.client.get(
reverse("api:family_graph", args=[self.main_user.id])
+ f"?godfathers_depth=1&godchildren_depth=1"
+ "?godfathers_depth=1&godchildren_depth=1"
)
assert response.status_code == 200
assert [u["id"] for u in response.json()["users"]] == [
@ -150,7 +150,7 @@ class TestFetchFamilyApi(TestCase):
self.client.force_login(self.main_user)
response = self.client.get(
reverse("api:family_graph", args=[self.main_user.id])
+ f"?godfathers_depth=10&godchildren_depth=0"
+ "?godfathers_depth=10&godchildren_depth=0"
)
assert response.status_code == 200
assert [u["id"] for u in response.json()["users"]] == [

View File

@ -29,13 +29,67 @@ from core.converters import (
FourDigitYearConverter,
TwoDigitMonthConverter,
)
from core.views import *
from core.views import (
FileDeleteView,
FileEditPropView,
FileEditView,
FileListView,
FileModerateView,
FileModerationView,
FileView,
GiftCreateView,
GiftDeleteView,
GroupCreateView,
GroupDeleteView,
GroupEditView,
GroupListView,
GroupTemplateView,
NotificationList,
PageCreateView,
PageDeleteView,
PageEditView,
PageHistView,
PageListView,
PagePropView,
PageRevView,
PageView,
SithLoginView,
SithPasswordChangeDoneView,
SithPasswordChangeView,
SithPasswordResetCompleteView,
SithPasswordResetConfirmView,
SithPasswordResetDoneView,
SithPasswordResetView,
UserAccountDetailView,
UserAccountView,
UserClubView,
UserCreationView,
UserGodfathersTreeView,
UserGodfathersView,
UserListView,
UserMiniView,
UserPicturesView,
UserPreferencesView,
UserStatsView,
UserToolsView,
UserUpdateGroupView,
UserUpdateProfileView,
UserView,
delete_user_godfather,
index,
logout,
notification,
password_root_change,
search_json,
search_user_json,
search_view,
send_file,
)
register_converter(FourDigitYearConverter, "yyyy")
register_converter(TwoDigitMonthConverter, "mm")
register_converter(BooleanStringConverter, "bool")
urlpatterns = [
path("", index, name="index"),
path("notifications/", NotificationList.as_view(), name="notification_list"),
@ -80,27 +134,17 @@ urlpatterns = [
path("group/new/", GroupCreateView.as_view(), name="group_new"),
path("group/<int:group_id>/", GroupEditView.as_view(), name="group_edit"),
path(
"group/<int:group_id>/delete/",
GroupDeleteView.as_view(),
name="group_delete",
"group/<int:group_id>/delete/", GroupDeleteView.as_view(), name="group_delete"
),
path(
"group/<int:group_id>/detail/",
GroupTemplateView.as_view(),
name="group_detail",
"group/<int:group_id>/detail/", GroupTemplateView.as_view(), name="group_detail"
),
# User views
path("user/", UserListView.as_view(), name="user_list"),
path(
"user/<int:user_id>/mini/",
UserMiniView.as_view(),
name="user_profile_mini",
),
path("user/<int:user_id>/mini/", UserMiniView.as_view(), name="user_profile_mini"),
path("user/<int:user_id>/", UserView.as_view(), name="user_profile"),
path(
"user/<int:user_id>/pictures/",
UserPicturesView.as_view(),
name="user_pictures",
"user/<int:user_id>/pictures/", UserPicturesView.as_view(), name="user_pictures"
),
path(
"user/<int:user_id>/godfathers/",
@ -117,28 +161,14 @@ urlpatterns = [
delete_user_godfather,
name="user_godfathers_delete",
),
path(
"user/<int:user_id>/edit/",
UserUpdateProfileView.as_view(),
name="user_edit",
),
path("user/<int:user_id>/edit/", UserUpdateProfileView.as_view(), name="user_edit"),
path("user/<int:user_id>/clubs/", UserClubView.as_view(), name="user_clubs"),
path("user/<int:user_id>/prefs/", UserPreferencesView.as_view(), name="user_prefs"),
path(
"user/<int:user_id>/prefs/",
UserPreferencesView.as_view(),
name="user_prefs",
),
path(
"user/<int:user_id>/groups/",
UserUpdateGroupView.as_view(),
name="user_groups",
"user/<int:user_id>/groups/", UserUpdateGroupView.as_view(), name="user_groups"
),
path("user/tools/", UserToolsView.as_view(), name="user_tools"),
path(
"user/<int:user_id>/account/",
UserAccountView.as_view(),
name="user_account",
),
path("user/<int:user_id>/account/", UserAccountView.as_view(), name="user_account"),
path(
"user/<int:user_id>/account/<yyyy:year>/<mm:month>/",
UserAccountDetailView.as_view(),
@ -179,42 +209,18 @@ urlpatterns = [
),
path("file/moderation/", FileModerationView.as_view(), name="file_moderation"),
path(
"file/<int:file_id>/moderate/",
FileModerateView.as_view(),
name="file_moderate",
"file/<int:file_id>/moderate/", FileModerateView.as_view(), name="file_moderate"
),
path("file/<int:file_id>/download/", send_file, name="download"),
# Page views
path("page/", PageListView.as_view(), name="page_list"),
path("page/create/", PageCreateView.as_view(), name="page_new"),
path("page/<int:page_id>/delete/", PageDeleteView.as_view(), name="page_delete"),
path("page/<path:page_name>/edit/", PageEditView.as_view(), name="page_edit"),
path("page/<path:page_name>/prop/", PagePropView.as_view(), name="page_prop"),
path("page/<path:page_name>/hist/", PageHistView.as_view(), name="page_hist"),
path(
"page/<int:page_id>/delete/",
PageDeleteView.as_view(),
name="page_delete",
),
path(
"page/<path:page_name>/edit/",
PageEditView.as_view(),
name="page_edit",
),
path(
"page/<path:page_name>/prop/",
PagePropView.as_view(),
name="page_prop",
),
path(
"page/<path:page_name>/hist/",
PageHistView.as_view(),
name="page_hist",
),
path(
"page/<path:page_name>/rev/<int:rev>/",
PageRevView.as_view(),
name="page_rev",
),
path(
"page/<path:page_name>/",
PageView.as_view(),
name="page",
"page/<path:page_name>/rev/<int:rev>/", PageRevView.as_view(), name="page_rev"
),
path("page/<path:page_name>/", PageView.as_view(), name="page"),
]

View File

@ -127,7 +127,7 @@ def resize_image_explicit(
def exif_auto_rotate(image):
for orientation in ExifTags.TAGS.keys():
for orientation in ExifTags.TAGS:
if ExifTags.TAGS[orientation] == "Orientation":
break
exif = dict(image._getexif().items())

View File

@ -25,6 +25,7 @@
import types
from typing import Any
from django.conf import settings
from django.contrib.auth.mixins import AccessMixin
from django.core.exceptions import (
ImproperlyConfigured,
@ -35,6 +36,7 @@ from django.http import (
HttpResponseNotFound,
HttpResponseServerError,
)
from django.shortcuts import render
from django.utils.functional import cached_property
from django.views.generic.base import View
from django.views.generic.detail import SingleObjectMixin
@ -79,9 +81,7 @@ def can_edit_prop(obj: Any, user: User) -> bool:
raise PermissionDenied
```
"""
if obj is None or user.is_owner(obj):
return True
return False
return obj is None or user.is_owner(obj)
def can_edit(obj: Any, user: User) -> bool:
@ -232,7 +232,9 @@ class UserIsRootMixin(GenericContentPermissionMixinBuilder):
PermissionDenied: if the user isn't root
"""
permission_function = lambda obj, user: user.is_root
@staticmethod
def permission_function(obj: Any, user: User):
return user.is_root
class FormerSubscriberMixin(AccessMixin):
@ -304,10 +306,10 @@ class QuickNotifMixin:
kwargs["quick_notifs"] = []
for n in self.quick_notif_list:
kwargs["quick_notifs"].append(settings.SITH_QUICK_NOTIF[n])
for k, v in settings.SITH_QUICK_NOTIF.items():
for gk in self.request.GET.keys():
if k == gk:
kwargs["quick_notifs"].append(v)
for key, val in settings.SITH_QUICK_NOTIF.items():
for gk in self.request.GET:
if key == gk:
kwargs["quick_notifs"].append(val)
return kwargs
@ -324,8 +326,10 @@ class DetailFormView(SingleObjectMixin, FormView):
return super().get_object()
from .files import *
from .group import *
from .page import *
from .site import *
from .user import *
# F403: those star-imports would be hellish to refactor
# E402: putting those import at the top of the file would also be difficult
from .files import * # noqa: F403 E402
from .group import * # noqa: F403 E402
from .page import * # noqa: F403 E402
from .site import * # noqa: F403 E402
from .user import * # noqa: F403 E402

View File

@ -193,7 +193,7 @@ class FileEditView(CanEditMixin, UpdateView):
def get_form_class(self):
fields = ["name", "is_moderated"]
if self.object.is_file:
fields = ["file"] + fields
fields = ["file", *fields]
return modelform_factory(SithFile, fields=fields)
def get_success_url(self):
@ -283,38 +283,38 @@ class FileView(CanViewMixin, DetailView, FormMixin):
`obj` is the SithFile object you want to put in the clipboard, or
where you want to paste the clipboard
"""
if "delete" in request.POST.keys():
if "delete" in request.POST:
for f_id in request.POST.getlist("file_list"):
sf = SithFile.objects.filter(id=f_id).first()
if sf:
sf.delete()
if "clear" in request.POST.keys():
file = SithFile.objects.filter(id=f_id).first()
if file:
file.delete()
if "clear" in request.POST:
request.session["clipboard"] = []
if "cut" in request.POST.keys():
for f_id in request.POST.getlist("file_list"):
f_id = int(f_id)
if "cut" in request.POST:
for f_id_str in request.POST.getlist("file_list"):
f_id = int(f_id_str)
if (
f_id in [c.id for c in obj.children.all()]
and f_id not in request.session["clipboard"]
):
request.session["clipboard"].append(f_id)
if "paste" in request.POST.keys():
if "paste" in request.POST:
for f_id in request.session["clipboard"]:
sf = SithFile.objects.filter(id=f_id).first()
if sf:
sf.move_to(obj)
file = SithFile.objects.filter(id=f_id).first()
if file:
file.move_to(obj)
request.session["clipboard"] = []
request.session.modified = True
def get(self, request, *args, **kwargs):
self.form = self.get_form()
if "clipboard" not in request.session.keys():
if "clipboard" not in request.session:
request.session["clipboard"] = []
return super().get(request, *args, **kwargs)
def post(self, request, *args, **kwargs):
self.object = self.get_object()
if "clipboard" not in request.session.keys():
if "clipboard" not in request.session:
request.session["clipboard"] = []
if request.user.can_edit(self.object):
# XXX this call can fail!
@ -398,6 +398,6 @@ class FileModerateView(CanEditPropMixin, SingleObjectMixin):
self.object.is_moderated = True
self.object.moderator = request.user
self.object.save()
if "next" in self.request.GET.keys():
if "next" in self.request.GET:
return redirect(self.request.GET["next"])
return redirect("core:file_moderation")

View File

@ -29,6 +29,7 @@ from captcha.fields import CaptchaField
from django import forms
from django.conf import settings
from django.contrib.auth.forms import AuthenticationForm, UserCreationForm
from django.contrib.staticfiles.storage import staticfiles_storage
from django.core.exceptions import ValidationError
from django.db import transaction
from django.forms import (
@ -38,7 +39,6 @@ from django.forms import (
Textarea,
TextInput,
)
from django.templatetags.static import static
from django.utils.translation import gettext
from django.utils.translation import gettext_lazy as _
from phonenumber_field.widgets import RegionalPhoneNumberWidget
@ -72,8 +72,8 @@ class MarkdownInput(Textarea):
context = super().get_context(name, value, attrs)
context["statics"] = {
"js": static("webpack/easymde-index.js"),
"css": static("webpack/easymde-index.css"),
"js": staticfiles_storage.url("webpack/easymde-index.ts"),
"css": staticfiles_storage.url("webpack/easymde-index.css"),
}
return context
@ -140,7 +140,7 @@ class SelectUser(TextInput):
class LoginForm(AuthenticationForm):
def __init__(self, *arg, **kwargs):
if "data" in kwargs.keys():
if "data" in kwargs:
from counter.models import Customer
data = kwargs["data"].copy()
@ -157,7 +157,7 @@ class LoginForm(AuthenticationForm):
else:
user = User.objects.filter(username=data["username"]).first()
data["username"] = user.username
except:
except: # noqa E722 I don't know what error is supposed to be raised here
pass
kwargs["data"] = data
super().__init__(*arg, **kwargs)

View File

@ -55,7 +55,7 @@ class PageView(CanViewMixin, DetailView):
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
if "page" not in context.keys():
if "page" not in context:
context["new_page"] = self.kwargs["page_name"]
return context
@ -92,22 +92,16 @@ class PageRevView(CanViewMixin, DetailView):
)
return res
def get_object(self):
def get_object(self, *args, **kwargs):
self.page = Page.get_page_by_full_name(self.kwargs["page_name"])
return self.page
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
if self.page is not None:
context["page"] = self.page
try:
rev = self.page.revisions.get(id=self.kwargs["rev"])
context["rev"] = rev
except:
# By passing, the template will just display the normal page without taking revision into account
pass
else:
context["new_page"] = self.kwargs["page_name"]
if not self.page:
return context | {"new_page": self.kwargs["page_name"]}
context["page"] = self.page
context["rev"] = self.page.revisions.filter(id=self.kwargs["rev"]).first()
return context
@ -118,7 +112,7 @@ class PageCreateView(CanCreateMixin, CreateView):
def get_initial(self):
init = {}
if "page" in self.request.GET.keys():
if "page" in self.request.GET:
page_name = self.request.GET["page"]
parent_name = "/".join(page_name.split("/")[:-1])
parent = Page.get_page_by_full_name(parent_name)
@ -145,18 +139,8 @@ class PagePropView(CanEditPagePropMixin, UpdateView):
slug_field = "_full_name"
slug_url_kwarg = "page_name"
def get_object(self):
o = super().get_object()
# Create the page if it does not exists
# if p == None:
# parent_name = '/'.join(page_name.split('/')[:-1])
# name = page_name.split('/')[-1]
# if parent_name == "":
# p = Page(name=name)
# else:
# parent = Page.get_page_by_full_name(parent_name)
# p = Page(name=name, parent=parent)
self.page = o
def get_object(self, queryset=None):
self.page = super().get_object()
try:
self.page.set_lock_recursive(self.request.user)
except LockError as e:

View File

@ -53,11 +53,8 @@ class NotificationList(ListView):
if self.request.user.is_anonymous:
return Notification.objects.none()
# TODO: Bulk update in django 2.2
if "see_all" in self.request.GET.keys():
for n in self.request.user.notifications.filter(viewed=False):
n.viewed = True
n.save()
if "see_all" in self.request.GET:
self.request.user.notifications.filter(viewed=False).update(viewed=True)
return self.request.user.notifications.order_by("-date")[:20]

View File

@ -255,8 +255,10 @@ class UserTabsMixin(TabedViewMixin):
"name": _("Groups"),
}
)
try:
if user.customer and (
if (
hasattr(user, "customer")
and user.customer
and (
user == self.request.user
or self.request.user.is_in_group(
pk=settings.SITH_GROUP_ACCOUNTING_ADMIN_ID
@ -266,25 +268,22 @@ class UserTabsMixin(TabedViewMixin):
+ settings.SITH_BOARD_SUFFIX
)
or self.request.user.is_root
):
tab_list.append(
{
"url": reverse("core:user_stats", kwargs={"user_id": user.id}),
"slug": "stats",
"name": _("Stats"),
}
)
tab_list.append(
{
"url": reverse(
"core:user_account", kwargs={"user_id": user.id}
),
"slug": "account",
"name": _("Account") + " (%s €)" % user.customer.amount,
}
)
except:
pass
)
):
tab_list.append(
{
"url": reverse("core:user_stats", kwargs={"user_id": user.id}),
"slug": "stats",
"name": _("Stats"),
}
)
tab_list.append(
{
"url": reverse("core:user_account", kwargs={"user_id": user.id}),
"slug": "account",
"name": _("Account") + " (%s €)" % user.customer.amount,
}
)
return tab_list

View File

@ -15,7 +15,19 @@
from django.contrib import admin
from haystack.admin import SearchModelAdmin
from counter.models import *
from counter.models import (
AccountDump,
BillingInfo,
CashRegisterSummary,
Counter,
Customer,
Eticket,
Permanency,
Product,
ProductType,
Refilling,
Selling,
)
@admin.register(Product)

View File

@ -1,4 +1,5 @@
import logging
import time
from smtplib import SMTPException
from django.conf import settings
@ -25,9 +26,34 @@ class Command(BaseCommand):
self.logger.setLevel(logging.INFO)
super().__init__(*args, **kwargs)
def add_arguments(self, parser):
parser.add_argument(
"--dry-run",
action="store_true",
help="Do not send the mails, just display the number of users concerned",
)
parser.add_argument(
"-d",
"--delay",
type=float,
default=0,
help="Delay in seconds between each mail sent",
)
def handle(self, *args, **options):
users = list(self._get_users())
users: list[User] = list(self._get_users())
self.stdout.write(f"{len(users)} users will be warned of their account dump")
if options["verbosity"] > 1:
self.stdout.write("Users concerned:\n")
self.stdout.write(
"\n".join(
f" - {user.get_display_name()} ({user.email}) : "
f"{user.customer.amount}"
for user in users
)
)
if options["dry_run"]:
return
dumps = []
for user in users:
is_success = self._send_mail(user)
@ -38,6 +64,10 @@ class Command(BaseCommand):
warning_mail_error=not is_success,
)
)
if options["delay"]:
# avoid spamming the mail server too much
time.sleep(options["delay"])
AccountDump.objects.bulk_create(dumps)
self.stdout.write("Finished !")

View File

@ -154,7 +154,7 @@ class Customer(models.Model):
self.save()
def get_full_url(self):
return "".join(["https://", settings.SITH_URL, self.get_absolute_url()])
return f"https://{settings.SITH_URL}{self.get_absolute_url()}"
class BillingInfo(models.Model):
@ -287,9 +287,7 @@ class ProductType(models.Model):
"""Method to see if that object can be edited by the given user."""
if user.is_anonymous:
return False
if user.is_in_group(pk=settings.SITH_GROUP_ACCOUNTING_ADMIN_ID):
return True
return False
return user.is_in_group(pk=settings.SITH_GROUP_ACCOUNTING_ADMIN_ID)
class Product(models.Model):
@ -346,21 +344,19 @@ class Product(models.Model):
@property
def is_record_product(self):
return settings.SITH_ECOCUP_CONS == self.id
return self.id == settings.SITH_ECOCUP_CONS
@property
def is_unrecord_product(self):
return settings.SITH_ECOCUP_DECO == self.id
return self.id == settings.SITH_ECOCUP_DECO
def is_owned_by(self, user):
"""Method to see if that object can be edited by the given user."""
if user.is_anonymous:
return False
if user.is_in_group(
return user.is_in_group(
pk=settings.SITH_GROUP_ACCOUNTING_ADMIN_ID
) or user.is_in_group(pk=settings.SITH_GROUP_COUNTER_ADMIN_ID):
return True
return False
) 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
@ -392,10 +388,7 @@ class Product(models.Model):
buying_groups = list(self.buying_groups.all())
if not buying_groups:
return True
for group in buying_groups:
if user.is_in_group(pk=group.id):
return True
return False
return any(user.is_in_group(pk=group.id) for group in buying_groups)
@property
def profit(self):
@ -887,27 +880,19 @@ class Selling(models.Model):
"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."
) % {
"event": event,
"url": "".join(
(
'<a href="',
self.customer.get_full_url(),
'">',
self.customer.get_full_url(),
"</a>",
)
"url": (
f'<a href="{self.customer.get_full_url()}">'
f"{self.customer.get_full_url()}</a>"
),
"eticket": "".join(
(
'<a href="',
self.get_eticket_full_url(),
'">',
self.get_eticket_full_url(),
"</a>",
)
"eticket": (
f'<a href="{self.get_eticket_full_url()}">'
f"{self.get_eticket_full_url()}</a>"
),
}
message_txt = _(
"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,
"url": self.customer.get_full_url(),
@ -919,7 +904,7 @@ class Selling(models.Model):
def get_eticket_full_url(self):
eticket_url = reverse("counter:eticket_pdf", kwargs={"selling_id": self.id})
return "".join(["https://", settings.SITH_URL, eticket_url])
return f"https://{settings.SITH_URL}{eticket_url}"
class Permanency(models.Model):
@ -1019,15 +1004,15 @@ class CashRegisterSummary(models.Model):
elif name == "hundred_euros":
return self.items.filter(value=100, is_check=False).first()
elif name == "check_1":
return checks[0] if 0 < len(checks) else None
return checks[0] if len(checks) > 0 else None
elif name == "check_2":
return checks[1] if 1 < len(checks) else None
return checks[1] if len(checks) > 1 else None
elif name == "check_3":
return checks[2] if 2 < len(checks) else None
return checks[2] if len(checks) > 2 else None
elif name == "check_4":
return checks[3] if 3 < len(checks) else None
return checks[3] if len(checks) > 3 else None
elif name == "check_5":
return checks[4] if 4 < len(checks) else None
return checks[4] if len(checks) > 4 else None
else:
return object.__getattribute__(self, name)
@ -1035,9 +1020,7 @@ class CashRegisterSummary(models.Model):
"""Method to see if that object can be edited by the given user."""
if user.is_anonymous:
return False
if user.is_in_group(pk=settings.SITH_GROUP_COUNTER_ADMIN_ID):
return True
return False
return user.is_in_group(pk=settings.SITH_GROUP_COUNTER_ADMIN_ID)
def get_total(self):
t = 0

View File

@ -51,7 +51,7 @@ def write_log(instance, operation_type):
# Return None by default
return None
log = OperationLog(
OperationLog(
label=str(instance),
operator=get_user(),
operation_type=operation_type,

View File

@ -1,43 +1,40 @@
<p>
Bonjour,
</p>
{% trans %}Hello{% endtrans %},
<p>
{%- trans date=last_subscription_date|date(DATETIME_FORMAT) -%}
You received this email because your last subscription to the
Students' association ended on {{ date }}.
{%- endtrans -%}
</p>
{% trans trimmed date=last_subscription_date|date(DATETIME_FORMAT) -%}
You received this email because your last subscription to the
Students' association ended on {{ date }}.
{%- endtrans %}
<p>
{%- trans date=dump_date|date(DATETIME_FORMAT), amount=balance -%}
In accordance with the Internal Regulations, the balance of any
inactive AE account for more than 2 years automatically goes back
to the AE.
The money present on your account will therefore be recovered in full
on {{ date }}, for a total of {{ amount }} €.
{%- endtrans -%}
</p>
{% trans trimmed date=dump_date|date(DATETIME_FORMAT), amount=balance -%}
In accordance with the Internal Regulations, the balance of any
inactive AE account for more than 2 years automatically goes back
to the AE.
The money present on your account will therefore be recovered in full
on {{ date }}, for a total of {{ amount }} €.
{%- endtrans %}
<p>
{%- trans -%}However, if your subscription is renewed by this date,
your right to keep the money in your AE account will be renewed.{%- endtrans -%}
</p>
{% trans trimmed -%}
However, if your subscription is renewed by this date,
your right to keep the money in your AE account will be renewed.
{%- endtrans %}
{% if balance >= 10 %}
<p>
{%- trans -%}You can also request a refund by sending an email to
<a href="mailto:ae@utbm.fr">ae@utbm.fr</a>
before the aforementioned date.{%- endtrans -%}
</p>
{% endif %}
{% if balance >= 10 -%}
{% trans trimmed -%}
You can also request a refund by sending an email to ae@utbm.fr
before the aforementioned date.
{%- endtrans %}
{%- endif %}
<p>
{% trans %}Sincerely{% endtrans %},
</p>
{% trans trimmed -%}
Whatever you decide, you won't be expelled from the association,
and you won't lose your rights.
You will always be able to renew your subscription later.
If you don't renew your subscription, there will be no consequences
other than the loss of the money currently in your AE account.
{%- endtrans %}
<p>
L'association des étudiants de l'UTBM <br>
6, Boulevard Anatole France <br>
90000 Belfort
</p>
{% trans %}Sincerely{% endtrans %},
L'association des étudiants de l'UTBM
6, Boulevard Anatole France
90000 Belfort

View File

@ -13,7 +13,7 @@
<h4>{{ product_type or _("Uncategorized") }}</h4>
<ul>
{%- for product in products -%}
<li><a href="{{ url('counter:product_edit', product_id=product.id) }}">{{ product }} ({{ product.code }})</a></li>
<li><a href="{{ url('counter:product_edit', product_id=product.id) }}">{{ product.name }} ({{ product.code }})</a></li>
{%- endfor -%}
</ul>
{%- else -%}

View File

@ -503,7 +503,7 @@ class TestBarmanConnection(TestCase):
)
response = self.client.get(reverse("counter:activity", args=[self.counter.id]))
assert not '<li><a href="/user/1/">S&#39; Kia</a></li>' in str(response.content)
assert '<li><a href="/user/1/">S&#39; Kia</a></li>' not in str(response.content)
@pytest.mark.django_db
@ -853,7 +853,7 @@ class TestCustomerAccountId(TestCase):
number = account_id[:-1]
assert created is True
assert number == "12346"
assert 6 == len(account_id)
assert len(account_id) == 6
assert account_id[-1] in string.ascii_lowercase
assert customer.amount == 0

View File

@ -15,7 +15,40 @@
from django.urls import path
from counter.views import *
from counter.views import (
ActiveProductListView,
ArchivedProductListView,
CashSummaryEditView,
CashSummaryListView,
CounterActivityView,
CounterCashSummaryView,
CounterClick,
CounterCreateView,
CounterDeleteView,
CounterEditPropView,
CounterEditView,
CounterLastOperationsView,
CounterListView,
CounterMain,
CounterRefillingListView,
CounterStatView,
EticketCreateView,
EticketEditView,
EticketListView,
EticketPDFView,
InvoiceCallView,
ProductCreateView,
ProductEditView,
ProductTypeCreateView,
ProductTypeEditView,
ProductTypeListView,
RefillingDeleteView,
SellingDeleteView,
StudentCardDeleteView,
StudentCardFormView,
counter_login,
counter_logout,
)
urlpatterns = [
path("<int:counter_id>/", CounterMain.as_view(), name="details"),

View File

@ -17,7 +17,7 @@ import re
from datetime import datetime, timedelta
from datetime import timezone as tz
from http import HTTPStatus
from operator import attrgetter
from operator import itemgetter
from typing import TYPE_CHECKING
from urllib.parse import parse_qs
@ -91,16 +91,10 @@ class CounterAdminMixin(View):
edit_club = []
def _test_group(self, user):
for grp_id in self.edit_group:
if user.is_in_group(pk=grp_id):
return True
return False
return any(user.is_in_group(pk=grp_id) for grp_id in self.edit_group)
def _test_club(self, user):
for c in self.edit_club:
if c.can_be_edited_by(user):
return True
return False
return any(c.can_be_edited_by(user) for c in self.edit_club)
def dispatch(self, request, *args, **kwargs):
if not (
@ -181,7 +175,7 @@ class CounterMain(
def post(self, request, *args, **kwargs):
self.object = self.get_object()
if self.object.type == "BAR" and not (
"counter_token" in self.request.session.keys()
"counter_token" in self.request.session
and self.request.session["counter_token"] == self.object.token
): # Check the token to avoid the bar to be stolen
return HttpResponseRedirect(
@ -219,7 +213,7 @@ class CounterMain(
kwargs["barmen"] = self.object.barmen_list
elif self.request.user.is_authenticated:
kwargs["barmen"] = [self.request.user]
if "last_basket" in self.request.session.keys():
if "last_basket" in self.request.session:
kwargs["last_basket"] = self.request.session.pop("last_basket")
kwargs["last_customer"] = self.request.session.pop("last_customer")
kwargs["last_total"] = self.request.session.pop("last_total")
@ -294,7 +288,7 @@ class CounterClick(CounterTabsMixin, CanViewMixin, DetailView):
def get(self, request, *args, **kwargs):
"""Simple get view."""
if "basket" not in request.session.keys(): # Init the basket session entry
if "basket" not in request.session: # Init the basket session entry
request.session["basket"] = {}
request.session["basket_total"] = 0
request.session["not_enough"] = False # Reset every variable
@ -318,7 +312,7 @@ class CounterClick(CounterTabsMixin, CanViewMixin, DetailView):
): # Check that at least one barman is logged in
return self.cancel(request)
if self.object.type == "BAR" and not (
"counter_token" in self.request.session.keys()
"counter_token" in self.request.session
and self.request.session["counter_token"] == self.object.token
): # Also check the token to avoid the bar to be stolen
return HttpResponseRedirect(
@ -329,7 +323,7 @@ class CounterClick(CounterTabsMixin, CanViewMixin, DetailView):
)
+ "?bad_location"
)
if "basket" not in request.session.keys():
if "basket" not in request.session:
request.session["basket"] = {}
request.session["basket_total"] = 0
request.session["not_enough"] = False # Reset every variable
@ -386,13 +380,12 @@ class CounterClick(CounterTabsMixin, CanViewMixin, DetailView):
def get_total_quantity_for_pid(self, request, pid):
pid = str(pid)
try:
return (
request.session["basket"][pid]["qty"]
+ request.session["basket"][pid]["bonus_qty"]
)
except:
if pid not in request.session["basket"]:
return 0
return (
request.session["basket"][pid]["qty"]
+ request.session["basket"][pid]["bonus_qty"]
)
def compute_record_product(self, request, product=None):
recorded = 0
@ -808,7 +801,7 @@ class ProductTypeEditView(CounterAdminTabsMixin, CounterAdminMixin, UpdateView):
class ProductListView(CounterAdminTabsMixin, CounterAdminMixin, ListView):
model = Product
queryset = Product.objects.annotate(type_name=F("product_type__name"))
queryset = Product.objects.values("id", "name", "code", "product_type__name")
template_name = "counter/product_list.jinja"
ordering = [
F("product_type__priority").desc(nulls_last=True),
@ -819,7 +812,7 @@ class ProductListView(CounterAdminTabsMixin, CounterAdminMixin, ListView):
def get_context_data(self, **kwargs):
res = super().get_context_data(**kwargs)
res["object_list"] = itertools.groupby(
res["object_list"], key=attrgetter("type_name")
res["object_list"], key=itemgetter("product_type__name")
)
return res

View File

@ -24,6 +24,12 @@ Si le mot apparaît dans le template Jinja :
{% trans %}Hello{% endtrans %}
```
Si on est dans un fichier javascript ou typescript :
```js
gettext("Hello");
```
## Générer le fichier django.po
La traduction se fait en trois étapes.
@ -32,7 +38,7 @@ l'éditer et enfin le compiler au format binaire pour qu'il soit lu par le serve
```bash
./manage.py makemessages --locale=fr -e py,jinja --ignore=node_modules # Pour le backend
./manage.py makemessages --locale=fr -d djangojs --ignore=node_modules # Pour le frontend
./manage.py makemessages --locale=fr -d djangojs -e js,ts --ignore=node_modules # Pour le frontend
```
## Éditer le fichier django.po

View File

@ -13,8 +13,9 @@
#
#
from django.contrib import admin
from django.db.models import F, Sum
from eboutic.models import *
from eboutic.models import Basket, BasketItem, Invoice, InvoiceItem
@admin.register(Basket)

View File

@ -117,9 +117,7 @@ class BasketForm:
"""
if not self.error_messages and not self.correct_items:
self.clean()
if self.error_messages:
return False
return True
return not self.error_messages
@cached_property
def errors(self) -> list[str]:

View File

@ -2,8 +2,6 @@ from typing import Annotated
from ninja import ModelSchema, Schema
from pydantic import Field, NonNegativeInt, PositiveInt, TypeAdapter
# from phonenumber_field.phonenumber import PhoneNumber
from pydantic_extra_types.phone_numbers import PhoneNumber, PhoneNumberValidator
from counter.models import BillingInfo

View File

@ -7,11 +7,11 @@
import base64
from pathlib import Path
from typing import TYPE_CHECKING
import pytest
from cryptography.exceptions import InvalidSignature
from cryptography.hazmat.primitives.asymmetric.padding import PKCS1v15
from cryptography.hazmat.primitives.asymmetric.rsa import RSAPrivateKey, RSAPublicKey
from cryptography.hazmat.primitives.hashes import SHA1
from cryptography.hazmat.primitives.serialization import (
load_pem_private_key,
@ -19,6 +19,12 @@ from cryptography.hazmat.primitives.serialization import (
)
from django.conf import settings
if TYPE_CHECKING:
from cryptography.hazmat.primitives.asymmetric.rsa import (
RSAPrivateKey,
RSAPublicKey,
)
def test_signature_valid():
"""Test that data sent to the bank is correctly signed."""

View File

@ -24,9 +24,9 @@
import base64
import json
import urllib
from typing import TYPE_CHECKING
from cryptography.hazmat.primitives.asymmetric.padding import PKCS1v15
from cryptography.hazmat.primitives.asymmetric.rsa import RSAPrivateKey
from cryptography.hazmat.primitives.hashes import SHA1
from cryptography.hazmat.primitives.serialization import load_pem_private_key
from django.conf import settings
@ -38,6 +38,9 @@ from core.models import User
from counter.models import Counter, Customer, Product, Selling
from eboutic.models import Basket, BasketItem
if TYPE_CHECKING:
from cryptography.hazmat.primitives.asymmetric.rsa import RSAPrivateKey
class TestEboutic(TestCase):
@classmethod

View File

@ -25,7 +25,14 @@
from django.urls import path, register_converter
from eboutic.converters import PaymentResultConverter
from eboutic.views import *
from eboutic.views import (
EbouticCommand,
EtransactionAutoAnswer,
e_transaction_data,
eboutic_main,
pay_with_sith,
payment_result,
)
register_converter(PaymentResultConverter, "res")

View File

@ -17,11 +17,11 @@ import base64
import json
from datetime import datetime
from enum import Enum
from typing import TYPE_CHECKING
import sentry_sdk
from cryptography.exceptions import InvalidSignature
from cryptography.hazmat.primitives.asymmetric.padding import PKCS1v15
from cryptography.hazmat.primitives.asymmetric.rsa import RSAPublicKey
from cryptography.hazmat.primitives.hashes import SHA1
from cryptography.hazmat.primitives.serialization import load_pem_public_key
from django.conf import settings
@ -47,6 +47,9 @@ from eboutic.models import (
)
from eboutic.schemas import PurchaseItemList, PurchaseItemSchema
if TYPE_CHECKING:
from cryptography.hazmat.primitives.asymmetric.rsa import RSAPublicKey
@login_required
@require_GET
@ -221,7 +224,7 @@ class EtransactionAutoAnswer(View):
# Payment authorized:
# * 'Error' is '00000'
# * 'Auto' is in the request
if request.GET["Error"] == "00000" and "Auto" in request.GET.keys():
if request.GET["Error"] == "00000" and "Auto" in request.GET:
try:
with transaction.atomic():
b = (

View File

@ -1,6 +1,22 @@
from django.urls import path
from election.views import *
from election.views import (
CandidatureCreateView,
CandidatureDeleteView,
CandidatureUpdateView,
ElectionCreateView,
ElectionDeleteView,
ElectionDetailView,
ElectionListArchivedView,
ElectionListCreateView,
ElectionListDeleteView,
ElectionsListView,
ElectionUpdateView,
RoleCreateView,
RoleDeleteView,
RoleUpdateView,
VoteFormView,
)
urlpatterns = [
path("", ElectionsListView.as_view(), name="list"),
@ -19,16 +35,10 @@ urlpatterns = [
name="delete_list",
),
path(
"<int:election_id>/role/create/",
RoleCreateView.as_view(),
name="create_role",
"<int:election_id>/role/create/", RoleCreateView.as_view(), name="create_role"
),
path("<int:role_id>/role/edit/", RoleUpdateView.as_view(), name="update_role"),
path(
"<int:role_id>/role/delete/",
RoleDeleteView.as_view(),
name="delete_role",
),
path("<int:role_id>/role/delete/", RoleDeleteView.as_view(), name="delete_role"),
path(
"<int:election_id>/candidate/add/",
CandidatureCreateView.as_view(),

View File

@ -1,3 +1,5 @@
from typing import TYPE_CHECKING
from ajax_select import make_ajax_field
from ajax_select.fields import AutoCompleteSelectField
from django import forms
@ -10,11 +12,14 @@ from django.utils.translation import gettext_lazy as _
from django.views.generic import DetailView, ListView
from django.views.generic.edit import CreateView, DeleteView, FormView, UpdateView
from core.models import User
from core.views import CanCreateMixin, CanEditMixin, CanViewMixin
from core.views.forms import MarkdownInput, SelectDateTime
from election.models import Candidature, Election, ElectionList, Role, Vote
if TYPE_CHECKING:
from core.models import User
# Custom form field
@ -23,7 +28,6 @@ class LimitedCheckboxField(forms.ModelMultipleChoiceField):
def __init__(self, queryset, max_choice, **kwargs):
self.max_choice = max_choice
widget = forms.CheckboxSelectMultiple()
super().__init__(queryset, **kwargs)
def clean(self, value):
@ -251,7 +255,7 @@ class VoteFormView(CanCreateMixin, FormView):
def vote(self, election_data):
with transaction.atomic():
for role_title in election_data.keys():
for role_title in election_data:
# If we have a multiple choice field
if isinstance(election_data[role_title], QuerySet):
if election_data[role_title].count() > 0:
@ -444,28 +448,16 @@ class ElectionUpdateView(CanEditMixin, UpdateView):
pk_url_kwarg = "election_id"
def get_initial(self):
init = {}
try:
init["start_date"] = self.object.start_date.strftime("%Y-%m-%d %H:%M:%S")
except Exception:
pass
try:
init["end_date"] = self.object.end_date.strftime("%Y-%m-%d %H:%M:%S")
except Exception:
pass
try:
init["start_candidature"] = self.object.start_candidature.strftime(
return {
"start_date": self.object.start_date.strftime("%Y-%m-%d %H:%M:%S"),
"end_date": self.object.end_date.strftime("%Y-%m-%d %H:%M:%S"),
"start_candidature": self.object.start_candidature.strftime(
"%Y-%m-%d %H:%M:%S"
)
except Exception:
pass
try:
init["end_candidature"] = self.object.end_candidature.strftime(
),
"end_candidature": self.object.end_candidature.strftime(
"%Y-%m-%d %H:%M:%S"
)
except Exception:
pass
return init
),
}
def get_success_url(self, **kwargs):
return reverse_lazy("election:detail", kwargs={"election_id": self.object.id})

View File

@ -16,7 +16,7 @@
from django.contrib import admin
from haystack.admin import SearchModelAdmin
from forum.models import *
from forum.models import Forum, ForumMessage, ForumTopic
@admin.register(Forum)

View File

@ -25,6 +25,7 @@ from __future__ import annotations
from datetime import datetime
from datetime import timezone as tz
from itertools import chain
from typing import Self
from django.conf import settings
from django.core.exceptions import ValidationError
@ -207,12 +208,12 @@ class Forum(models.Model):
return self.get_parent_list()
def get_parent_list(self):
l = []
p = self.parent
while p is not None:
l.append(p)
p = p.parent
return l
parents = []
current = self.parent
while current is not None:
parents.append(current)
current = current.parent
return parents
@property
def topic_number(self):
@ -228,12 +229,12 @@ class Forum(models.Model):
def last_message(self):
return self._last_message
def get_children_list(self):
l = [self.id]
def get_children_list(self) -> list[Self]:
children = [self.id]
for c in self.children.all():
l.append(c.id)
l += c.get_children_list()
return l
children.append(c.id)
children.extend(c.get_children_list())
return children
class ForumTopic(models.Model):

View File

@ -23,7 +23,26 @@
from django.urls import path
from forum.views import *
from forum.views import (
ForumCreateView,
ForumDeleteView,
ForumDetailView,
ForumEditView,
ForumFavoriteTopics,
ForumLastUnread,
ForumMainView,
ForumMarkAllAsRead,
ForumMessageCreateView,
ForumMessageDeleteView,
ForumMessageEditView,
ForumMessageUndeleteView,
ForumMessageView,
ForumSearchView,
ForumTopicCreateView,
ForumTopicDetailView,
ForumTopicEditView,
ForumTopicSubscribeView,
)
urlpatterns = [
path("", ForumMainView.as_view(), name="main"),
@ -35,21 +54,9 @@ urlpatterns = [
path("<int:forum_id>/", ForumDetailView.as_view(), name="view_forum"),
path("<int:forum_id>/edit/", ForumEditView.as_view(), name="edit_forum"),
path("<int:forum_id>/delete/", ForumDeleteView.as_view(), name="delete_forum"),
path(
"<int:forum_id>/new_topic/",
ForumTopicCreateView.as_view(),
name="new_topic",
),
path(
"topic/<int:topic_id>/",
ForumTopicDetailView.as_view(),
name="view_topic",
),
path(
"topic/<int:topic_id>/edit/",
ForumTopicEditView.as_view(),
name="edit_topic",
),
path("<int:forum_id>/new_topic/", ForumTopicCreateView.as_view(), name="new_topic"),
path("topic/<int:topic_id>/", ForumTopicDetailView.as_view(), name="view_topic"),
path("topic/<int:topic_id>/edit/", ForumTopicEditView.as_view(), name="edit_topic"),
path(
"topic/<int:topic_id>/new_message/",
ForumMessageCreateView.as_view(),
@ -60,11 +67,7 @@ urlpatterns = [
ForumTopicSubscribeView.as_view(),
name="toggle_subscribe_topic",
),
path(
"message/<int:message_id>/",
ForumMessageView.as_view(),
name="view_message",
),
path("message/<int:message_id>/", ForumMessageView.as_view(), name="view_message"),
path(
"message/<int:message_id>/edit/",
ForumMessageEditView.as_view(),

View File

@ -71,7 +71,7 @@ class Command(BaseCommand):
def handle(self, *args, **options):
self.logger = logging.getLogger("main")
if options["verbosity"] < 0 or 2 < options["verbosity"]:
if not 0 <= options["verbosity"] <= 2:
warnings.warn(
"verbosity level should be between 0 and 2 included", stacklevel=2
)

View File

@ -40,7 +40,7 @@ class Command(BaseCommand):
def handle(self, *args, **options):
logger = logging.getLogger("main")
if options["verbosity"] < 0 or 2 < options["verbosity"]:
if not 0 <= options["verbosity"] <= 2:
warnings.warn(
"verbosity level should be between 0 and 2 included", stacklevel=2
)

View File

@ -23,17 +23,9 @@
from django.urls import path
from galaxy.views import *
from galaxy.views import GalaxyDataView, GalaxyUserView
urlpatterns = [
path(
"<int:user_id>/",
GalaxyUserView.as_view(),
name="user",
),
path(
"data.json",
GalaxyDataView.as_view(),
name="data",
),
path("<int:user_id>/", GalaxyUserView.as_view(), name="user"),
path("data.json", GalaxyDataView.as_view(), name="data"),
]

View File

@ -14,7 +14,7 @@
#
from django.contrib import admin
from launderette.models import *
from launderette.models import Launderette, Machine, Slot, Token
@admin.register(Launderette)

View File

@ -51,18 +51,14 @@ class Launderette(models.Model):
unix_name=settings.SITH_LAUNDERETTE_MANAGER["unix_name"]
).first()
m = launderette_club.get_membership_for(user)
if m and m.role >= 9:
return True
return False
return bool(m and m.role >= 9)
def can_be_edited_by(self, user):
launderette_club = Club.objects.filter(
unix_name=settings.SITH_LAUNDERETTE_MANAGER["unix_name"]
).first()
m = launderette_club.get_membership_for(user)
if m and m.role >= 2:
return True
return False
return bool(m and m.role >= 2)
def can_be_viewed_by(self, user):
return user.is_subscribed
@ -113,9 +109,7 @@ class Machine(models.Model):
unix_name=settings.SITH_LAUNDERETTE_MANAGER["unix_name"]
).first()
m = launderette_club.get_membership_for(user)
if m and m.role >= 9:
return True
return False
return bool(m and m.role >= 9)
class Token(models.Model):
@ -164,15 +158,7 @@ class Token(models.Model):
unix_name=settings.SITH_LAUNDERETTE_MANAGER["unix_name"]
).first()
m = launderette_club.get_membership_for(user)
if m and m.role >= 9:
return True
return False
def is_avaliable(self):
if not self.borrow_date and not self.user:
return True
else:
return False
return bool(m and m.role >= 9)
class Slot(models.Model):

View File

@ -15,22 +15,28 @@
from django.urls import path
from launderette.views import *
from launderette.views import (
LaunderetteAdminView,
LaunderetteBookMainView,
LaunderetteBookView,
LaunderetteClickView,
LaunderetteCreateView,
LaunderetteEditView,
LaunderetteListView,
LaunderetteMainClickView,
LaunderetteMainView,
MachineCreateView,
MachineDeleteView,
MachineEditView,
SlotDeleteView,
)
urlpatterns = [
# views
path("", LaunderetteMainView.as_view(), name="launderette_main"),
path(
"slot/<int:slot_id>/delete/",
SlotDeleteView.as_view(),
name="delete_slot",
),
path("slot/<int:slot_id>/delete/", SlotDeleteView.as_view(), name="delete_slot"),
path("book/", LaunderetteBookMainView.as_view(), name="book_main"),
path(
"book/<int:launderette_id>/",
LaunderetteBookView.as_view(),
name="book_slot",
),
path("book/<int:launderette_id>/", LaunderetteBookView.as_view(), name="book_slot"),
path(
"<int:launderette_id>/click/",
LaunderetteMainClickView.as_view(),

View File

@ -19,7 +19,7 @@ from datetime import timezone as tz
from django import forms
from django.conf import settings
from django.db import DataError, transaction
from django.db import transaction
from django.template import defaultfilters
from django.urls import reverse_lazy
from django.utils import dateparse, timezone
@ -73,15 +73,15 @@ class LaunderetteBookView(CanViewMixin, DetailView):
self.machines = {}
with transaction.atomic():
self.object = self.get_object()
if "slot_type" in request.POST.keys():
if "slot_type" in request.POST:
self.slot_type = request.POST["slot_type"]
if "slot" in request.POST.keys() and request.user.is_authenticated:
if "slot" in request.POST and request.user.is_authenticated:
self.subscriber = request.user
if self.subscriber.is_subscribed:
self.date = dateparse.parse_datetime(request.POST["slot"]).replace(
tzinfo=tz.utc
)
if self.slot_type == "WASHING":
if self.slot_type in ["WASHING", "DRYING"]:
if self.check_slot(self.slot_type):
Slot(
user=self.subscriber,
@ -89,30 +89,21 @@ class LaunderetteBookView(CanViewMixin, DetailView):
machine=self.machines[self.slot_type],
type=self.slot_type,
).save()
elif self.slot_type == "DRYING":
if self.check_slot(self.slot_type):
Slot(
user=self.subscriber,
start_date=self.date,
machine=self.machines[self.slot_type],
type=self.slot_type,
).save()
else:
if self.check_slot("WASHING") and self.check_slot(
"DRYING", self.date + timedelta(hours=1)
):
Slot(
user=self.subscriber,
start_date=self.date,
machine=self.machines["WASHING"],
type="WASHING",
).save()
Slot(
user=self.subscriber,
start_date=self.date + timedelta(hours=1),
machine=self.machines["DRYING"],
type="DRYING",
).save()
elif self.check_slot("WASHING") and self.check_slot(
"DRYING", self.date + timedelta(hours=1)
):
Slot(
user=self.subscriber,
start_date=self.date,
machine=self.machines["WASHING"],
type="WASHING",
).save()
Slot(
user=self.subscriber,
start_date=self.date + timedelta(hours=1),
machine=self.machines["DRYING"],
type="DRYING",
).save()
return super().get(request, *args, **kwargs)
def check_slot(self, machine_type, date=None):
@ -149,15 +140,17 @@ class LaunderetteBookView(CanViewMixin, DetailView):
):
free = False
if (
self.slot_type == "BOTH"
(
self.slot_type == "BOTH"
and self.check_slot("WASHING", h)
and self.check_slot("DRYING", h + timedelta(hours=1))
)
or self.slot_type == "WASHING"
and self.check_slot("WASHING", h)
and self.check_slot("DRYING", h + timedelta(hours=1))
or self.slot_type == "DRYING"
and self.check_slot("DRYING", h)
):
free = True
elif self.slot_type == "WASHING" and self.check_slot("WASHING", h):
free = True
elif self.slot_type == "DRYING" and self.check_slot("DRYING", h):
free = True
if free and datetime.now().replace(tzinfo=tz.utc) < h:
kwargs["planning"][date].append(h)
else:
@ -236,42 +229,39 @@ class ManageTokenForm(forms.Form):
token_list = cleaned_data["tokens"].strip(" \n\r").split(" ")
token_type = cleaned_data["token_type"]
self.data = {}
if cleaned_data["action"] not in ["BACK", "ADD", "DEL"]:
return
tokens = list(
Token.objects.filter(
launderette=launderette, type=token_type, name__in=token_list
)
)
existing_names = {t.name for t in tokens}
if cleaned_data["action"] in ["BACK", "DEL"]:
for t in set(token_list) - existing_names:
self.add_error(
None,
_("Token %(token_name)s does not exists") % {"token_name": t},
)
if cleaned_data["action"] == "BACK":
for t in token_list:
try:
tok = Token.objects.filter(
launderette=launderette, type=token_type, name=t
).first()
tok.borrow_date = None
tok.user = None
tok.save()
except:
self.add_error(
None,
_("Token %(token_name)s does not exists") % {"token_name": t},
)
elif cleaned_data["action"] == "ADD":
for t in token_list:
try:
Token(launderette=launderette, type=token_type, name=t).save()
except DataError as e:
self.add_error(None, e)
except:
self.add_error(
None,
_("Token %(token_name)s already exists") % {"token_name": t},
)
Token.objects.filter(id__in=[t.id for t in tokens]).update(
borrow_date=None, user=None
)
elif cleaned_data["action"] == "DEL":
Token.objects.filter(id__in=[t.id for t in tokens]).delete()
elif cleaned_data["action"] == "ADD":
for name in existing_names:
self.add_error(
None,
_("Token %(token_name)s already exists") % {"token_name": name},
)
for t in token_list:
try:
Token.objects.filter(
launderette=launderette, type=token_type, name=t
).delete()
except:
self.add_error(
None,
_("Token %(token_name)s does not exists") % {"token_name": t},
)
if t == "":
self.add_error(None, _("Token name can not be blank"))
else:
Token(launderette=launderette, type=token_type, name=t).save()
class LaunderetteAdminView(CanEditPropMixin, BaseFormView, DetailView):
@ -288,13 +278,7 @@ class LaunderetteAdminView(CanEditPropMixin, BaseFormView, DetailView):
def post(self, request, *args, **kwargs):
self.object = self.get_object()
form = self.get_form()
return super().post(request, *args, **kwargs)
form.launderette = self.object
if form.is_valid():
return self.form_valid(form)
else:
return self.form_invalid(form)
def form_valid(self, form):
"""We handle here the redirection, passing the user id of the asked customer."""
@ -353,7 +337,7 @@ class LaunderetteMainClickView(CanEditMixin, BaseFormView, DetailView):
kwargs["counter"] = self.object.counter
kwargs["form"] = self.get_form()
kwargs["barmen"] = [self.request.user]
if "last_basket" in self.request.session.keys():
if "last_basket" in self.request.session:
kwargs["last_basket"] = self.request.session.pop("last_basket", None)
kwargs["last_customer"] = self.request.session.pop("last_customer", None)
kwargs["last_total"] = self.request.session.pop("last_total", None)
@ -479,7 +463,7 @@ class LaunderetteClickView(CanEditMixin, DetailView, BaseFormView):
def get_context_data(self, **kwargs):
"""We handle here the login form for the barman."""
kwargs = super().get_context_data(**kwargs)
if "form" not in kwargs.keys():
if "form" not in kwargs:
kwargs["form"] = self.get_form()
kwargs["counter"] = self.object.counter
kwargs["customer"] = self.customer
@ -519,7 +503,7 @@ class MachineCreateView(CanCreateMixin, CreateView):
def get_initial(self):
ret = super().get_initial()
if "launderette" in self.request.GET.keys():
if "launderette" in self.request.GET:
obj = Launderette.objects.filter(
id=int(self.request.GET["launderette"])
).first()

File diff suppressed because it is too large Load Diff

View File

@ -7,7 +7,7 @@
msgid ""
msgstr ""
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2024-10-09 11:50+0200\n"
"POT-Creation-Date: 2024-10-16 02:19+0200\n"
"PO-Revision-Date: 2024-09-17 11:54+0200\n"
"Last-Translator: Sli <antoine@bartuccio.fr>\n"
"Language-Team: AE info <ae.info@utbm.fr>\n"
@ -22,87 +22,95 @@ msgstr ""
msgid "captured.%s"
msgstr "capture.%s"
#: core/static/webpack/easymde-index.js:32
#: core/static/webpack/ajax-select-index.ts:73
msgid "You need to type %(number)s more characters"
msgstr "Vous devez taper %(number)s caractères de plus"
#: core/static/webpack/ajax-select-index.ts:76
msgid "No results found"
msgstr "Aucun résultat trouvé"
#: core/static/webpack/easymde-index.ts:31
msgid "Heading"
msgstr "Titre"
#: core/static/webpack/easymde-index.js:38
#: core/static/webpack/easymde-index.ts:37
msgid "Italic"
msgstr "Italique"
#: core/static/webpack/easymde-index.js:44
#: core/static/webpack/easymde-index.ts:43
msgid "Bold"
msgstr "Gras"
#: core/static/webpack/easymde-index.js:50
#: core/static/webpack/easymde-index.ts:49
msgid "Strikethrough"
msgstr "Barré"
#: core/static/webpack/easymde-index.js:59
#: core/static/webpack/easymde-index.ts:58
msgid "Underline"
msgstr "Souligné"
#: core/static/webpack/easymde-index.js:68
#: core/static/webpack/easymde-index.ts:67
msgid "Superscript"
msgstr "Exposant"
#: core/static/webpack/easymde-index.js:77
#: core/static/webpack/easymde-index.ts:76
msgid "Subscript"
msgstr "Indice"
#: core/static/webpack/easymde-index.js:83
#: core/static/webpack/easymde-index.ts:82
msgid "Code"
msgstr "Code"
#: core/static/webpack/easymde-index.js:90
#: core/static/webpack/easymde-index.ts:89
msgid "Quote"
msgstr "Citation"
#: core/static/webpack/easymde-index.js:96
#: core/static/webpack/easymde-index.ts:95
msgid "Unordered list"
msgstr "Liste non ordonnée"
#: core/static/webpack/easymde-index.js:102
#: core/static/webpack/easymde-index.ts:101
msgid "Ordered list"
msgstr "Liste ordonnée"
#: core/static/webpack/easymde-index.js:109
#: core/static/webpack/easymde-index.ts:108
msgid "Insert link"
msgstr "Insérer lien"
#: core/static/webpack/easymde-index.js:115
#: core/static/webpack/easymde-index.ts:114
msgid "Insert image"
msgstr "Insérer image"
#: core/static/webpack/easymde-index.js:121
#: core/static/webpack/easymde-index.ts:120
msgid "Insert table"
msgstr "Insérer tableau"
#: core/static/webpack/easymde-index.js:128
#: core/static/webpack/easymde-index.ts:127
msgid "Clean block"
msgstr "Nettoyer bloc"
#: core/static/webpack/easymde-index.js:135
#: core/static/webpack/easymde-index.ts:134
msgid "Toggle preview"
msgstr "Activer la prévisualisation"
#: core/static/webpack/easymde-index.js:141
#: core/static/webpack/easymde-index.ts:140
msgid "Toggle side by side"
msgstr "Activer la vue côte à côte"
#: core/static/webpack/easymde-index.js:147
#: core/static/webpack/easymde-index.ts:146
msgid "Toggle fullscreen"
msgstr "Activer le plein écran"
#: core/static/webpack/easymde-index.js:154
#: core/static/webpack/easymde-index.ts:153
msgid "Markdown guide"
msgstr "Guide markdown"
#: core/static/webpack/user/family-graph-index.js:222
#: core/static/webpack/user/family-graph-index.js:233
msgid "family_tree.%(extension)s"
msgstr "arbre_genealogique.%(extension)s"
#: core/static/webpack/user/pictures-index.js:67
#: core/static/webpack/user/pictures-index.js:76
msgid "pictures.%(extension)s"
msgstr "photos.%(extension)s"
@ -110,10 +118,10 @@ msgstr "photos.%(extension)s"
msgid "Incorrect value"
msgstr "Valeur incorrecte"
#: sas/static/sas/js/viewer.js:205
#: sas/static/webpack/sas/viewer-index.ts:271
msgid "Couldn't moderate picture"
msgstr "Il n'a pas été possible de modérer l'image"
#: sas/static/sas/js/viewer.js:217
#: sas/static/webpack/sas/viewer-index.ts:284
msgid "Couldn't delete picture"
msgstr "Il n'a pas été possible de supprimer l'image"

View File

@ -23,7 +23,12 @@
from django.urls import path
from matmat.views import *
from matmat.views import (
SearchClearFormView,
SearchNormalFormView,
SearchQuickFormView,
SearchReverseFormView,
)
urlpatterns = [
path("", SearchNormalFormView.as_view(), name="search"),

View File

@ -71,15 +71,15 @@ class SearchForm(forms.ModelForm):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
for key in self.fields.keys():
for key in self.fields:
self.fields[key].required = False
@property
def cleaned_data_json(self):
data = self.cleaned_data
for key in data.keys():
if key in ("date_of_birth", "phone") and data[key] is not None:
data[key] = str(data[key])
for key, val in data.items():
if key in ("date_of_birth", "phone") and val is not None:
data[key] = str(val)
return data
@ -98,10 +98,7 @@ class SearchFormListView(FormerSubscriberMixin, SingleObjectMixin, ListView):
self.session = request.session
self.last_search = self.session.get("matmat_search_result", str([]))
self.last_search = literal_eval(self.last_search)
if "valid_form" in kwargs.keys():
self.valid_form = kwargs["valid_form"]
else:
self.valid_form = None
self.valid_form = kwargs.get("valid_form")
self.init_query = self.model.objects
self.can_see_hidden = True
@ -202,8 +199,8 @@ class SearchClearFormView(FormerSubscriberMixin, View):
def dispatch(self, request, *args, **kwargs):
super().dispatch(request, *args, **kwargs)
if "matmat_search_form" in request.session.keys():
if "matmat_search_form" in request.session:
request.session.pop("matmat_search_form")
if "matmat_search_result" in request.session.keys():
if "matmat_search_result" in request.session:
request.session.pop("matmat_search_result")
return HttpResponseRedirect(reverse("matmat:search"))

35
package-lock.json generated
View File

@ -28,9 +28,9 @@
"jquery-ui": "^1.14.0",
"jquery.shorten": "^1.0.0",
"native-file-system-adapter": "^3.0.1",
"select2": "^4.1.0-rc.0",
"three": "^0.169.0",
"three-spritetext": "^1.9.0"
"three-spritetext": "^1.9.0",
"tom-select": "^2.3.1"
},
"devDependencies": {
"@babel/core": "^7.25.2",
@ -39,7 +39,6 @@
"@hey-api/openapi-ts": "^0.53.8",
"@types/alpinejs": "^3.13.10",
"@types/jquery": "^3.5.31",
"@types/select2": "^4.0.63",
"babel-loader": "^9.2.1",
"css-loader": "^7.1.2",
"css-minimizer-webpack-plugin": "^7.0.0",
@ -2112,6 +2111,19 @@
"integrity": "sha512-fuscdXJ9G1qb7W8VdHi+IwRqij3lBkosAm4ydQtEmbY58OzHXqQhvlxqEkoz0yssNVn38bcpRWgA9PP+OGoisw==",
"license": "MIT"
},
"node_modules/@orchidjs/sifter": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@orchidjs/sifter/-/sifter-1.0.3.tgz",
"integrity": "sha512-zCZbwKegHytfsPm8Amcfh7v/4vHqTAaOu6xFswBYcn8nznBOuseu6COB2ON7ez0tFV0mKL0nRNnCiZZA+lU9/g==",
"dependencies": {
"@orchidjs/unicode-variants": "^1.0.4"
}
},
"node_modules/@orchidjs/unicode-variants": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/@orchidjs/unicode-variants/-/unicode-variants-1.0.4.tgz",
"integrity": "sha512-NvVBRnZNE+dugiXERFsET1JlKZfM5lJDEpSMilKW4bToYJ7pxf0Zne78xyXB2ny2c2aHfJ6WLnz1AaTNHAmQeQ=="
},
"node_modules/@pkgjs/parseargs": {
"version": "0.11.0",
"resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
@ -6134,6 +6146,7 @@
"integrity": "sha512-Hr9TdhyHCZUtwznEH2CBf7967mEM0idtJ5nMtjvk3Up5tPukOLXbHUNmh10oRfeNIhj+3GD3niu+g6sVK+gK0A==",
"license": "MIT"
},
"node_modules/semver": {
"version": "7.6.3",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz",
@ -6617,6 +6630,22 @@
"node": ">=8.0"
}
},
"node_modules/tom-select": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/tom-select/-/tom-select-2.3.1.tgz",
"integrity": "sha512-QS4vnOcB6StNGqX4sGboGXL2fkhBF2gIBB+8Hwv30FZXYPn0CyYO8kkdATRvwfCTThxiR4WcXwKJZ3cOmtI9eg==",
"dependencies": {
"@orchidjs/sifter": "^1.0.3",
"@orchidjs/unicode-variants": "^1.0.4"
},
"engines": {
"node": "*"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/tom-select"
}
},
"node_modules/totalist": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz",

View File

@ -27,7 +27,6 @@
"@hey-api/openapi-ts": "^0.53.8",
"@types/alpinejs": "^3.13.10",
"@types/jquery": "^3.5.31",
"@types/select2": "^4.0.63",
"babel-loader": "^9.2.1",
"css-loader": "^7.1.2",
"css-minimizer-webpack-plugin": "^7.0.0",
@ -61,8 +60,8 @@
"jquery-ui": "^1.14.0",
"jquery.shorten": "^1.0.0",
"native-file-system-adapter": "^3.0.1",
"select2": "^4.1.0-rc.0",
"three": "^0.169.0",
"three-spritetext": "^1.9.0"
"three-spritetext": "^1.9.0",
"tom-select": "^2.3.1"
}
}

View File

@ -32,7 +32,10 @@ class Migration(migrations.Migration):
unique=True,
validators=[
django.core.validators.RegexValidator(
message="The code of an UV must only contains uppercase characters without accent and numbers",
message=(
"The code of an UV must only contains "
"uppercase characters without accent and numbers"
),
regex="([A-Z0-9]+)",
)
],

View File

@ -45,7 +45,8 @@ class UV(models.Model):
validators.RegexValidator(
regex="([A-Z0-9]+)",
message=_(
"The code of an UV must only contains uppercase characters without accent and numbers"
"The code of an UV must only contains "
"uppercase characters without accent and numbers"
),
)
],

View File

@ -27,7 +27,10 @@ class TestUVSearch(TestCase):
semester="AUTUMN",
department="GI",
manager="francky",
title="Programmation Orientée Objet: Concepts fondamentaux et mise en pratique avec le langage C++",
title=(
"Programmation Orientée Objet: "
"Concepts fondamentaux et mise en pratique avec le langage C++"
),
),
uv_recipe.prepare(
code="MT01",
@ -118,7 +121,7 @@ class TestUVSearch(TestCase):
("M", {"MT01", "MT10"}),
("mt", {"MT01", "MT10"}),
("MT", {"MT01", "MT10"}),
("algèbre", {"MT01"}), # Title search case insensitive
("algèbre", {"MT01"}), # Title search case insensitive
# Manager search
("moss", {"TNEV"}),
("francky", {"DA50", "AP4A"}),

View File

@ -381,7 +381,9 @@ class TestUVCommentCreationAndDisplay(TestCase):
self.assertContains(
response,
_(
"You already posted a comment on this UV. If you want to comment again, please modify or delete your previous comment."
"You already posted a comment on this UV. "
"If you want to comment again, "
"please modify or delete your previous comment."
),
)

View File

@ -23,7 +23,17 @@
from django.urls import path
from pedagogy.views import *
from pedagogy.views import (
UVCommentDeleteView,
UVCommentReportCreateView,
UVCommentUpdateView,
UVCreateView,
UVDeleteView,
UVDetailFormView,
UVGuideView,
UVModerationFormView,
UVUpdateView,
)
urlpatterns = [
# Urls displaying the actual application for visitors

View File

@ -23,7 +23,7 @@
from django.conf import settings
from django.contrib.auth.mixins import LoginRequiredMixin
from django.core.exceptions import ObjectDoesNotExist, PermissionDenied
from django.core.exceptions import PermissionDenied
from django.shortcuts import get_object_or_404
from django.urls import reverse, reverse_lazy
from django.views.generic import (
@ -193,18 +193,12 @@ class UVModerationFormView(FormView):
def form_valid(self, form):
form_clean = form.clean()
for report in form_clean.get("accepted_reports", []):
try:
report.comment.delete() # Delete the related comment
except ObjectDoesNotExist:
# To avoid errors when two reports points the same comment
pass
for report in form_clean.get("denied_reports", []):
try:
report.delete() # Delete the report itself
except ObjectDoesNotExist:
# To avoid errors when two reports points the same comment
pass
accepted = form_clean.get("accepted_reports", [])
if len(accepted) > 0: # delete the reported comments
UVComment.objects.filter(reports__in=accepted).delete()
denied = form_clean.get("denied_reports", [])
if len(denied) > 0: # delete the comments themselves
UVCommentReport.objects.filter(id__in={d.id for d in denied}).delete()
return super().form_valid(form)
def get_success_url(self):

284
poetry.lock generated
View File

@ -343,73 +343,73 @@ files = [
[[package]]
name = "coverage"
version = "7.6.2"
version = "7.6.3"
description = "Code coverage measurement for Python"
optional = false
python-versions = ">=3.9"
files = [
{file = "coverage-7.6.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:c9df1950fb92d49970cce38100d7e7293c84ed3606eaa16ea0b6bc27175bb667"},
{file = "coverage-7.6.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:24500f4b0e03aab60ce575c85365beab64b44d4db837021e08339f61d1fbfe52"},
{file = "coverage-7.6.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a663b180b6669c400b4630a24cc776f23a992d38ce7ae72ede2a397ce6b0f170"},
{file = "coverage-7.6.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bfde025e2793a22efe8c21f807d276bd1d6a4bcc5ba6f19dbdfc4e7a12160909"},
{file = "coverage-7.6.2-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:087932079c065d7b8ebadd3a0160656c55954144af6439886c8bcf78bbbcde7f"},
{file = "coverage-7.6.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:9c6b0c1cafd96213a0327cf680acb39f70e452caf8e9a25aeb05316db9c07f89"},
{file = "coverage-7.6.2-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:6e85830eed5b5263ffa0c62428e43cb844296f3b4461f09e4bdb0d44ec190bc2"},
{file = "coverage-7.6.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:62ab4231c01e156ece1b3a187c87173f31cbeee83a5e1f6dff17f288dca93345"},
{file = "coverage-7.6.2-cp310-cp310-win32.whl", hash = "sha256:7b80fbb0da3aebde102a37ef0138aeedff45997e22f8962e5f16ae1742852676"},
{file = "coverage-7.6.2-cp310-cp310-win_amd64.whl", hash = "sha256:d20c3d1f31f14d6962a4e2f549c21d31e670b90f777ef4171be540fb7fb70f02"},
{file = "coverage-7.6.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:bb21bac7783c1bf6f4bbe68b1e0ff0d20e7e7732cfb7995bc8d96e23aa90fc7b"},
{file = "coverage-7.6.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a7b2e437fbd8fae5bc7716b9c7ff97aecc95f0b4d56e4ca08b3c8d8adcaadb84"},
{file = "coverage-7.6.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:536f77f2bf5797983652d1d55f1a7272a29afcc89e3ae51caa99b2db4e89d658"},
{file = "coverage-7.6.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f361296ca7054f0936b02525646b2731b32c8074ba6defab524b79b2b7eeac72"},
{file = "coverage-7.6.2-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7926d8d034e06b479797c199747dd774d5e86179f2ce44294423327a88d66ca7"},
{file = "coverage-7.6.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0bbae11c138585c89fb4e991faefb174a80112e1a7557d507aaa07675c62e66b"},
{file = "coverage-7.6.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:fcad7d5d2bbfeae1026b395036a8aa5abf67e8038ae7e6a25c7d0f88b10a8e6a"},
{file = "coverage-7.6.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f01e53575f27097d75d42de33b1b289c74b16891ce576d767ad8c48d17aeb5e0"},
{file = "coverage-7.6.2-cp311-cp311-win32.whl", hash = "sha256:7781f4f70c9b0b39e1b129b10c7d43a4e0c91f90c60435e6da8288efc2b73438"},
{file = "coverage-7.6.2-cp311-cp311-win_amd64.whl", hash = "sha256:9bcd51eeca35a80e76dc5794a9dd7cb04b97f0e8af620d54711793bfc1fbba4b"},
{file = "coverage-7.6.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ebc94fadbd4a3f4215993326a6a00e47d79889391f5659bf310f55fe5d9f581c"},
{file = "coverage-7.6.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:9681516288e3dcf0aa7c26231178cc0be6cac9705cac06709f2353c5b406cfea"},
{file = "coverage-7.6.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8d9c5d13927d77af4fbe453953810db766f75401e764727e73a6ee4f82527b3e"},
{file = "coverage-7.6.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b92f9ca04b3e719d69b02dc4a69debb795af84cb7afd09c5eb5d54b4a1ae2191"},
{file = "coverage-7.6.2-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0ff2ef83d6d0b527b5c9dad73819b24a2f76fdddcfd6c4e7a4d7e73ecb0656b4"},
{file = "coverage-7.6.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:47ccb6e99a3031ffbbd6e7cc041e70770b4fe405370c66a54dbf26a500ded80b"},
{file = "coverage-7.6.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:a867d26f06bcd047ef716175b2696b315cb7571ccb951006d61ca80bbc356e9e"},
{file = "coverage-7.6.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:cdfcf2e914e2ba653101157458afd0ad92a16731eeba9a611b5cbb3e7124e74b"},
{file = "coverage-7.6.2-cp312-cp312-win32.whl", hash = "sha256:f9035695dadfb397bee9eeaf1dc7fbeda483bf7664a7397a629846800ce6e276"},
{file = "coverage-7.6.2-cp312-cp312-win_amd64.whl", hash = "sha256:5ed69befa9a9fc796fe015a7040c9398722d6b97df73a6b608e9e275fa0932b0"},
{file = "coverage-7.6.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4eea60c79d36a8f39475b1af887663bc3ae4f31289cd216f514ce18d5938df40"},
{file = "coverage-7.6.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:aa68a6cdbe1bc6793a9dbfc38302c11599bbe1837392ae9b1d238b9ef3dafcf1"},
{file = "coverage-7.6.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3ec528ae69f0a139690fad6deac8a7d33629fa61ccce693fdd07ddf7e9931fba"},
{file = "coverage-7.6.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ed5ac02126f74d190fa2cc14a9eb2a5d9837d5863920fa472b02eb1595cdc925"},
{file = "coverage-7.6.2-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:21c0ea0d4db8a36b275cb6fb2437a3715697a4ba3cb7b918d3525cc75f726304"},
{file = "coverage-7.6.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:35a51598f29b2a19e26d0908bd196f771a9b1c5d9a07bf20be0adf28f1ad4f77"},
{file = "coverage-7.6.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:c9192925acc33e146864b8cf037e2ed32a91fdf7644ae875f5d46cd2ef086a5f"},
{file = "coverage-7.6.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:bf4eeecc9e10f5403ec06138978235af79c9a79af494eb6b1d60a50b49ed2869"},
{file = "coverage-7.6.2-cp313-cp313-win32.whl", hash = "sha256:e4ee15b267d2dad3e8759ca441ad450c334f3733304c55210c2a44516e8d5530"},
{file = "coverage-7.6.2-cp313-cp313-win_amd64.whl", hash = "sha256:c71965d1ced48bf97aab79fad56df82c566b4c498ffc09c2094605727c4b7e36"},
{file = "coverage-7.6.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:7571e8bbecc6ac066256f9de40365ff833553e2e0c0c004f4482facb131820ef"},
{file = "coverage-7.6.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:078a87519057dacb5d77e333f740708ec2a8f768655f1db07f8dfd28d7a005f0"},
{file = "coverage-7.6.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1e5e92e3e84a8718d2de36cd8387459cba9a4508337b8c5f450ce42b87a9e760"},
{file = "coverage-7.6.2-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ebabdf1c76593a09ee18c1a06cd3022919861365219ea3aca0247ededf6facd6"},
{file = "coverage-7.6.2-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:12179eb0575b8900912711688e45474f04ab3934aaa7b624dea7b3c511ecc90f"},
{file = "coverage-7.6.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:39d3b964abfe1519b9d313ab28abf1d02faea26cd14b27f5283849bf59479ff5"},
{file = "coverage-7.6.2-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:84c4315577f7cd511d6250ffd0f695c825efe729f4205c0340f7004eda51191f"},
{file = "coverage-7.6.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:ff797320dcbff57caa6b2301c3913784a010e13b1f6cf4ab3f563f3c5e7919db"},
{file = "coverage-7.6.2-cp313-cp313t-win32.whl", hash = "sha256:2b636a301e53964550e2f3094484fa5a96e699db318d65398cfba438c5c92171"},
{file = "coverage-7.6.2-cp313-cp313t-win_amd64.whl", hash = "sha256:d03a060ac1a08e10589c27d509bbdb35b65f2d7f3f8d81cf2fa199877c7bc58a"},
{file = "coverage-7.6.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:c37faddc8acd826cfc5e2392531aba734b229741d3daec7f4c777a8f0d4993e5"},
{file = "coverage-7.6.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:ab31fdd643f162c467cfe6a86e9cb5f1965b632e5e65c072d90854ff486d02cf"},
{file = "coverage-7.6.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:97df87e1a20deb75ac7d920c812e9326096aa00a9a4b6d07679b4f1f14b06c90"},
{file = "coverage-7.6.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:343056c5e0737487a5291f5691f4dfeb25b3e3c8699b4d36b92bb0e586219d14"},
{file = "coverage-7.6.2-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ad4ef1c56b47b6b9024b939d503ab487231df1f722065a48f4fc61832130b90e"},
{file = "coverage-7.6.2-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:7fca4a92c8a7a73dee6946471bce6d1443d94155694b893b79e19ca2a540d86e"},
{file = "coverage-7.6.2-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:69f251804e052fc46d29d0e7348cdc5fcbfc4861dc4a1ebedef7e78d241ad39e"},
{file = "coverage-7.6.2-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:e8ea055b3ea046c0f66217af65bc193bbbeca1c8661dc5fd42698db5795d2627"},
{file = "coverage-7.6.2-cp39-cp39-win32.whl", hash = "sha256:6c2ba1e0c24d8fae8f2cf0aeb2fc0a2a7f69b6d20bd8d3749fd6b36ecef5edf0"},
{file = "coverage-7.6.2-cp39-cp39-win_amd64.whl", hash = "sha256:2186369a654a15628e9c1c9921409a6b3eda833e4b91f3ca2a7d9f77abb4987c"},
{file = "coverage-7.6.2-pp39.pp310-none-any.whl", hash = "sha256:667952739daafe9616db19fbedbdb87917eee253ac4f31d70c7587f7ab531b4e"},
{file = "coverage-7.6.2.tar.gz", hash = "sha256:a5f81e68aa62bc0cfca04f7b19eaa8f9c826b53fc82ab9e2121976dc74f131f3"},
{file = "coverage-7.6.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:6da42bbcec130b188169107ecb6ee7bd7b4c849d24c9370a0c884cf728d8e976"},
{file = "coverage-7.6.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c222958f59b0ae091f4535851cbb24eb57fc0baea07ba675af718fb5302dddb2"},
{file = "coverage-7.6.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ab84a8b698ad5a6c365b08061920138e7a7dd9a04b6feb09ba1bfae68346ce6d"},
{file = "coverage-7.6.3-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:70a6756ce66cd6fe8486c775b30889f0dc4cb20c157aa8c35b45fd7868255c5c"},
{file = "coverage-7.6.3-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3c2e6fa98032fec8282f6b27e3f3986c6e05702828380618776ad794e938f53a"},
{file = "coverage-7.6.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:921fbe13492caf6a69528f09d5d7c7d518c8d0e7b9f6701b7719715f29a71e6e"},
{file = "coverage-7.6.3-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:6d99198203f0b9cb0b5d1c0393859555bc26b548223a769baf7e321a627ed4fc"},
{file = "coverage-7.6.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:87cd2e29067ea397a47e352efb13f976eb1b03e18c999270bb50589323294c6e"},
{file = "coverage-7.6.3-cp310-cp310-win32.whl", hash = "sha256:a3328c3e64ea4ab12b85999eb0779e6139295bbf5485f69d42cf794309e3d007"},
{file = "coverage-7.6.3-cp310-cp310-win_amd64.whl", hash = "sha256:bca4c8abc50d38f9773c1ec80d43f3768df2e8576807d1656016b9d3eeaa96fd"},
{file = "coverage-7.6.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c51ef82302386d686feea1c44dbeef744585da16fcf97deea2a8d6c1556f519b"},
{file = "coverage-7.6.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:0ca37993206402c6c35dc717f90d4c8f53568a8b80f0bf1a1b2b334f4d488fba"},
{file = "coverage-7.6.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c77326300b839c44c3e5a8fe26c15b7e87b2f32dfd2fc9fee1d13604347c9b38"},
{file = "coverage-7.6.3-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6e484e479860e00da1f005cd19d1c5d4a813324e5951319ac3f3eefb497cc549"},
{file = "coverage-7.6.3-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0c6c0f4d53ef603397fc894a895b960ecd7d44c727df42a8d500031716d4e8d2"},
{file = "coverage-7.6.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:37be7b5ea3ff5b7c4a9db16074dc94523b5f10dd1f3b362a827af66a55198175"},
{file = "coverage-7.6.3-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:43b32a06c47539fe275106b376658638b418c7cfdfff0e0259fbf877e845f14b"},
{file = "coverage-7.6.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ee77c7bef0724165e795b6b7bf9c4c22a9b8468a6bdb9c6b4281293c6b22a90f"},
{file = "coverage-7.6.3-cp311-cp311-win32.whl", hash = "sha256:43517e1f6b19f610a93d8227e47790722c8bf7422e46b365e0469fc3d3563d97"},
{file = "coverage-7.6.3-cp311-cp311-win_amd64.whl", hash = "sha256:04f2189716e85ec9192df307f7c255f90e78b6e9863a03223c3b998d24a3c6c6"},
{file = "coverage-7.6.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:27bd5f18d8f2879e45724b0ce74f61811639a846ff0e5c0395b7818fae87aec6"},
{file = "coverage-7.6.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d546cfa78844b8b9c1c0533de1851569a13f87449897bbc95d698d1d3cb2a30f"},
{file = "coverage-7.6.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9975442f2e7a5cfcf87299c26b5a45266ab0696348420049b9b94b2ad3d40234"},
{file = "coverage-7.6.3-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:583049c63106c0555e3ae3931edab5669668bbef84c15861421b94e121878d3f"},
{file = "coverage-7.6.3-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2341a78ae3a5ed454d524206a3fcb3cec408c2a0c7c2752cd78b606a2ff15af4"},
{file = "coverage-7.6.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a4fb91d5f72b7e06a14ff4ae5be625a81cd7e5f869d7a54578fc271d08d58ae3"},
{file = "coverage-7.6.3-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:e279f3db904e3b55f520f11f983cc8dc8a4ce9b65f11692d4718ed021ec58b83"},
{file = "coverage-7.6.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:aa23ce39661a3e90eea5f99ec59b763b7d655c2cada10729ed920a38bfc2b167"},
{file = "coverage-7.6.3-cp312-cp312-win32.whl", hash = "sha256:52ac29cc72ee7e25ace7807249638f94c9b6a862c56b1df015d2b2e388e51dbd"},
{file = "coverage-7.6.3-cp312-cp312-win_amd64.whl", hash = "sha256:40e8b1983080439d4802d80b951f4a93d991ef3261f69e81095a66f86cf3c3c6"},
{file = "coverage-7.6.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:9134032f5aa445ae591c2ba6991d10136a1f533b1d2fa8f8c21126468c5025c6"},
{file = "coverage-7.6.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:99670790f21a96665a35849990b1df447993880bb6463a0a1d757897f30da929"},
{file = "coverage-7.6.3-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2dc7d6b380ca76f5e817ac9eef0c3686e7834c8346bef30b041a4ad286449990"},
{file = "coverage-7.6.3-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f7b26757b22faf88fcf232f5f0e62f6e0fd9e22a8a5d0d5016888cdfe1f6c1c4"},
{file = "coverage-7.6.3-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4c59d6a4a4633fad297f943c03d0d2569867bd5372eb5684befdff8df8522e39"},
{file = "coverage-7.6.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f263b18692f8ed52c8de7f40a0751e79015983dbd77b16906e5b310a39d3ca21"},
{file = "coverage-7.6.3-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:79644f68a6ff23b251cae1c82b01a0b51bc40c8468ca9585c6c4b1aeee570e0b"},
{file = "coverage-7.6.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:71967c35828c9ff94e8c7d405469a1fb68257f686bca7c1ed85ed34e7c2529c4"},
{file = "coverage-7.6.3-cp313-cp313-win32.whl", hash = "sha256:e266af4da2c1a4cbc6135a570c64577fd3e6eb204607eaff99d8e9b710003c6f"},
{file = "coverage-7.6.3-cp313-cp313-win_amd64.whl", hash = "sha256:ea52bd218d4ba260399a8ae4bb6b577d82adfc4518b93566ce1fddd4a49d1dce"},
{file = "coverage-7.6.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:8d4c6ea0f498c7c79111033a290d060c517853a7bcb2f46516f591dab628ddd3"},
{file = "coverage-7.6.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:331b200ad03dbaa44151d74daeb7da2cf382db424ab923574f6ecca7d3b30de3"},
{file = "coverage-7.6.3-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:54356a76b67cf8a3085818026bb556545ebb8353951923b88292556dfa9f812d"},
{file = "coverage-7.6.3-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ebec65f5068e7df2d49466aab9128510c4867e532e07cb6960075b27658dca38"},
{file = "coverage-7.6.3-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d33a785ea8354c480515e781554d3be582a86297e41ccbea627a5c632647f2cd"},
{file = "coverage-7.6.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:f7ddb920106bbbbcaf2a274d56f46956bf56ecbde210d88061824a95bdd94e92"},
{file = "coverage-7.6.3-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:70d24936ca6c15a3bbc91ee9c7fc661132c6f4c9d42a23b31b6686c05073bde5"},
{file = "coverage-7.6.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:c30e42ea11badb147f0d2e387115b15e2bd8205a5ad70d6ad79cf37f6ac08c91"},
{file = "coverage-7.6.3-cp313-cp313t-win32.whl", hash = "sha256:365defc257c687ce3e7d275f39738dcd230777424117a6c76043459db131dd43"},
{file = "coverage-7.6.3-cp313-cp313t-win_amd64.whl", hash = "sha256:23bb63ae3f4c645d2d82fa22697364b0046fbafb6261b258a58587441c5f7bd0"},
{file = "coverage-7.6.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:da29ceabe3025a1e5a5aeeb331c5b1af686daab4ff0fb4f83df18b1180ea83e2"},
{file = "coverage-7.6.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:df8c05a0f574d480947cba11b947dc41b1265d721c3777881da2fb8d3a1ddfba"},
{file = "coverage-7.6.3-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ec1e3b40b82236d100d259854840555469fad4db64f669ab817279eb95cd535c"},
{file = "coverage-7.6.3-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b4adeb878a374126f1e5cf03b87f66279f479e01af0e9a654cf6d1509af46c40"},
{file = "coverage-7.6.3-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:43d6a66e33b1455b98fc7312b124296dad97a2e191c80320587234a77b1b736e"},
{file = "coverage-7.6.3-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:1990b1f4e2c402beb317840030bb9f1b6a363f86e14e21b4212e618acdfce7f6"},
{file = "coverage-7.6.3-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:12f9515d875859faedb4144fd38694a761cd2a61ef9603bf887b13956d0bbfbb"},
{file = "coverage-7.6.3-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:99ded130555c021d99729fabd4ddb91a6f4cc0707df4b1daf912c7850c373b13"},
{file = "coverage-7.6.3-cp39-cp39-win32.whl", hash = "sha256:c3a79f56dee9136084cf84a6c7c4341427ef36e05ae6415bf7d787c96ff5eaa3"},
{file = "coverage-7.6.3-cp39-cp39-win_amd64.whl", hash = "sha256:aac7501ae73d4a02f4b7ac8fcb9dc55342ca98ffb9ed9f2dfb8a25d53eda0e4d"},
{file = "coverage-7.6.3-pp39.pp310-none-any.whl", hash = "sha256:b9853509b4bf57ba7b1f99b9d866c422c9c5248799ab20e652bbb8a184a38181"},
{file = "coverage-7.6.3.tar.gz", hash = "sha256:bb7d5fe92bd0dc235f63ebe9f8c6e0884f7360f88f3411bfed1350c872ef2054"},
]
[package.extras]
@ -491,13 +491,13 @@ tests = ["noseofyeti[black] (==2.4.9)", "pytest (==8.3.2)"]
[[package]]
name = "distlib"
version = "0.3.8"
version = "0.3.9"
description = "Distribution utilities"
optional = false
python-versions = "*"
files = [
{file = "distlib-0.3.8-py2.py3-none-any.whl", hash = "sha256:034db59a0b96f8ca18035f36290806a9a6e6bd9d1ff91e45a7f172eb17e51784"},
{file = "distlib-0.3.8.tar.gz", hash = "sha256:1530ea13e350031b6312d8580ddb6b27a104275a31106523b8f123787f494f64"},
{file = "distlib-0.3.9-py2.py3-none-any.whl", hash = "sha256:47f8c22fd27c27e25a65601af709b38e4f0a45ea4fc2e710f65755fa8caaaf87"},
{file = "distlib-0.3.9.tar.gz", hash = "sha256:a60f20dea646b8a33f3e7772f74dc0b2d0772d2837ee1342a00645c81edf9403"},
]
[[package]]
@ -814,13 +814,13 @@ dev = ["flake8", "markdown", "twine", "wheel"]
[[package]]
name = "griffe"
version = "1.3.2"
version = "1.4.1"
description = "Signatures for entire Python programs. Extract the structure, the frame, the skeleton of your project, to generate API documentation or find breaking changes in your API."
optional = false
python-versions = ">=3.8"
python-versions = ">=3.9"
files = [
{file = "griffe-1.3.2-py3-none-any.whl", hash = "sha256:2e34b5e46507d615915c8e6288bb1a2234bd35dee44d01e40a2bc2f25bd4d10c"},
{file = "griffe-1.3.2.tar.gz", hash = "sha256:1ec50335aa507ed2445f2dd45a15c9fa3a45f52c9527e880571dfc61912fd60c"},
{file = "griffe-1.4.1-py3-none-any.whl", hash = "sha256:84295ee0b27743bd880aea75632830ef02ded65d16124025e4c263bb826ab645"},
{file = "griffe-1.4.1.tar.gz", hash = "sha256:911a201b01dc92e08c0e84c38a301e9da5ec067f00e7d9f2e39bc24dbfa3c176"},
]
[package.dependencies]
@ -1283,13 +1283,13 @@ cache = ["platformdirs"]
[[package]]
name = "mkdocs-material"
version = "9.5.39"
version = "9.5.40"
description = "Documentation that simply works"
optional = false
python-versions = ">=3.8"
files = [
{file = "mkdocs_material-9.5.39-py3-none-any.whl", hash = "sha256:0f2f68c8db89523cb4a59705cd01b4acd62b2f71218ccb67e1e004e560410d2b"},
{file = "mkdocs_material-9.5.39.tar.gz", hash = "sha256:25faa06142afa38549d2b781d475a86fb61de93189f532b88e69bf11e5e5c3be"},
{file = "mkdocs_material-9.5.40-py3-none-any.whl", hash = "sha256:8e7a16ada34e79a7b6459ff2602584222f522c738b6a023d1bea853d5049da6f"},
{file = "mkdocs_material-9.5.40.tar.gz", hash = "sha256:b69d70e667ec51fc41f65e006a3184dd00d95b2439d982cb1586e4c018943156"},
]
[package.dependencies]
@ -1323,13 +1323,13 @@ files = [
[[package]]
name = "mkdocstrings"
version = "0.26.1"
version = "0.26.2"
description = "Automatic documentation from sources, for MkDocs."
optional = false
python-versions = ">=3.8"
python-versions = ">=3.9"
files = [
{file = "mkdocstrings-0.26.1-py3-none-any.whl", hash = "sha256:29738bfb72b4608e8e55cc50fb8a54f325dc7ebd2014e4e3881a49892d5983cf"},
{file = "mkdocstrings-0.26.1.tar.gz", hash = "sha256:bb8b8854d6713d5348ad05b069a09f3b79edbc6a0f33a34c6821141adb03fe33"},
{file = "mkdocstrings-0.26.2-py3-none-any.whl", hash = "sha256:1248f3228464f3b8d1a15bd91249ce1701fe3104ac517a5f167a0e01ca850ba5"},
{file = "mkdocstrings-0.26.2.tar.gz", hash = "sha256:34a8b50f1e6cfd29546c6c09fbe02154adfb0b361bb758834bf56aa284ba876e"},
]
[package.dependencies]
@ -1349,13 +1349,13 @@ python-legacy = ["mkdocstrings-python-legacy (>=0.2.1)"]
[[package]]
name = "mkdocstrings-python"
version = "1.11.1"
version = "1.12.1"
description = "A Python handler for mkdocstrings."
optional = false
python-versions = ">=3.8"
python-versions = ">=3.9"
files = [
{file = "mkdocstrings_python-1.11.1-py3-none-any.whl", hash = "sha256:a21a1c05acef129a618517bb5aae3e33114f569b11588b1e7af3e9d4061a71af"},
{file = "mkdocstrings_python-1.11.1.tar.gz", hash = "sha256:8824b115c5359304ab0b5378a91f6202324a849e1da907a3485b59208b797322"},
{file = "mkdocstrings_python-1.12.1-py3-none-any.whl", hash = "sha256:205244488199c9aa2a39787ad6a0c862d39b74078ea9aa2be817bc972399563f"},
{file = "mkdocstrings_python-1.12.1.tar.gz", hash = "sha256:60d6a5ca912c9af4ad431db6d0111ce9f79c6c48d33377dde6a05a8f5f48d792"},
]
[package.dependencies]
@ -1365,13 +1365,13 @@ mkdocstrings = ">=0.26"
[[package]]
name = "model-bakery"
version = "1.19.5"
version = "1.20.0"
description = "Smart object creation facility for Django."
optional = false
python-versions = ">=3.8"
files = [
{file = "model_bakery-1.19.5-py3-none-any.whl", hash = "sha256:09ecbbf124d32614339581b642c82ac4a73147442f598c7bad23eece24187e5c"},
{file = "model_bakery-1.19.5.tar.gz", hash = "sha256:37cece544a33f8899ed8f0488cd6a9d2b0b6925e7b478a4ff2786dece8c63745"},
{file = "model_bakery-1.20.0-py3-none-any.whl", hash = "sha256:875326466f5982ee8f0281abdfa774d78893d5473562575dfd5a9304ac7c5b8c"},
{file = "model_bakery-1.20.0.tar.gz", hash = "sha256:ec9dc846b9a00b20f92df38fac310263323ab61b59b6eeebf77a4aefb0412724"},
]
[package.dependencies]
@ -1599,13 +1599,13 @@ testing = ["pytest", "pytest-benchmark"]
[[package]]
name = "pre-commit"
version = "3.8.0"
version = "4.0.1"
description = "A framework for managing and maintaining multi-language pre-commit hooks."
optional = false
python-versions = ">=3.9"
files = [
{file = "pre_commit-3.8.0-py2.py3-none-any.whl", hash = "sha256:9a90a53bf82fdd8778d58085faf8d83df56e40dfe18f45b19446e26bf1b3a63f"},
{file = "pre_commit-3.8.0.tar.gz", hash = "sha256:8bb6494d4a20423842e198980c9ecf9f96607a07ea29549e180eef9ae80fe7af"},
{file = "pre_commit-4.0.1-py2.py3-none-any.whl", hash = "sha256:efde913840816312445dc98787724647c65473daefe420785f885e8ed9a06878"},
{file = "pre_commit-4.0.1.tar.gz", hash = "sha256:80905ac375958c0444c65e9cebebd948b3cdb518f335a091a670a89d652139d2"},
]
[package.dependencies]
@ -2192,58 +2192,70 @@ use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"]
[[package]]
name = "rjsmin"
version = "1.2.2"
version = "1.2.3"
description = "Javascript Minifier"
optional = false
python-versions = "*"
files = [
{file = "rjsmin-1.2.2-cp27-cp27m-manylinux1_i686.whl", hash = "sha256:4420107304ba7a00b5b9b56cdcd166b9876b34e626829fc4552c85d8fdc3737a"},
{file = "rjsmin-1.2.2-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:155a2f3312c1f8c6cec7b5080581cafc761dc0e41d64bfb5d46a772c5230ded8"},
{file = "rjsmin-1.2.2-cp27-cp27mu-manylinux1_i686.whl", hash = "sha256:88fcb58d65f88cbfa752d51c1ebe5845553f9706def6d9671e98283411575e3e"},
{file = "rjsmin-1.2.2-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:6eae13608b88f4ce32e0557c8fdef58e69bb4d293182202a03e800f0d33b5268"},
{file = "rjsmin-1.2.2-cp310-cp310-manylinux1_i686.whl", hash = "sha256:81f92fb855fb613ebd04a6d6d46483e71fe3c4f22042dc30dcc938fbd748e59c"},
{file = "rjsmin-1.2.2-cp310-cp310-manylinux1_x86_64.whl", hash = "sha256:897db9bf25538047e9388951d532dc291a629b5d041180a8a1a8c102e9d44b90"},
{file = "rjsmin-1.2.2-cp310-cp310-manylinux2014_aarch64.whl", hash = "sha256:5938af8c46734f92f74fdc4d0b6324137c0e09f0a8c3825c83e4cfca1b532e40"},
{file = "rjsmin-1.2.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:0424a7b9096fa2b0ab577c4dc7acd683e6cfb5c718ad39a9fb293cb6cbaba95b"},
{file = "rjsmin-1.2.2-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:1714ed93c2bd40c5f970905d2eeda4a6844e09087ae11277d4d43b3e68c32a47"},
{file = "rjsmin-1.2.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:35596fa6d2d44a5471715c464657123995da78aa6f79bccfbb4b8d6ff7d0a4b4"},
{file = "rjsmin-1.2.2-cp311-cp311-manylinux1_i686.whl", hash = "sha256:3968667158948355b9a62e9641497aac7ac069c076a595e93199d0fe3a40217a"},
{file = "rjsmin-1.2.2-cp311-cp311-manylinux1_x86_64.whl", hash = "sha256:d07d14354694f6a47f572f2aa2a1ad74b76723e62a0d2b6df796138b71888247"},
{file = "rjsmin-1.2.2-cp311-cp311-manylinux2014_aarch64.whl", hash = "sha256:a78dfa6009235b902454ac53264252b7b94f1e43e3a9e97c4cadae88e409b882"},
{file = "rjsmin-1.2.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:9b7a45001e58243a455d11d2de925cadb8c2a0dc737001de646a0f4d90cf0034"},
{file = "rjsmin-1.2.2-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:86c5e657b74b6c9482bb96f18a79d61750f4e8204759cce179f7eb17d395c683"},
{file = "rjsmin-1.2.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:8c2c30b86c7232443a4a726e1bbee34f800556e581e95fc07194ecbf8e02d1d2"},
{file = "rjsmin-1.2.2-cp312-cp312-manylinux1_i686.whl", hash = "sha256:8982c3ef27fac26dd6b7d0c55ae98fa550fee72da2db010b87211e4b5dd78a67"},
{file = "rjsmin-1.2.2-cp312-cp312-manylinux1_x86_64.whl", hash = "sha256:3fc27ae4ece99e2c994cd79df2f0d3f7ac650249f632d19aa8ce85118e33bf0f"},
{file = "rjsmin-1.2.2-cp312-cp312-manylinux2014_aarch64.whl", hash = "sha256:41113d8d6cae7f7406b30143cc49cc045bbb3fadc2f28df398cea30e1daa60b1"},
{file = "rjsmin-1.2.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:3aa09a89b2b7aa2b9251329fe0c3e36c2dc2f10f78b8811e5be92a072596348b"},
{file = "rjsmin-1.2.2-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:5abb8d1241f4ea97950b872fa97a422ba8413fe02358f64128ff0cf745017f07"},
{file = "rjsmin-1.2.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:5abc686a9ef7eaf208f9ad1fb5fb949556ecb7cc1fee27290eb7f194e01d97bd"},
{file = "rjsmin-1.2.2-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:076adcf04c34f712c9427fd9ba6a75bbf7aab975650dfc78cbdd0fbdbe49ca63"},
{file = "rjsmin-1.2.2-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:8cb8947ddd250fce58261b0357846cd5d55419419c0f7dfb131dc4b733579a26"},
{file = "rjsmin-1.2.2-cp36-cp36m-manylinux2014_aarch64.whl", hash = "sha256:9069c48b6508b9c5b05435e2c6042c2a0e2f97b35d7b9c27ceaea5fd377ffdc5"},
{file = "rjsmin-1.2.2-cp36-cp36m-musllinux_1_1_aarch64.whl", hash = "sha256:02b61cf9b6bc518fdac667f3ca3dab051cb8bd1bf4cba28b6d29153ec27990ad"},
{file = "rjsmin-1.2.2-cp36-cp36m-musllinux_1_1_i686.whl", hash = "sha256:09eca8581797244587916e5e07e36c4c86d54a4b7e5c7697484a95b75803515d"},
{file = "rjsmin-1.2.2-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:c52b9dd45c837f1c5c2e8d40776f9e63257f8dbd5f79b85f648cc70da6c1e4e9"},
{file = "rjsmin-1.2.2-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:4fe4ce990412c053a6bcd47d55133927e22fd3d100233d73355f60f9053054c5"},
{file = "rjsmin-1.2.2-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:aa883b9363b5134239066060879d5eb422a0d4ccf24ccf871f65a5b34c64926f"},
{file = "rjsmin-1.2.2-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:6f4e95c5ac95b4cbb519917b3aa1d3d92fc6939c371637674c4a42b67b2b3f44"},
{file = "rjsmin-1.2.2-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:ae3cd64e18e62aa330b24dd6f7b9809ce0a694afd1f01fe99c21f9acd1cb0ea6"},
{file = "rjsmin-1.2.2-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:7999d797fcf805844d2d91598651785497249f592f31674da0964e794b3be019"},
{file = "rjsmin-1.2.2-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:e733fea039a7b5ad7c06cc8bf215ee7afac81d462e273b3ab55c1ccc906cf127"},
{file = "rjsmin-1.2.2-cp38-cp38-manylinux1_i686.whl", hash = "sha256:ccca74461bd53a99ff3304fcf299ea861df89846be3207329cb82d717ce47ea6"},
{file = "rjsmin-1.2.2-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:88f59ad24f91bf9c25d5c2ca3c84a72eed0028f57a98e3b85a915ece5c25be1e"},
{file = "rjsmin-1.2.2-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:7a8b56fbd64adcc4402637f0e07b90b441e9981d720a10eb6265118018b42682"},
{file = "rjsmin-1.2.2-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:2c24686cfdf86e55692183f7867e72c9e982add479c244eda7b8390f96db2c6c"},
{file = "rjsmin-1.2.2-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:6c0d9f9ea8d9cd48cbcdc74a1c2e85d4d588af12bb8f0b672070ae7c9b6e6306"},
{file = "rjsmin-1.2.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:27abd32c9f5b6e0c0a3bcad43e8e24108c6d6c13a4e6c50c97497ea2b4614bb4"},
{file = "rjsmin-1.2.2-cp39-cp39-manylinux1_i686.whl", hash = "sha256:e0e009f6f8460901f5144b34ac2948f94af2f9b8c9b5425da705dbc8152c36c2"},
{file = "rjsmin-1.2.2-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:41e6013cb37a5b3563c19aa35f8e659fa536aa4197a0e3b6a57a381638294a15"},
{file = "rjsmin-1.2.2-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:62cbd38c9f5090f0a6378a45c415b4f96ae871216cedab0dfa21965620c0be4c"},
{file = "rjsmin-1.2.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:2fd5254d36f10a17564b63e8bf9ac579c7b5f211364e11e9753ff5b562843c67"},
{file = "rjsmin-1.2.2-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:6cf0309d001a0d45d731dbaab1afd0c23d135c9e029fe56c935c1798094686fc"},
{file = "rjsmin-1.2.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:bfbe333dab8d23f0a71da90e2d8e8b762a739cbd55a6f948b2dfda089b6d5853"},
{file = "rjsmin-1.2.2.tar.gz", hash = "sha256:8c1bcd821143fecf23242012b55e13610840a839cd467b358f16359010d62dae"},
{file = "rjsmin-1.2.3-cp27-cp27m-manylinux1_i686.whl", hash = "sha256:86e4257875d89b0f7968af9e7c0292e72454f6c75031d1818997782b2e8425a8"},
{file = "rjsmin-1.2.3-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:f2ab72093591127e627b13c1243d4fef40c10593c733517999682f7f2ebf47ee"},
{file = "rjsmin-1.2.3-cp27-cp27mu-manylinux1_i686.whl", hash = "sha256:448b9eb9fd7b6a70beb5c728a41bc23561dd011f0b8fcf7ed9855b6be198c9a2"},
{file = "rjsmin-1.2.3-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:ebd3948f9bc912525bab93f61c694b11410296b5fd0806e988d42378ef302b8e"},
{file = "rjsmin-1.2.3-cp310-cp310-manylinux1_i686.whl", hash = "sha256:823f856b40681328157e5dffc0a588dddefb4b6ce49f79de994dfca6084617be"},
{file = "rjsmin-1.2.3-cp310-cp310-manylinux1_x86_64.whl", hash = "sha256:15e3019f0823a003741ddb93e0c70c5d22567acd0757a7edacc40face1517029"},
{file = "rjsmin-1.2.3-cp310-cp310-manylinux2014_aarch64.whl", hash = "sha256:dece04e309e29879c12dca8af166ea5d77c497ec932cf82e4a1eb24d1489c398"},
{file = "rjsmin-1.2.3-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:dd4a1e527568c3a9711ff1d5251763645c14df02d52a45aec089836600b664ea"},
{file = "rjsmin-1.2.3-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:78aaa3b79a244a4e21164ce355ce22a5a0d7f2d7841a10343009406a3d34d9bb"},
{file = "rjsmin-1.2.3-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:8ea4617618cbf78d98756878a292309f6f54fb4ea1b1ea406f79e88eda4d5d50"},
{file = "rjsmin-1.2.3-cp311-cp311-manylinux1_i686.whl", hash = "sha256:85957171184ef2dee1957cef5e4adb93a7e2702c12c30bd74420ebace1756e89"},
{file = "rjsmin-1.2.3-cp311-cp311-manylinux1_x86_64.whl", hash = "sha256:b6485014e9cbec9a41fb4a7b96ce511ab45a5db8c54ca57ad610f53747e7bab1"},
{file = "rjsmin-1.2.3-cp311-cp311-manylinux2014_aarch64.whl", hash = "sha256:64ac6ef8753c56179a53e237ea4d2b3ccdef88b8b51141618311d48e31013207"},
{file = "rjsmin-1.2.3-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:dbd5f653b5ebcd4920793009ffa210ad5523c523e39e45ee1a0770e4323126dc"},
{file = "rjsmin-1.2.3-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:b0174d7786dcebab808485d1c27f049c74b97590cddcd62f6ed54796a2c6503b"},
{file = "rjsmin-1.2.3-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:6cf24720ea214cbffa0ed96ba0dc24a5cf3ff3cbf59d44a1018292424b48452a"},
{file = "rjsmin-1.2.3-cp312-cp312-manylinux1_i686.whl", hash = "sha256:ac911d1a12a6d7879ba52e08c56b0ad1a74377bae52610ea74f0f9d936d41785"},
{file = "rjsmin-1.2.3-cp312-cp312-manylinux1_x86_64.whl", hash = "sha256:57a0b2f13402623e4ec44eb7ad8846387b2d5605aa8732a05ebefb2289c24b96"},
{file = "rjsmin-1.2.3-cp312-cp312-manylinux2014_aarch64.whl", hash = "sha256:e28610cca3ab03e43113eadad4f7dd9ea235ddc29a8dc5462bb161a80e5d251f"},
{file = "rjsmin-1.2.3-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:d4afb4fc3624dc44a7fbae4e41c0b5dc5d861a7f5de865ad463041ec1b5d835c"},
{file = "rjsmin-1.2.3-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:ca26b80c7e63cf0788b41571a4bd08d175df7719364e0dd9a3cf7b6cb1ab834c"},
{file = "rjsmin-1.2.3-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:fcc22001588b84d34bbf2c77afa519894244150c4b0754a6e573298ffac24666"},
{file = "rjsmin-1.2.3-cp313-cp313-manylinux1_i686.whl", hash = "sha256:624d1a0a35122f3f8955d160a39305cf6f786a5b346ee34c516b391cb153a106"},
{file = "rjsmin-1.2.3-cp313-cp313-manylinux1_x86_64.whl", hash = "sha256:72bd04b7db6190339d8214a5fd289ca31fc1ed30a240f8b0ca13acb9ce3a88af"},
{file = "rjsmin-1.2.3-cp313-cp313-manylinux2014_aarch64.whl", hash = "sha256:7559f59f4749519b92d72bb68e33b68463f479a82a2a739f1b28a853067aa0e7"},
{file = "rjsmin-1.2.3-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:aa8bdecf278f754d1a133ab51119a63a4d38500557912bb0930ae0fd61437ec6"},
{file = "rjsmin-1.2.3-cp313-cp313-musllinux_1_1_i686.whl", hash = "sha256:2078acc2d02a005ef122eb330e941462c8c3102cf798ad49f1c5ec18ac714240"},
{file = "rjsmin-1.2.3-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:fa40584fddb4f1d2236119505f6c2fe2b57a1ebaf6eaee2bb2eaac33d2a4ca73"},
{file = "rjsmin-1.2.3-cp313-cp313t-manylinux1_i686.whl", hash = "sha256:bbe5d8340878b38dd4f7b879ed7728f6fc3d7524ad81a5cfbe4eb8ae63951407"},
{file = "rjsmin-1.2.3-cp313-cp313t-manylinux1_x86_64.whl", hash = "sha256:c298c93f5633cf894325907cf49fc7fb010c0f75dc9cda90b0fc1684ad19e5a3"},
{file = "rjsmin-1.2.3-cp313-cp313t-manylinux2014_aarch64.whl", hash = "sha256:35f18cffe3f1bf6d96bcfd977199378ebfd641d823b08e235d1e0bb0fbaa5532"},
{file = "rjsmin-1.2.3-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:9aeadf4dd5f941bebf110fe83960a4bafdac176647537819bb7662f5e9a37aaa"},
{file = "rjsmin-1.2.3-cp313-cp313t-musllinux_1_1_i686.whl", hash = "sha256:c3219e6e22897b31c8598cb412ed56bc12a722c1d4f88a71710c16efe8c07d0c"},
{file = "rjsmin-1.2.3-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:bceccb06b118be890fe735fc09ee256851f4993708cb3647f6c71dd0151cce89"},
{file = "rjsmin-1.2.3-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:f3620271f00b8ba3c7c5134ca1d99cde5fd1bf1e84aa96aa65c177ee634122f7"},
{file = "rjsmin-1.2.3-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:f3d86f70fcca5f68b65eabbce365d07d80404ecd6aa9c55ba9e9f1042a3514c7"},
{file = "rjsmin-1.2.3-cp36-cp36m-manylinux2014_aarch64.whl", hash = "sha256:1dae9230eae6d7eb2820a511cb640ca6f2e5b91ff78805d71332e8a65a898ea1"},
{file = "rjsmin-1.2.3-cp36-cp36m-musllinux_1_1_aarch64.whl", hash = "sha256:b788c3ec9d68d8fda2240eb7831bdfb2cc0c88d5fb38c9ed6e0fd090eb5d1490"},
{file = "rjsmin-1.2.3-cp36-cp36m-musllinux_1_1_i686.whl", hash = "sha256:4763efbfad7fbf3240a33f08f64991bf0db07453caf283eea51ade84053e9bb7"},
{file = "rjsmin-1.2.3-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:e1379e448da75e2426205c756e79d7b9ba1b7ed616fe97122d72c3fe054e8cac"},
{file = "rjsmin-1.2.3-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:27e134f4d91a5986cba6dced5cb539947a3ec61544ab5ef31b74b384ddc03931"},
{file = "rjsmin-1.2.3-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:2674fcad70d0fab4c1c71e4ac1d4d67935f67e6ecc3924de0dd1264c80a9f9a2"},
{file = "rjsmin-1.2.3-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:b1f2540bd0ce7eda326df7b3bfa360f6edd526bfcb959b5d136afdbccddf0765"},
{file = "rjsmin-1.2.3-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:23f3b4adde995a0d0b7835558840dd4673adf99d2473b6d40474d30801d6c57b"},
{file = "rjsmin-1.2.3-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:c5fb0574eb541d374a2751e9c0ae019fdd86c9e3eb2e7cf893756886e7b3923f"},
{file = "rjsmin-1.2.3-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:18d6a3229d1ed511a0b0a9a7271ef58ff3b02ba408b92b426857b33b137e7f15"},
{file = "rjsmin-1.2.3-cp38-cp38-manylinux1_i686.whl", hash = "sha256:7fe1181452fca7713f377cb6c43cd139638a9edc8c8c29c67119626df164b317"},
{file = "rjsmin-1.2.3-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:57708a4e637aac571c578424a7092d3ec64afb1eabbb73e0c71659457eac9ee4"},
{file = "rjsmin-1.2.3-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:4c1b5a888d43063a22e2e2c2b4db4d6139dfa6e0d2903ae9bb050ed63a340f40"},
{file = "rjsmin-1.2.3-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:e4ac3f85df88d636a9680432fbbf5d3fe1f171821688106a6710738f06575fc2"},
{file = "rjsmin-1.2.3-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:9dff6b14f92ca7a9f6fbf13548358715e47c5e69576aa5dd8b0ad5048fdc967f"},
{file = "rjsmin-1.2.3-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:07c4f1efbbbcd16a645ada1f012595f3eb3e5d5933395effb6104d3731de2d96"},
{file = "rjsmin-1.2.3-cp39-cp39-manylinux1_i686.whl", hash = "sha256:37a73f6ff49dd8c662399575a249a2a028d098c1fa940c6e88aa9082beb44eca"},
{file = "rjsmin-1.2.3-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:73357ec91465cf69173d637ccde7b46ed3a8001161c9650325fa305a486e89a3"},
{file = "rjsmin-1.2.3-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:236c792fbe18c3b18d4e0ad5ff1b1145f1fbe02126aee9f21bca757b00b63b7e"},
{file = "rjsmin-1.2.3-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:3a630a3131a4e63e10665a0ea7cfe0784a3e1e1c854edf79a8ac0654e3756648"},
{file = "rjsmin-1.2.3-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:a1c98f60ca57adbae023cf989eec91d052f0601df63ddc52a0a48303b21a7f9e"},
{file = "rjsmin-1.2.3-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:32a0174efac83ac72a681edcb9acf5e1c87c5b6aae65ed3424468b5945a90f9d"},
{file = "rjsmin-1.2.3.tar.gz", hash = "sha256:1388b52493a4c04fbc970a2d757c301fa05a3c37640314c2ce9dfc8d8a730cc6"},
]
[[package]]
@ -2679,4 +2691,4 @@ filelock = ">=3.4"
[metadata]
lock-version = "2.0"
python-versions = "^3.12"
content-hash = "730b020b335ea67342069c40591f6959f1e5f01eef40806c95777def2f39eb37"
content-hash = "cb47f6409e629d8369a19d82f44a57dbe9414c79e6e72bd88a6bcb34d78f0bc0"

View File

@ -65,7 +65,7 @@ optional = true
# deps used for development purposes, but unneeded in prod
django-debug-toolbar = "^4.4.6"
ipython = "^8.26.0"
pre-commit = "^3.8.0"
pre-commit = "^4.0.1"
ruff = "^0.6.9" # Version used in pipeline is controlled by pre-commit hooks in .pre-commit.config.yaml
djhtml = "^3.0.6"
faker = "^30.3.0"
@ -77,14 +77,14 @@ freezegun = "^1.5.1" # used to test time-dependent code
pytest = "^8.3.2"
pytest-cov = "^5.0.0"
pytest-django = "^4.9.0"
model-bakery = "^1.19.5"
model-bakery = "^1.20.0"
[tool.poetry.group.docs.dependencies]
# deps used to work on the documentation
mkdocs = "^1.6.1"
mkdocs-material = "^9.5.39"
mkdocstrings = "^0.26.1"
mkdocstrings-python = "^1.11.1"
mkdocs-material = "^9.5.40"
mkdocstrings = "^0.26.2"
mkdocstrings-python = "^1.12.0"
mkdocs-include-markdown-plugin = "^6.2.2"
[tool.poetry.group.docs]
@ -101,17 +101,30 @@ select = [
"A", # shadowing of Python builtins
"B",
"C4", # use comprehensions when possible
"I", # isort
"DJ", # django-specific rules,
"F401", # unused import
"E", # pycodestyle (https://docs.astral.sh/ruff/rules/#pycodestyle-e-w)
"ERA", # commented code
"F", # pyflakes (https://docs.astral.sh/ruff/rules/#pyflakes-f)
"FBT", # boolean trap
"FLY", # f-string instead of str.join
"FURB", # https://docs.astral.sh/ruff/rules/#refurb-furb
"I", # isort
"INT", # gettext
"PERF", # performance
"PLW", # pylint warnings (https://docs.astral.sh/ruff/rules/#pylint-pl)
"RUF", # Ruff specific rules
"SIM", # simplify (https://docs.astral.sh/ruff/rules/#flake8-simplify-sim)
"T100", # breakpoint()
"T2", # print statements
"TCH", # type-checking block
"UP008", # Use super() instead of super(__class__, self)
"UP009", # utf-8 encoding declaration is unnecessary
"T2", # print statements
]
ignore = [
"DJ001", # null=True in CharField/TextField. this one would require a migration
"E501", # line too long. The rule is too harsh, and the formatter deals with it in most cases
"RUF012" # mutable class attributes. This rule doesn't integrate well with django
]
[tool.ruff.lint.pydocstyle]

View File

@ -44,8 +44,8 @@ class Command(BaseCommand):
exit(1)
confirm = input(
"User selected: %s\nDo you really want to delete all message from this user ? [y/N] "
% (user,)
"User selected: %s\nDo you really want "
"to delete all message from this user ? [y/N] " % (user,)
)
if not confirm.lower().startswith("y"):

View File

@ -66,11 +66,11 @@ class TestMergeUser(TestCase):
self.to_keep = User.objects.get(pk=self.to_keep.pk)
# fields of to_delete should be assigned to to_keep
# if they were not set beforehand
assert "Biggus" == self.to_keep.first_name
assert "Dickus" == self.to_keep.last_name
assert "B'ian" == self.to_keep.nick_name
assert "Jerusalem" == self.to_keep.address
assert "Rome" == self.to_keep.parent_address
assert self.to_keep.first_name == "Biggus"
assert self.to_keep.last_name == "Dickus"
assert self.to_keep.nick_name == "B'ian"
assert self.to_keep.address == "Jerusalem"
assert self.to_keep.parent_address == "Rome"
assert self.to_keep.groups.count() == 3
groups = sorted(self.to_keep.groups.all(), key=lambda i: i.id)
expected = sorted([subscribers, mde_admin, sas_admin], key=lambda i: i.id)

View File

@ -24,7 +24,11 @@
from django.urls import path
from rootplace.views import *
from rootplace.views import (
DeleteAllForumUserMessagesView,
MergeUsersView,
OperationLogListView,
)
urlpatterns = [
path("merge/", MergeUsersView.as_view(), name="merge"),

View File

@ -48,7 +48,8 @@ def __merge_subscriptions(u1: User, u2: User):
Some examples :
- if u1 is not subscribed, his subscription end date become the one of u2
- if u1 is subscribed but not u2, nothing happen
- if u1 is subscribed for, let's say, 2 remaining months and u2 is subscribed for 3 remaining months,
- if u1 is subscribed for, let's say,
2 remaining months and u2 is subscribed for 3 remaining months,
he shall then be subscribed for 5 months
"""
last_subscription = (

View File

@ -15,7 +15,7 @@
from django.contrib import admin
from sas.models import Album, PeoplePictureRelation, Picture
from sas.models import Album, PeoplePictureRelation, Picture, PictureModerationRequest
@admin.register(Picture)
@ -31,4 +31,15 @@ class PeoplePictureRelationAdmin(admin.ModelAdmin):
autocomplete_fields = ("picture", "user")
admin.site.register(Album)
@admin.register(Album)
class AlbumAdmin(admin.ModelAdmin):
list_display = ("name", "parent", "date", "owner", "is_moderated")
search_fields = ("name",)
autocomplete_fields = ("owner", "parent", "edit_groups", "view_groups")
@admin.register(PictureModerationRequest)
class PictureModerationRequestAdmin(admin.ModelAdmin):
list_display = ("author", "picture", "created_at")
search_fields = ("author", "picture")
autocomplete_fields = ("author", "picture")

View File

@ -9,10 +9,17 @@ from ninja_extra.permissions import IsAuthenticated
from ninja_extra.schemas import PaginatedResponseSchema
from pydantic import NonNegativeInt
from core.api_permissions import CanView, IsOwner
from core.api_permissions import CanView, IsInGroup, IsRoot
from core.models import Notification, User
from sas.models import PeoplePictureRelation, Picture
from sas.schemas import IdentifiedUserSchema, PictureFilterSchema, PictureSchema
from sas.schemas import (
IdentifiedUserSchema,
ModerationRequestSchema,
PictureFilterSchema,
PictureSchema,
)
IsSasAdmin = IsRoot | IsInGroup(settings.SITH_GROUP_SAS_ADMIN_ID)
@api_controller("/sas/picture")
@ -85,18 +92,35 @@ class PicturesController(ControllerBase):
},
)
@route.delete("/{picture_id}", permissions=[IsOwner])
@route.delete("/{picture_id}", permissions=[IsSasAdmin])
def delete_picture(self, picture_id: int):
self.get_object_or_exception(Picture, pk=picture_id).delete()
@route.patch("/{picture_id}/moderate", permissions=[IsOwner])
@route.patch(
"/{picture_id}/moderation",
permissions=[IsSasAdmin],
url_name="picture_moderate",
)
def moderate_picture(self, picture_id: int):
"""Mark a picture as moderated and remove its pending moderation requests."""
picture = self.get_object_or_exception(Picture, pk=picture_id)
picture.moderation_requests.all().delete()
picture.is_moderated = True
picture.moderator = self.context.request.user
picture.asked_for_removal = False
picture.save()
@route.get(
"/{picture_id}/moderation",
permissions=[IsSasAdmin],
response=list[ModerationRequestSchema],
url_name="picture_moderation_requests",
)
def fetch_moderation_requests(self, picture_id: int):
"""Fetch the moderation requests issued on this picture."""
picture = self.get_object_or_exception(Picture, pk=picture_id)
return picture.moderation_requests.select_related("author")
@api_controller("/sas/relation", tags="User identification on SAS pictures")
class UsersIdentifiedController(ControllerBase):

124
sas/forms.py Normal file
View File

@ -0,0 +1,124 @@
from typing import Any
from ajax_select import make_ajax_field
from ajax_select.fields import AutoCompleteSelectMultipleField
from django import forms
from django.utils.translation import gettext_lazy as _
from core.models import User
from core.views import MultipleImageField
from core.views.forms import SelectDate
from sas.models import Album, PeoplePictureRelation, Picture, PictureModerationRequest
class SASForm(forms.Form):
album_name = forms.CharField(
label=_("Add a new album"), max_length=Album.NAME_MAX_LENGTH, required=False
)
images = MultipleImageField(
label=_("Upload images"),
required=False,
)
def process(self, parent, owner, files, *, automodere=False):
try:
if self.cleaned_data["album_name"] != "":
album = Album(
parent=parent,
name=self.cleaned_data["album_name"],
owner=owner,
is_moderated=automodere,
)
album.clean()
album.save()
except Exception as e:
self.add_error(
None,
_("Error creating album %(album)s: %(msg)s")
% {"album": self.cleaned_data["album_name"], "msg": repr(e)},
)
for f in files:
new_file = Picture(
parent=parent,
name=f.name,
file=f,
owner=owner,
mime_type=f.content_type,
size=f.size,
is_folder=False,
is_moderated=automodere,
)
if automodere:
new_file.moderator = owner
try:
new_file.clean()
new_file.generate_thumbnails()
new_file.save()
except Exception as e:
self.add_error(
None,
_("Error uploading file %(file_name)s: %(msg)s")
% {"file_name": f, "msg": repr(e)},
)
class RelationForm(forms.ModelForm):
class Meta:
model = PeoplePictureRelation
fields = ["picture"]
widgets = {"picture": forms.HiddenInput}
users = AutoCompleteSelectMultipleField(
"users", show_help_text=False, help_text="", label=_("Add user"), required=False
)
class PictureEditForm(forms.ModelForm):
class Meta:
model = Picture
fields = ["name", "parent"]
parent = make_ajax_field(Picture, "parent", "files", help_text="")
class AlbumEditForm(forms.ModelForm):
class Meta:
model = Album
fields = ["name", "date", "file", "parent", "edit_groups"]
name = forms.CharField(max_length=Album.NAME_MAX_LENGTH, label=_("file name"))
date = forms.DateField(label=_("Date"), widget=SelectDate, required=True)
parent = make_ajax_field(Album, "parent", "files", help_text="")
edit_groups = make_ajax_field(Album, "edit_groups", "groups", help_text="")
recursive = forms.BooleanField(label=_("Apply rights recursively"), required=False)
class PictureModerationRequestForm(forms.ModelForm):
"""Form to create a PictureModerationRequest.
The form only manages the reason field,
because the author and the picture are set in the view.
"""
class Meta:
model = PictureModerationRequest
fields = ["reason"]
def __init__(self, *args, user: User, picture: Picture, **kwargs):
super().__init__(*args, **kwargs)
self.user = user
self.picture = picture
def clean(self) -> dict[str, Any]:
if PictureModerationRequest.objects.filter(
author=self.user, picture=self.picture
).exists():
raise forms.ValidationError(
_("You already requested moderation for this picture.")
)
return super().clean()
def save(self, *, commit=True) -> PictureModerationRequest:
self.instance.author = self.user
self.instance.picture = self.picture
return super().save(commit)

View File

@ -0,0 +1,68 @@
# Generated by Django 4.2.16 on 2024-10-10 20:44
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
("sas", "0003_sasfile"),
]
operations = [
migrations.CreateModel(
name="PictureModerationRequest",
fields=[
(
"id",
models.AutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("created_at", models.DateTimeField(auto_now_add=True)),
(
"reason",
models.TextField(
default="",
help_text="Why do you want this image to be removed ?",
verbose_name="Reason",
),
),
],
options={
"verbose_name": "Picture moderation request",
"verbose_name_plural": "Picture moderation requests",
},
),
migrations.AddField(
model_name="picturemoderationrequest",
name="author",
field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="moderation_requests",
to=settings.AUTH_USER_MODEL,
verbose_name="Author",
),
),
migrations.AddField(
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",
),
),
migrations.AddConstraint(
model_name="picturemoderationrequest",
constraint=models.UniqueConstraint(
fields=("author", "picture"), name="one_request_per_user_per_picture"
),
),
]

View File

@ -15,6 +15,7 @@
from __future__ import annotations
import contextlib
from io import BytesIO
from pathlib import Path
from typing import ClassVar, Self
@ -108,10 +109,8 @@ class Picture(SasFile):
def generate_thumbnails(self, *, overwrite=False):
im = Image.open(BytesIO(self.file.read()))
try:
with contextlib.suppress(Exception):
im = exif_auto_rotate(im)
except:
pass
# 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
@ -273,16 +272,12 @@ class PeoplePictureRelation(models.Model):
User,
verbose_name=_("user"),
related_name="pictures",
null=False,
blank=False,
on_delete=models.CASCADE,
)
picture = models.ForeignKey(
Picture,
verbose_name=_("picture"),
related_name="people",
null=False,
blank=False,
on_delete=models.CASCADE,
)
@ -290,4 +285,39 @@ class PeoplePictureRelation(models.Model):
unique_together = ["user", "picture"]
def __str__(self):
return self.user.get_display_name() + " - " + str(self.picture)
return f"Moderation request by {self.user.get_short_name()} - {self.picture}"
class PictureModerationRequest(models.Model):
"""A request to remove a Picture from the SAS."""
created_at = models.DateTimeField(auto_now_add=True)
author = models.ForeignKey(
User,
verbose_name=_("Author"),
related_name="moderation_requests",
on_delete=models.CASCADE,
)
picture = models.ForeignKey(
Picture,
verbose_name=_("Picture"),
related_name="moderation_requests",
on_delete=models.CASCADE,
)
reason = models.TextField(
verbose_name=_("Reason"),
default="",
help_text=_("Why do you want this image to be removed ?"),
)
class Meta:
verbose_name = _("Picture moderation request")
verbose_name_plural = _("Picture moderation requests")
constraints = [
models.UniqueConstraint(
fields=["author", "picture"], name="one_request_per_user_per_picture"
)
]
def __str__(self):
return f"Moderation request by {self.author.get_short_name()}"

View File

@ -4,8 +4,8 @@ from django.urls import reverse
from ninja import FilterSchema, ModelSchema, Schema
from pydantic import Field, NonNegativeInt
from core.schemas import UserProfileSchema
from sas.models import Picture
from core.schemas import SimpleUserSchema, UserProfileSchema
from sas.models import Picture, PictureModerationRequest
class PictureFilterSchema(FilterSchema):
@ -52,3 +52,11 @@ class PictureRelationCreationSchema(Schema):
class IdentifiedUserSchema(Schema):
id: int
user: UserProfileSchema
class ModerationRequestSchema(ModelSchema):
author: SimpleUserSchema
class Meta:
model = PictureModerationRequest
fields = ["id", "created_at", "reason"]

View File

@ -29,7 +29,7 @@
width: 100%;
}
> .photo {
>.photo {
box-sizing: border-box;
height: 500px;
display: flex;
@ -42,7 +42,7 @@
height: auto;
}
> img {
>img {
height: 100%;
max-width: 100%;
object-fit: contain;
@ -57,7 +57,7 @@
width: 100%;
}
> .navigation {
>.navigation {
display: flex;
flex-direction: row;
gap: 10px;
@ -66,8 +66,8 @@
width: 100%;
}
> #prev,
> #next {
>#prev,
>#next {
width: calc(50% - 5px);
aspect-ratio: 16/9;
background: #333333;
@ -80,6 +80,7 @@
object-fit: cover;
opacity: 70%;
}
.overlay {
position: absolute;
top: 50%;
@ -89,7 +90,7 @@
font-size: 40px;
}
> div {
>div {
display: flex;
position: relative;
width: 100%;
@ -98,12 +99,12 @@
}
}
> .tags {
>.tags {
@media (min-width: 1001px) {
margin-right: 5px;
}
> ul {
>ul {
list-style-type: none;
margin: 0;
display: flex;
@ -118,7 +119,7 @@
margin-right: 5px;
}
> li {
>li {
box-sizing: border-box;
display: flex;
flex-direction: row;
@ -135,7 +136,7 @@
max-width: calc(50% - 5px);
}
> a {
>a {
box-sizing: border-box;
display: flex;
flex-direction: row;
@ -155,7 +156,7 @@
background-color: #aaa;
}
> span {
>span {
width: 100%;
text-overflow: ellipsis;
white-space: nowrap;
@ -167,14 +168,14 @@
margin-left: 10px;
}
> img {
>img {
width: 25px;
max-height: 25px;
object-fit: contain;
border-radius: 50%;
}
> .profile-pic {
>.profile-pic {
background-position: center center;
background-size: cover;
background-repeat: no-repeat;
@ -187,23 +188,24 @@
}
}
> form {
> p {
>form {
>p {
box-sizing: border-box;
}
> .results_on_deck > div {
>.results_on_deck>div {
position: relative;
display: flex;
align-items: center;
word-break: break-word;
> span {
>span {
position: absolute;
top: 0;
right: 0;
}
}
input {
min-width: 100%;
max-width: 100%;
@ -226,17 +228,17 @@
flex-direction: column;
}
> .infos {
>.infos {
display: flex;
flex-direction: column;
width: 50%;
> div > div {
>div>div {
display: flex;
flex-direction: row;
justify-content: space-between;
> *:first-child {
>*:first-child {
min-width: 150px;
@media (max-width: 1000px) {
@ -246,18 +248,18 @@
}
}
> .tools {
>.tools {
display: flex;
flex-direction: column;
width: 50%;
> div {
>div {
display: flex;
flex-direction: row;
justify-content: space-between;
> div {
> a.button {
>div {
>a.button {
box-sizing: border-box;
background-color: #f2f2f2;
display: flex;
@ -274,7 +276,7 @@
}
}
> a.text.danger {
>a.text.danger {
color: red;
&:hover {
@ -289,4 +291,4 @@
}
}
}
}
}

View File

@ -1,24 +1,20 @@
import { makeUrl, paginated } from "#core:utils/api";
import { paginated } from "#core:utils/api";
import { exportToHtml } from "#core:utils/globals";
import { History } from "#core:utils/history";
import {
type AjaxResponse,
type RemoteResult,
remoteDataSource,
sithSelect2,
} from "#core:utils/select2";
import type TomSelect from "tom-select";
import {
type IdentifiedUserSchema,
type PictureSchema,
type PicturesFetchIdentificationsResponse,
type PicturesFetchModerationRequestsResponse,
type PicturesFetchPicturesData,
type UserProfileSchema,
picturesDeletePicture,
picturesFetchIdentifications,
picturesFetchModerationRequests,
picturesFetchPictures,
picturesIdentifyUsers,
picturesModeratePicture,
userSearchUsers,
usersidentifiedDeleteRelation,
} from "#openapi";
@ -27,18 +23,20 @@ import {
* able to prefetch its data.
*/
class PictureWithIdentifications {
identifications: PicturesFetchIdentificationsResponse | null = null;
identifications: PicturesFetchIdentificationsResponse = null;
imageLoading = false;
identificationsLoading = false;
moderationLoading = false;
id: number;
// biome-ignore lint/style/useNamingConvention: api is in snake_case
compressed_url: string;
moderationRequests: PicturesFetchModerationRequestsResponse = null;
constructor(picture: PictureSchema) {
Object.assign(this, picture);
}
static fromPicture(picture: PictureSchema) {
static fromPicture(picture: PictureSchema): PictureWithIdentifications {
return new PictureWithIdentifications(picture);
}
@ -46,7 +44,7 @@ class PictureWithIdentifications {
* If not already done, fetch the users identified on this picture and
* populate the identifications field
*/
async loadIdentifications(options?: { forceReload: boolean }) {
async loadIdentifications(options?: { forceReload: boolean }): Promise<void> {
if (this.identificationsLoading) {
return; // The users are already being fetched.
}
@ -65,11 +63,29 @@ class PictureWithIdentifications {
this.identificationsLoading = false;
}
async loadModeration(options?: { forceReload: boolean }): Promise<void> {
if (this.moderationLoading) {
return; // The moderation requests are already being fetched.
}
if (!!this.moderationRequests && !options?.forceReload) {
// The moderation requests are already fetched
// and the user does not want to force the reload
return;
}
this.moderationLoading = true;
this.moderationRequests = (
await picturesFetchModerationRequests({
// biome-ignore lint/style/useNamingConvention: api is in snake_case
path: { picture_id: this.id },
})
).data;
this.moderationLoading = false;
}
/**
* Preload the photo and the identifications
* @return {Promise<void>}
*/
async preload() {
async preload(): Promise<void> {
const img = new Image();
img.src = this.compressed_url;
if (!img.complete) {
@ -87,12 +103,12 @@ interface ViewerConfig {
userId: number;
/** Url of the current album */
albumUrl: string;
/** Id of the album to displlay */
/** Id of the album to display */
albumId: number;
/** id of the first picture to load on the page */
firstPictureId: number;
/** if the user is sas admin */
userIsSasAdmin: number;
userIsSasAdmin: boolean;
}
/**
@ -103,9 +119,8 @@ exportToHtml("loadViewer", (config: ViewerConfig) => {
Alpine.data("picture_viewer", () => ({
/**
* All the pictures that can be displayed on this picture viewer
* @type PictureWithIdentifications[]
**/
pictures: [],
pictures: [] as PictureWithIdentifications[],
/**
* The currently displayed picture
* Default dummy data are pre-loaded to avoid javascript error
@ -131,14 +146,12 @@ exportToHtml("loadViewer", (config: ViewerConfig) => {
},
/**
* The picture which will be displayed next if the user press the "next" button
* @type ?PictureWithIdentifications
**/
nextPicture: null,
nextPicture: null as PictureWithIdentifications,
/**
* The picture which will be displayed next if the user press the "previous" button
* @type ?PictureWithIdentifications
**/
previousPicture: null,
previousPicture: null as PictureWithIdentifications,
/**
* The select2 component used to identify users
**/
@ -148,13 +161,11 @@ exportToHtml("loadViewer", (config: ViewerConfig) => {
**/
/**
* Error message when a moderation operation fails
* @type string
**/
moderationError: "",
/**
* Method of pushing new url to the browser history
* Used by popstate event and always reset to it's default value when used
* @type History
**/
pushstate: History.Push,
@ -165,20 +176,21 @@ exportToHtml("loadViewer", (config: ViewerConfig) => {
query: { album_id: config.albumId },
} as PicturesFetchPicturesData)
).map(PictureWithIdentifications.fromPicture);
this.selector = sithSelect2({
element: $(this.$refs.search) as unknown as HTMLElement,
dataSource: remoteDataSource(await makeUrl(userSearchUsers), {
excluded: () => [
...(this.currentPicture.identifications || []).map(
(i: IdentifiedUserSchema) => i.user.id,
),
],
resultConverter: (obj: AjaxResponse) => {
return { ...obj, text: (obj as UserProfileSchema).display_name };
},
}),
pictureGetter: (user: RemoteResult) => user.profile_pict,
});
this.selector = this.$refs.search;
this.selector.filter = (users: UserProfileSchema[]) => {
const resp: UserProfileSchema[] = [];
const ids = [
...(this.currentPicture.identifications || []).map(
(i: IdentifiedUserSchema) => i.user.id,
),
];
for (const user of users) {
if (!ids.includes(user.id)) {
resp.push(user);
}
}
return resp;
};
this.currentPicture = this.pictures.find(
(i: PictureSchema) => i.id === config.firstPictureId,
);
@ -213,7 +225,7 @@ exportToHtml("loadViewer", (config: ViewerConfig) => {
* and the previous picture, the next picture and
* the list of identified users are updated.
*/
async updatePicture() {
async updatePicture(): Promise<void> {
const updateArgs = {
data: { sasPictureId: this.currentPicture.id },
unused: "",
@ -231,16 +243,23 @@ exportToHtml("loadViewer", (config: ViewerConfig) => {
}
this.moderationError = "";
const index = this.pictures.indexOf(this.currentPicture);
const index: number = this.pictures.indexOf(this.currentPicture);
this.previousPicture = this.pictures[index - 1] || null;
this.nextPicture = this.pictures[index + 1] || null;
await this.currentPicture.loadIdentifications();
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() {
@ -253,7 +272,7 @@ exportToHtml("loadViewer", (config: ViewerConfig) => {
return;
}
this.currentPicture.is_moderated = true;
this.currentPicture.askedForRemoval = false;
this.currentPicture.asked_for_removal = false;
},
async deletePicture() {
@ -277,33 +296,34 @@ exportToHtml("loadViewer", (config: ViewerConfig) => {
/**
* Send the identification request and update the list of identified users.
*/
async submitIdentification() {
async submitIdentification(): Promise<void> {
const widget: TomSelect = this.selector.widget;
await picturesIdentifyUsers({
path: {
// biome-ignore lint/style/useNamingConvention: api is in snake_case
picture_id: this.currentPicture.id,
},
body: this.selector.val().map((i: string) => Number.parseInt(i)),
body: widget.items.map((i: string) => Number.parseInt(i)),
});
// refresh the identified users list
await this.currentPicture.loadIdentifications({ forceReload: true });
this.selector.empty().trigger("change");
// Clear selection and cache of retrieved user so they can be filtered again
widget.clear(false);
widget.clearOptions();
},
/**
* Check if an identification can be removed by the currently logged user
* @param {PictureIdentification} identification
* @return {boolean}
*/
canBeRemoved(identification: IdentifiedUserSchema) {
canBeRemoved(identification: IdentifiedUserSchema): boolean {
return config.userIsSasAdmin || identification.user.id === config.userId;
},
/**
* Untag a user from the current picture
* @param {PictureIdentification} identification
*/
async removeIdentification(identification: IdentifiedUserSchema) {
async removeIdentification(identification: IdentifiedUserSchema): Promise<void> {
const res = await usersidentifiedDeleteRelation({
// biome-ignore lint/style/useNamingConvention: api is in snake_case
path: { relation_id: identification.id },

View File

@ -0,0 +1,28 @@
{% extends "core/base.jinja" %}
{% block title %}
{% trans %}SAS{% endtrans %}
{% endblock %}
{% block content %}
<h1>{% trans %}Image removal request{% endtrans %}</h1>
<form action="" method="post">
{% csrf_token %}
{{ form.non_field_errors() }}
<div class="helptext">
{{ form.reason.help_text }}
</div>
{{ form.reason }}
<br/>
<br/>
<a class="clickable btn btn-grey" href="{{ object.get_absolute_url() }}">
{% trans %}Cancel{% endtrans %}
</a>
<input
class="btn btn-blue"
type="submit"
value="{% trans %}Request removal{% endtrans %}"
>
</form>
{% endblock content %}

View File

@ -1,11 +1,12 @@
{% extends "core/base.jinja" %}
{%- block additional_css -%}
<link rel="stylesheet" href="{{ static('webpack/ajax-select-index.css') }}">
<link rel="stylesheet" href="{{ static('sas/css/picture.scss') }}">
<link rel="stylesheet" href="{{ static('webpack/sas/viewer-index.css') }}" defer>
{%- endblock -%}
{%- block additional_js -%}
<script defer src="{{ static('webpack/ajax-select-index.ts') }}"></script>
<script defer src="{{ static("webpack/sas/viewer-index.ts") }}"></script>
{%- endblock -%}
@ -30,10 +31,10 @@
<br>
<template x-if="!currentPicture.is_moderated">
<div class="alert alert-red">
<div class="alert alert-red" @click="console.log(currentPicture)">
<div class="alert-main">
<template x-if="currentPicture.askedForRemoval">
<span class="important">{% trans %}Asked for removal{% endtrans %}</span>
<template x-if="currentPicture.asked_for_removal">
<h3 class="alert-title">{% trans %}Asked for removal{% endtrans %}</h3>
</template>
<p>
{% trans trimmed %}
@ -41,16 +42,33 @@
It will be hidden to other users until it has been moderated.
{% endtrans %}
</p>
<template x-if="currentPicture.asked_for_removal">
<div>
<h5 @click="console.log(currentPicture.moderationRequests)">
{% trans %}The following issues have been raised:{% endtrans %}
</h5>
<template x-for="req in (currentPicture.moderationRequests ?? [])" :key="req.id">
<div>
<h6
x-text="`${req.author.first_name} ${req.author.last_name}`"
></h6>
<i x-text="Intl.DateTimeFormat(
'{{ LANGUAGE_CODE }}',
{dateStyle: 'long', timeStyle: 'short'}
).format(new Date(req.created_at))"></i>
<blockquote x-text="`> ${req.reason}`"></blockquote>
</div>
</template>
</div>
</template>
</div>
<div>
<div>
<button class="btn btn-blue" @click="moderatePicture()">
{% trans %}Moderate{% endtrans %}
</button>
<button class="btn btn-red" @click.prevent="deletePicture()">
{% trans %}Delete{% endtrans %}
</button>
</div>
<div class="alert-aside">
<button class="btn btn-blue" @click="moderatePicture()">
{% trans %}Moderate{% endtrans %}
</button>
<button class="btn btn-red" @click.prevent="deletePicture()">
{% trans %}Delete{% endtrans %}
</button>
<p x-show="!!moderationError" x-text="moderationError"></p>
</div>
</div>
@ -58,7 +76,6 @@
<div class="container" id="pict">
<div class="main">
<div class="photo" :aria-busy="currentPicture.imageLoading">
<img
:src="currentPicture.compressed_url"
@ -96,7 +113,9 @@
{% trans %}HD version{% endtrans %}
</a>
<br>
<a class="text danger" href="?ask_removal">{% trans %}Ask for removal{% endtrans %}</a>
<a class="text danger" :href="`/sas/picture/${currentPicture.id}/report`">
{% trans %}Ask for removal{% endtrans %}
</a>
</div>
<div class="buttons">
<a class="button" :href="`/sas/picture/${currentPicture.id}/edit/`"><i class="fa-regular fa-pen-to-square edit-action"></i></a>
@ -138,7 +157,12 @@
<h5>{% trans %}People{% endtrans %}</h5>
{% if user.was_subscribed %}
<form @submit.prevent="submitIdentification" x-show="!!selector">
<select x-ref="search" multiple="multiple"></select>
<ajax-select
x-ref="search"
multiple
data-delay="300"
data-placeholder="{%- trans -%}Identify users on pictures{%- endtrans -%}"
></ajax-select>
<input type="submit" value="{% trans %}Go{% endtrans %}"/>
</form>
{% endif %}

View File

@ -3,35 +3,45 @@ from django.db import transaction
from django.test import TestCase
from django.urls import reverse
from model_bakery import baker
from model_bakery.recipe import Recipe
from pytest_django.asserts import assertNumQueries
from core.baker_recipes import old_subscriber_user, subscriber_user
from core.models import RealGroup, User
from core.models import RealGroup, SithFile, User
from sas.baker_recipes import picture_recipe
from sas.models import Album, PeoplePictureRelation, Picture
from sas.models import Album, PeoplePictureRelation, Picture, PictureModerationRequest
class TestSas(TestCase):
@classmethod
def setUpTestData(cls):
Picture.objects.all().delete()
sas = SithFile.objects.get(pk=settings.SITH_SAS_ROOT_DIR_ID)
Picture.objects.exclude(id=sas.id).delete()
owner = User.objects.get(username="root")
cls.user_a = old_subscriber_user.make()
cls.user_b, cls.user_c = subscriber_user.make(_quantity=2)
picture = picture_recipe.extend(owner=owner)
cls.album_a = baker.make(Album, is_in_sas=True)
cls.album_b = baker.make(Album, is_in_sas=True)
cls.album_a = baker.make(Album, is_in_sas=True, parent=sas)
cls.album_b = baker.make(Album, is_in_sas=True, parent=sas)
relation_recipe = Recipe(PeoplePictureRelation)
relations = []
for album in cls.album_a, cls.album_b:
pictures = picture.make(parent=album, _quantity=5, _bulk_create=True)
baker.make(PeoplePictureRelation, picture=pictures[1], user=cls.user_a)
baker.make(PeoplePictureRelation, picture=pictures[2], user=cls.user_a)
baker.make(PeoplePictureRelation, picture=pictures[2], user=cls.user_b)
baker.make(PeoplePictureRelation, picture=pictures[3], user=cls.user_b)
baker.make(PeoplePictureRelation, picture=pictures[4], user=cls.user_a)
baker.make(PeoplePictureRelation, picture=pictures[4], user=cls.user_b)
baker.make(PeoplePictureRelation, picture=pictures[4], user=cls.user_c)
relations.extend(
[
relation_recipe.prepare(picture=pictures[1], user=cls.user_a),
relation_recipe.prepare(picture=pictures[2], user=cls.user_a),
relation_recipe.prepare(picture=pictures[2], user=cls.user_b),
relation_recipe.prepare(picture=pictures[3], user=cls.user_b),
relation_recipe.prepare(picture=pictures[4], user=cls.user_a),
relation_recipe.prepare(picture=pictures[4], user=cls.user_b),
relation_recipe.prepare(picture=pictures[4], user=cls.user_c),
]
)
PeoplePictureRelation.objects.bulk_create(relations)
class TestPictureSearch(TestSas):
@ -170,3 +180,49 @@ class TestPictureRelation(TestSas):
res = self.client.delete(f"/api/sas/relation/{relation.id}")
assert res.status_code == 404
assert PeoplePictureRelation.objects.count() == relation_count
class TestPictureModeration(TestSas):
@classmethod
def setUpTestData(cls):
super().setUpTestData()
cls.sas_admin = baker.make(
User, groups=[RealGroup.objects.get(pk=settings.SITH_GROUP_SAS_ADMIN_ID)]
)
cls.picture = Picture.objects.filter(parent=cls.album_a)[0]
cls.picture.is_moderated = False
cls.picture.asked_for_removal = True
cls.picture.save()
cls.url = reverse("api:picture_moderate", kwargs={"picture_id": cls.picture.id})
baker.make(PictureModerationRequest, picture=cls.picture, author=cls.user_a)
def test_moderation_route_forbidden(self):
"""Test that basic users (even if owner) cannot moderate a picture."""
self.picture.owner = self.user_b
for user in baker.make(User), subscriber_user.make(), self.user_b:
self.client.force_login(user)
res = self.client.patch(self.url)
assert res.status_code == 403
def test_moderation_route_authorized(self):
"""Test that sas admins can moderate a picture."""
self.client.force_login(self.sas_admin)
res = self.client.patch(self.url)
assert res.status_code == 200
self.picture.refresh_from_db()
assert self.picture.is_moderated
assert not self.picture.asked_for_removal
assert not self.picture.moderation_requests.exists()
def test_get_moderation_requests(self):
"""Test that fetching moderation requests work."""
url = reverse(
"api:picture_moderation_requests", kwargs={"picture_id": self.picture.id}
)
self.client.force_login(self.sas_admin)
res = self.client.get(url)
assert res.status_code == 200
assert len(res.json()) == 1
assert res.json()[0]["author"]["id"] == self.user_a.id

View File

@ -20,7 +20,7 @@ from django.core.cache import cache
from django.test import Client, TestCase
from django.urls import reverse
from model_bakery import baker
from pytest_django.asserts import assertRedirects
from pytest_django.asserts import assertInHTML, assertRedirects
from core.baker_recipes import old_subscriber_user, subscriber_user
from core.models import RealGroup, User
@ -70,7 +70,9 @@ def test_album_access_non_subscriber(client: Client):
class TestSasModeration(TestCase):
@classmethod
def setUpTestData(cls):
album = baker.make(Album, parent_id=settings.SITH_SAS_ROOT_DIR_ID)
album = baker.make(
Album, parent_id=settings.SITH_SAS_ROOT_DIR_ID, is_moderated=True
)
cls.pictures = picture_recipe.make(
parent=album, _quantity=10, _bulk_create=True
)
@ -82,6 +84,9 @@ class TestSasModeration(TestCase):
)
cls.simple_user = subscriber_user.make()
def setUp(self):
cache.clear()
def test_moderation_page_sas_admin(self):
"""Test that a moderator can see the pictures needing moderation."""
self.client.force_login(self.moderator)
@ -132,3 +137,37 @@ class TestSasModeration(TestCase):
)
assert res.status_code == 403
assert Picture.objects.filter(pk=self.to_moderate.id).exists()
def test_request_moderation_form_access(self):
"""Test that regular can access the form to ask for moderation."""
self.client.force_login(self.simple_user)
res = self.client.get(
reverse(
"sas:picture_ask_removal", kwargs={"picture_id": self.pictures[1].id}
),
)
assert res.status_code == 200
def test_request_moderation_form_submit(self):
"""Test that moderation requests are created."""
self.client.force_login(self.simple_user)
message = "J'aime pas cette photo (ni la Cocarde)."
url = reverse(
"sas:picture_ask_removal", kwargs={"picture_id": self.pictures[1].id}
)
res = self.client.post(url, data={"reason": message})
assertRedirects(
res, reverse("sas:album", kwargs={"album_id": self.pictures[1].parent_id})
)
assert self.pictures[1].moderation_requests.count() == 1
assert self.pictures[1].moderation_requests.first().reason == message
# test that the user cannot ask for moderation twice
res = self.client.post(url, data={"reason": message})
assert res.status_code == 200
assert self.pictures[1].moderation_requests.count() == 1
assertInHTML(
'<ul class="errorlist nonfield"><li>'
"Vous avez déjà déposé une demande de retrait pour cette photo.</li></ul>",
res.content.decode(),
)

View File

@ -15,24 +15,38 @@
from django.urls import path
from sas.views import *
from sas.views import (
AlbumEditView,
AlbumUploadView,
AlbumView,
ModerationView,
PictureAskRemovalView,
PictureEditView,
PictureView,
SASMainView,
send_album,
send_compressed,
send_pict,
send_thumb,
)
urlpatterns = [
path("", SASMainView.as_view(), name="main"),
path("moderation/", ModerationView.as_view(), name="moderation"),
path("album/<int:album_id>/", AlbumView.as_view(), name="album"),
path(
"album/<int:album_id>/upload/",
AlbumUploadView.as_view(),
name="album_upload",
"album/<int:album_id>/upload/", AlbumUploadView.as_view(), name="album_upload"
),
path("album/<int:album_id>/edit/", AlbumEditView.as_view(), name="album_edit"),
path("album/<int:album_id>/preview/", send_album, name="album_preview"),
path("picture/<int:picture_id>/", PictureView.as_view(), name="picture"),
path(
"picture/<int:picture_id>/edit/",
PictureEditView.as_view(),
name="picture_edit",
"picture/<int:picture_id>/edit/", PictureEditView.as_view(), name="picture_edit"
),
path(
"picture/<int:picture_id>/report",
PictureAskRemovalView.as_view(),
name="picture_ask_removal",
),
path("picture/<int:picture_id>/download/", send_pict, name="download"),
path(
@ -40,9 +54,5 @@ urlpatterns = [
send_compressed,
name="download_compressed",
),
path(
"picture/<int:picture_id>/download/thumb/",
send_thumb,
name="download_thumb",
),
path("picture/<int:picture_id>/download/thumb/", send_thumb, name="download_thumb"),
]

View File

@ -12,14 +12,12 @@
# OR WITHIN THE LOCAL FILE "LICENSE"
#
#
from typing import Any
from ajax_select import make_ajax_field
from ajax_select.fields import AutoCompleteSelectMultipleField
from django import forms
from django.conf import settings
from django.core.exceptions import PermissionDenied
from django.http import Http404, HttpResponse
from django.shortcuts import get_object_or_404, redirect
from django.http import Http404, HttpResponse, HttpResponseRedirect
from django.shortcuts import get_object_or_404
from django.urls import reverse, reverse_lazy
from django.utils.translation import gettext_lazy as _
from django.views.generic import DetailView, TemplateView
@ -27,71 +25,14 @@ from django.views.generic.edit import FormMixin, FormView, UpdateView
from core.models import SithFile, User
from core.views import CanEditMixin, CanViewMixin
from core.views.files import FileView, MultipleImageField, send_file
from core.views.forms import SelectDate
from sas.models import Album, PeoplePictureRelation, Picture
class SASForm(forms.Form):
album_name = forms.CharField(
label=_("Add a new album"), max_length=Album.NAME_MAX_LENGTH, required=False
)
images = MultipleImageField(
label=_("Upload images"),
required=False,
)
def process(self, parent, owner, files, *, automodere=False):
try:
if self.cleaned_data["album_name"] != "":
album = Album(
parent=parent,
name=self.cleaned_data["album_name"],
owner=owner,
is_moderated=automodere,
)
album.clean()
album.save()
except Exception as e:
self.add_error(
None,
_("Error creating album %(album)s: %(msg)s")
% {"album": self.cleaned_data["album_name"], "msg": repr(e)},
)
for f in files:
new_file = Picture(
parent=parent,
name=f.name,
file=f,
owner=owner,
mime_type=f.content_type,
size=f.size,
is_folder=False,
is_moderated=automodere,
)
if automodere:
new_file.moderator = owner
try:
new_file.clean()
new_file.generate_thumbnails()
new_file.save()
except Exception as e:
self.add_error(
None,
_("Error uploading file %(file_name)s: %(msg)s")
% {"file_name": f, "msg": repr(e)},
)
class RelationForm(forms.ModelForm):
class Meta:
model = PeoplePictureRelation
fields = ["picture"]
widgets = {"picture": forms.HiddenInput}
users = AutoCompleteSelectMultipleField(
"users", show_help_text=False, help_text="", label=_("Add user"), required=False
)
from core.views.files import FileView, send_file
from sas.forms import (
AlbumEditForm,
PictureEditForm,
PictureModerationRequestForm,
SASForm,
)
from sas.models import Album, Picture
class SASMainView(FormView):
@ -138,11 +79,6 @@ class PictureView(CanViewMixin, DetailView):
self.object.rotate(270)
if "rotate_left" in request.GET:
self.object.rotate(90)
if "ask_removal" in request.GET.keys():
self.object.is_moderated = False
self.object.asked_for_removal = True
self.object.save()
return redirect("sas:album", album_id=self.object.parent.id)
return super().get(request, *args, **kwargs)
def get_context_data(self, **kwargs):
@ -179,19 +115,18 @@ class AlbumUploadView(CanViewMixin, DetailView, FormMixin):
self.form = self.get_form()
parent = SithFile.objects.filter(id=self.object.id).first()
files = request.FILES.getlist("images")
if request.user.is_authenticated and request.user.is_subscribed:
if request.user.is_subscribed and self.form.is_valid():
self.form.process(
parent=parent,
owner=request.user,
files=files,
automodere=(
request.user.is_in_group(pk=settings.SITH_GROUP_SAS_ADMIN_ID)
or request.user.is_root
),
)
if self.form.is_valid():
self.form.process(
parent=parent,
owner=request.user,
files=files,
automodere=(
request.user.is_in_group(pk=settings.SITH_GROUP_SAS_ADMIN_ID)
or request.user.is_root
),
)
if self.form.is_valid():
return HttpResponse(str(self.form.errors), status=200)
return HttpResponse(str(self.form.errors), status=200)
return HttpResponse(str(self.form.errors), status=500)
@ -210,7 +145,7 @@ class AlbumView(CanViewMixin, DetailView, FormMixin):
def get(self, request, *args, **kwargs):
self.form = self.get_form()
if "clipboard" not in request.session.keys():
if "clipboard" not in request.session:
request.session["clipboard"] = []
return super().get(request, *args, **kwargs)
@ -219,7 +154,7 @@ class AlbumView(CanViewMixin, DetailView, FormMixin):
if not self.object.file:
self.object.generate_thumbnail()
self.form = self.get_form()
if "clipboard" not in request.session.keys():
if "clipboard" not in request.session:
request.session["clipboard"] = []
if request.user.can_edit(self.object): # Handle the copy-paste functions
FileView.handle_clipboard(request, self.object)
@ -293,26 +228,6 @@ class ModerationView(TemplateView):
return kwargs
class PictureEditForm(forms.ModelForm):
class Meta:
model = Picture
fields = ["name", "parent"]
parent = make_ajax_field(Picture, "parent", "files", help_text="")
class AlbumEditForm(forms.ModelForm):
class Meta:
model = Album
fields = ["name", "date", "file", "parent", "edit_groups"]
name = forms.CharField(max_length=Album.NAME_MAX_LENGTH, label=_("file name"))
date = forms.DateField(label=_("Date"), widget=SelectDate, required=True)
parent = make_ajax_field(Album, "parent", "files", help_text="")
edit_groups = make_ajax_field(Album, "edit_groups", "groups", help_text="")
recursive = forms.BooleanField(label=_("Apply rights recursively"), required=False)
class PictureEditView(CanEditMixin, UpdateView):
model = Picture
form_class = PictureEditForm
@ -320,6 +235,41 @@ class PictureEditView(CanEditMixin, UpdateView):
pk_url_kwarg = "picture_id"
class PictureAskRemovalView(CanViewMixin, DetailView, FormView):
"""View to allow users to ask pictures to be removed."""
model = Picture
template_name = "sas/ask_picture_removal.jinja"
pk_url_kwarg = "picture_id"
form_class = PictureModerationRequestForm
def get_form_kwargs(self) -> dict[str, Any]:
"""Add the user and picture to the form kwargs.
Those are required to create the PictureModerationRequest,
and aren't part of the form itself
(picture is a path parameter, and user is the request user).
"""
return super().get_form_kwargs() | {
"user": self.request.user,
"picture": self.object,
}
def get_success_url(self) -> str:
"""Return the URL to the album containing the picture."""
album = Album.objects.filter(pk=self.object.parent_id).first()
if not album:
return reverse("sas:main")
return album.get_absolute_url()
def form_valid(self, form: PictureModerationRequestForm) -> HttpResponseRedirect:
form.save()
self.object.is_moderated = False
self.object.asked_for_removal = True
self.object.save()
return super().form_valid(form)
class AlbumEditView(CanEditMixin, UpdateView):
model = Album
form_class = AlbumEditForm

View File

@ -346,8 +346,8 @@ SITH_LAUNDERETTE_MANAGER = {
# Main root for club pages
SITH_CLUB_ROOT_PAGE = "clubs"
# Define the date in the year serving as reference for the subscriptions calendar
# (month, day)
# Define the date in the year serving as
# reference for the subscriptions calendar (month, day)
SITH_SEMESTER_START_AUTUMN = (8, 15) # 15 August
SITH_SEMESTER_START_SPRING = (2, 15) # 15 February
@ -510,10 +510,12 @@ SITH_ACCOUNT_INACTIVITY_DELTA = relativedelta(years=2)
SITH_ACCOUNT_DUMP_DELTA = timedelta(days=30)
"""timedelta between the warning mail and the actual account dump"""
# Defines which product type is the refilling type, and thus increases the account amount
# Defines which product type is the refilling type,
# and thus increases the account amount
SITH_COUNTER_PRODUCTTYPE_REFILLING = 3
# Defines which product is the one year subscription and which one is the six month subscription
# Defines which product is the one year subscription
# and which one is the six month subscription
SITH_PRODUCT_SUBSCRIPTION_ONE_SEMESTER = 1
SITH_PRODUCT_SUBSCRIPTION_TWO_SEMESTERS = 2
SITH_PRODUCTTYPE_SUBSCRIPTION = 2
@ -701,15 +703,15 @@ TOXIC_DOMAINS_PROVIDERS = [
]
try:
from .settings_custom import *
from .settings_custom import * # noqa F403 (this star-import is actually useful)
logging.getLogger("django").info("Custom settings imported")
except:
except ImportError:
logging.getLogger("django").warning("Custom settings failed")
if DEBUG:
INSTALLED_APPS += ("debug_toolbar",)
MIDDLEWARE = ("debug_toolbar.middleware.DebugToolbarMiddleware",) + MIDDLEWARE
MIDDLEWARE = ("debug_toolbar.middleware.DebugToolbarMiddleware", *MIDDLEWARE)
DEBUG_TOOLBAR_PANELS = [
"debug_toolbar.panels.versions.VersionsPanel",
"debug_toolbar.panels.timer.TimerPanel",
@ -723,7 +725,8 @@ if DEBUG:
"debug_toolbar.panels.signals.SignalsPanel",
"debug_toolbar.panels.redirects.RedirectsPanel",
]
SENTRY_ENV = "development"
if not TESTING:
SENTRY_ENV = "development" # We can't test if it gets overridden in settings
if TESTING:
CAPTCHA_TEST_MODE = True

32
sith/tests.py Normal file
View File

@ -0,0 +1,32 @@
from contextlib import nullcontext as does_not_raise
import pytest
from _pytest.python_api import RaisesContext
from django.test import Client
from django.test.utils import override_settings
from django.urls import reverse
@pytest.mark.django_db
@pytest.mark.parametrize(
("sentry_dsn", "sentry_env", "expected_error", "expected_return_code"),
[
# Working case
("something", "development", pytest.raises(ZeroDivisionError), None),
# View is disabled when DSN isn't defined or environment isn't development
("something", "production", does_not_raise(), 404),
("", "development", does_not_raise(), 404),
("", "production", does_not_raise(), 404),
],
)
def test_sentry_debug_endpoint(
client: Client,
sentry_dsn: str,
sentry_env: str,
expected_error: RaisesContext[ZeroDivisionError] | does_not_raise[None],
expected_return_code: int | None,
):
with expected_error, override_settings(
SENTRY_DSN=sentry_dsn, SENTRY_ENV=sentry_env
):
assert client.get(reverse("sentry-debug")).status_code == expected_return_code

View File

@ -17,6 +17,7 @@ from ajax_select import urls as ajax_select_urls
from django.conf import settings
from django.conf.urls.static import static
from django.contrib import admin
from django.http import Http404
from django.urls import include, path
from django.views.i18n import JavaScriptCatalog
from ninja_extra import NinjaExtraAPI
@ -71,20 +72,22 @@ if settings.DEBUG:
urlpatterns += [path("__debug__/", include(debug_toolbar.urls))]
if settings.SENTRY_ENV == "development":
def sentry_debug(request):
"""Sentry debug endpoint
This function always crash and allows us to test
the sentry configuration and the modal popup
the sentry configuration and the modal popup
displayed to users on production
The error will be displayed on Sentry
inside the "development" environment
NOTE : you need to specify the SENTRY_DSN setting in settings_custom.py
"""
if settings.SENTRY_ENV != "development" or not settings.SENTRY_DSN:
raise Http404
_division_by_zero = 1 / 0
def raise_exception(request):
division_by_zero = 1 / 0
urlpatterns += [path("sentry-debug/", raise_exception)]
urlpatterns += [path("sentry-debug/", sentry_debug, name="sentry-debug")]

Some files were not shown because too many files have changed in this diff Show More