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 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(BankAccount)
admin.site.register(ClubAccount) admin.site.register(ClubAccount)

View File

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

View File

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

View File

@ -15,7 +15,41 @@
from django.urls import path 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 = [ urlpatterns = [
# Accounting types # Accounting types

View File

@ -182,7 +182,7 @@ class ClubAccountCreateView(CanCreateMixin, CreateView):
def get_initial(self): def get_initial(self):
ret = super().get_initial() 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() obj = BankAccount.objects.filter(id=int(self.request.GET["parent"])).first()
if obj is not None: if obj is not None:
ret["bank_account"] = obj.id ret["bank_account"] = obj.id
@ -264,7 +264,7 @@ class JournalCreateView(CanCreateMixin, CreateView):
def get_initial(self): def get_initial(self):
ret = super().get_initial() 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() obj = ClubAccount.objects.filter(id=int(self.request.GET["parent"])).first()
if obj is not None: if obj is not None:
ret["club_account"] = obj.id ret["club_account"] = obj.id
@ -362,7 +362,7 @@ class OperationForm(forms.ModelForm):
def clean(self): def clean(self):
self.cleaned_data = super().clean() self.cleaned_data = super().clean()
if "target_type" in self.cleaned_data.keys(): if "target_type" in self.cleaned_data:
if ( if (
self.cleaned_data.get("user") is None self.cleaned_data.get("user") is None
and self.cleaned_data.get("club") is None and self.cleaned_data.get("club") is None
@ -633,19 +633,17 @@ class JournalNatureStatementView(JournalTabsMixin, CanViewMixin, DetailView):
ret = collections.OrderedDict() ret = collections.OrderedDict()
statement = collections.OrderedDict() statement = collections.OrderedDict()
total_sum = 0 total_sum = 0
for sat in [None] + list( for sat in [
SimplifiedAccountingType.objects.order_by("label").all() None,
): *list(SimplifiedAccountingType.objects.order_by("label")),
]:
amount = queryset.filter( amount = queryset.filter(
accounting_type__movement_type=movement_type, simpleaccounting_type=sat accounting_type__movement_type=movement_type, simpleaccounting_type=sat
).aggregate(amount_sum=Sum("amount"))["amount_sum"] ).aggregate(amount_sum=Sum("amount"))["amount_sum"]
if sat: label = sat.label if sat is not None else ""
sat = sat.label
else:
sat = ""
if amount: if amount:
total_sum += amount total_sum += amount
statement[sat] = amount statement[label] = amount
ret[movement_type] = statement ret[movement_type] = statement
ret[movement_type + "_sum"] = total_sum ret[movement_type + "_sum"] = total_sum
return ret return ret
@ -668,15 +666,12 @@ class JournalNatureStatementView(JournalTabsMixin, CanViewMixin, DetailView):
self.statement(self.object.operations.filter(label=None).all(), "DEBIT") self.statement(self.object.operations.filter(label=None).all(), "DEBIT")
) )
statement[_("No label operations")] = no_label_statement statement[_("No label operations")] = no_label_statement
for l in labels: for label in labels:
l_stmt = collections.OrderedDict() l_stmt = collections.OrderedDict()
l_stmt.update( journals = self.object.operations.filter(label=label).all()
self.statement(self.object.operations.filter(label=l).all(), "CREDIT") l_stmt.update(self.statement(journals, "CREDIT"))
) l_stmt.update(self.statement(journals, "DEBIT"))
l_stmt.update( statement[label] = l_stmt
self.statement(self.object.operations.filter(label=l).all(), "DEBIT")
)
statement[l] = l_stmt
return statement return statement
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
@ -798,7 +793,7 @@ class LabelCreateView(
def get_initial(self): def get_initial(self):
ret = super().get_initial() 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() obj = ClubAccount.objects.filter(id=int(self.request.GET["parent"])).first()
if obj is not None: if obj is not None:
ret["club_account"] = obj.id 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.""" """Convert given users into real users and check their validity."""
cleaned_data = super().clean() cleaned_data = super().clean()
users = [] users = []
for user in cleaned_data["subscription_users"]: for user_id in cleaned_data["subscription_users"]:
user = User.objects.filter(id=user).first() user = User.objects.filter(id=user_id).first()
if not user: if not user:
raise forms.ValidationError( raise forms.ValidationError(
_("One of the selected users doesn't exist"), code="invalid" _("One of the selected users doesn't exist"), code="invalid"
@ -128,7 +128,7 @@ class MailingForm(forms.Form):
def clean(self): def clean(self):
cleaned_data = super().clean() 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 # If there is no action provided, we can stop here
raise forms.ValidationError(_("An action is required"), code="invalid") 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: if user.is_root or user.is_board_member:
return True return True
membership = self.club.get_membership_for(user) membership = self.club.get_membership_for(user)
if membership is not None and membership.role >= self.role: return membership is not None and membership.role >= self.role
return True
return False
def delete(self, *args, **kwargs): def delete(self, *args, **kwargs):
super().delete(*args, **kwargs) super().delete(*args, **kwargs)

View File

@ -24,7 +24,32 @@
from django.urls import path 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 = [ urlpatterns = [
path("", ClubListView.as_view(), name="club_list"), path("", ClubListView.as_view(), name="club_list"),
@ -32,32 +57,20 @@ urlpatterns = [
path("stats/", ClubStatView.as_view(), name="club_stats"), path("stats/", ClubStatView.as_view(), name="club_stats"),
path("<int:club_id>/", ClubView.as_view(), name="club_view"), path("<int:club_id>/", ClubView.as_view(), name="club_view"),
path( path(
"<int:club_id>/rev/<int:rev_id>/", "<int:club_id>/rev/<int:rev_id>/", ClubRevView.as_view(), name="club_view_rev"
ClubRevView.as_view(),
name="club_view_rev",
), ),
path("<int:club_id>/hist/", ClubPageHistView.as_view(), name="club_hist"), 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/", ClubEditView.as_view(), name="club_edit"),
path( path("<int:club_id>/edit/page/", ClubPageEditView.as_view(), name="club_edit_page"),
"<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>/members/", ClubMembersView.as_view(), name="club_members"),
path( path(
"<int:club_id>/elderlies/", "<int:club_id>/elderlies/",
ClubOldMembersView.as_view(), ClubOldMembersView.as_view(),
name="club_old_members", name="club_old_members",
), ),
path("<int:club_id>/sellings/", ClubSellingView.as_view(), name="club_sellings"),
path( path(
"<int:club_id>/sellings/", "<int:club_id>/sellings/csv/", ClubSellingCSVView.as_view(), name="sellings_csv"
ClubSellingView.as_view(),
name="club_sellings",
),
path(
"<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>/prop/", ClubEditPropView.as_view(), name="club_prop"),
path("<int:club_id>/tools/", ClubToolsView.as_view(), name="tools"), 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/", PosterListView.as_view(), name="poster_list"),
path( path(
"<int:club_id>/poster/create/", "<int:club_id>/poster/create/", PosterCreateView.as_view(), name="poster_create"
PosterCreateView.as_view(),
name="poster_create",
), ),
path( path(
"<int:club_id>/poster/<int:poster_id>/edit/", "<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()) row.append(selling.customer.user.get_display_name())
else: else:
row.append("") row.append("")
row = row + [ row = [
*row,
selling.label, selling.label,
selling.quantity, selling.quantity,
selling.quantity * selling.unit_price, selling.quantity * selling.unit_price,
@ -408,7 +409,7 @@ class ClubSellingCSVView(ClubSellingView):
row.append(selling.product.purchase_price) row.append(selling.product.purchase_price)
row.append(selling.product.selling_price - selling.product.purchase_price) row.append(selling.product.selling_price - selling.product.purchase_price)
else: else:
row = row + ["", "", ""] row = [*row, "", "", ""]
return row return row
def get(self, request, *args, **kwargs): def get(self, request, *args, **kwargs):
@ -622,9 +623,7 @@ class ClubMailingView(ClubTabsMixin, CanEditMixin, DetailFormView):
def remove_subscription(self, cleaned_data): def remove_subscription(self, cleaned_data):
"""Remove specified users from a mailing list.""" """Remove specified users from a mailing list."""
fields = [ fields = [
cleaned_data[key] val for key, val in cleaned_data.items() if key.startswith("removal_")
for key in cleaned_data.keys()
if key.startswith("removal_")
] ]
for field in fields: for field in fields:
for sub in field: for sub in field:

View File

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

View File

@ -16,7 +16,36 @@
from django.urls import path from django.urls import path
from club.views import MailingDeleteView 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 = [ urlpatterns = [
path("sith/edit/alert/", AlertMsgEditView.as_view(), name="alert_edit"), 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/", NewsListView.as_view(), name="news_list"),
path("news/admin/", NewsAdminListView.as_view(), name="news_admin_list"), path("news/admin/", NewsAdminListView.as_view(), name="news_admin_list"),
path("news/create/", NewsCreateView.as_view(), name="news_new"), path("news/create/", NewsCreateView.as_view(), name="news_new"),
path("news/<int:news_id>/delete/", NewsDeleteView.as_view(), name="news_delete"),
path( path(
"news/<int:news_id>/delete/", "news/<int:news_id>/moderate/", NewsModerateView.as_view(), name="news_moderate"
NewsDeleteView.as_view(),
name="news_delete",
),
path(
"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>/edit/", NewsEditView.as_view(), name="news_edit"),
path("news/<int:news_id>/", NewsDetailView.as_view(), name="news_detail"), 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/", PosterListView.as_view(), name="poster_list"),
path("poster/create/", PosterCreateView.as_view(), name="poster_create"), path("poster/create/", PosterCreateView.as_view(), name="poster_create"),
path( path("poster/<int:poster_id>/edit/", PosterEditView.as_view(), name="poster_edit"),
"poster/<int:poster_id>/edit/",
PosterEditView.as_view(),
name="poster_edit",
),
path( path(
"poster/<int:poster_id>/delete/", "poster/<int:poster_id>/delete/",
PosterDeleteView.as_view(), PosterDeleteView.as_view(),
@ -98,11 +117,7 @@ urlpatterns = [
ScreenSlideshowView.as_view(), ScreenSlideshowView.as_view(),
name="screen_slideshow", name="screen_slideshow",
), ),
path( path("screen/<int:screen_id>/edit/", ScreenEditView.as_view(), name="screen_edit"),
"screen/<int:screen_id>/edit/",
ScreenEditView.as_view(),
name="screen_edit",
),
path( path(
"screen/<int:screen_id>/delete/", "screen/<int:screen_id>/delete/",
ScreenDeleteView.as_view(), ScreenDeleteView.as_view(),

View File

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

View File

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

View File

@ -934,7 +934,7 @@ Welcome to the wiki page!
# Adding subscription for sli # Adding subscription for sli
s = Subscription( s = Subscription(
member=User.objects.filter(pk=sli.pk).first(), 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], payment_method=settings.SITH_SUBSCRIPTION_PAYMENT_METHOD[0][0],
) )
s.subscription_start = s.compute_start() s.subscription_start = s.compute_start()
@ -947,7 +947,7 @@ Welcome to the wiki page!
# Adding subscription for Krophil # Adding subscription for Krophil
s = Subscription( s = Subscription(
member=User.objects.filter(pk=krophil.pk).first(), 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], payment_method=settings.SITH_SUBSCRIPTION_PAYMENT_METHOD[0][0],
) )
s.subscription_start = s.compute_start() s.subscription_start = s.compute_start()

View File

@ -217,9 +217,9 @@ class Command(BaseCommand):
UV.objects.bulk_create(uvs, ignore_conflicts=True) UV.objects.bulk_create(uvs, ignore_conflicts=True)
def create_products(self): def create_products(self):
categories = [] categories = [
for _ in range(10): ProductType(name=self.faker.text(max_nb_chars=30)) for _ in range(10)
categories.append(ProductType(name=self.faker.text(max_nb_chars=30))) ]
ProductType.objects.bulk_create(categories) ProductType.objects.bulk_create(categories)
categories = list( categories = list(
ProductType.objects.filter(name__in=[c.name for c in categories]) ProductType.objects.filter(name__in=[c.name for c in categories])
@ -254,16 +254,16 @@ class Command(BaseCommand):
archived=bool(random.random() > 0.7), archived=bool(random.random() > 0.7),
) )
products.append(product) products.append(product)
for group in random.sample(groups, k=random.randint(0, 3)): # there will be products without buying groups
# there will be products without buying groups # but there are also such products in the real database
# but there are also such products in the real database buying_groups.extend(
buying_groups.append( Product.buying_groups.through(product=product, group=group)
Product.buying_groups.through(product=product, group=group) for group in random.sample(groups, k=random.randint(0, 3))
) )
for counter in random.sample(counters, random.randint(0, 4)): selling_places.extend(
selling_places.append( Counter.products.through(counter=counter, product=product)
Counter.products.through(counter=counter, product=product) for counter in random.sample(counters, random.randint(0, 4))
) )
Product.objects.bulk_create(products) Product.objects.bulk_create(products)
Product.buying_groups.through.objects.bulk_create(buying_groups) Product.buying_groups.through.objects.bulk_create(buying_groups)
Counter.products.through.objects.bulk_create(selling_places) 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. """Search for a group by its primary key or its name.
Either one of the two must be set. Either one of the two must be set.
@ -445,7 +445,7 @@ class User(AbstractBaseUser):
else: else:
return 0 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. """Check if this user is in the given group.
Either a group id or a group name must be provided. Either a group id or a group name must be provided.
If both are passed, only the id will be considered. If both are passed, only the id will be considered.
@ -649,7 +649,7 @@ class User(AbstractBaseUser):
continue continue
links = list(User.godfathers.through.objects.filter(**{key: self.id})) links = list(User.godfathers.through.objects.filter(**{key: self.id}))
res.extend(links) 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] ids = [getattr(c, reverse_key) for c in links]
links = list( links = list(
User.godfathers.through.objects.filter( User.godfathers.through.objects.filter(
@ -703,9 +703,7 @@ class User(AbstractBaseUser):
return True return True
if hasattr(obj, "owner_group") and self.is_in_group(pk=obj.owner_group.id): if hasattr(obj, "owner_group") and self.is_in_group(pk=obj.owner_group.id):
return True return True
if self.is_root: return self.is_root
return True
return False
def can_edit(self, obj): def can_edit(self, obj):
"""Determine if the object can be edited by the user.""" """Determine if the object can be edited by the user."""
@ -717,9 +715,7 @@ class User(AbstractBaseUser):
return True return True
if isinstance(obj, User) and obj == self: if isinstance(obj, User) and obj == self:
return True return True
if self.is_owner(obj): return self.is_owner(obj)
return True
return False
def can_view(self, obj): def can_view(self, obj):
"""Determine if the object can be viewed by the user.""" """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): for pk in obj.view_groups.values_list("pk", flat=True):
if self.is_in_group(pk=pk): if self.is_in_group(pk=pk):
return True return True
if self.can_edit(obj): return self.can_edit(obj)
return True
return False
def can_be_edited_by(self, user): def can_be_edited_by(self, user):
return user.is_root or user.is_board_member return user.is_root or user.is_board_member
@ -759,23 +753,17 @@ class User(AbstractBaseUser):
@cached_property @cached_property
def preferences(self): def preferences(self):
try: if hasattr(self, "_preferences"):
return self._preferences return self._preferences
except: return Preferences.objects.create(user=self)
prefs = Preferences(user=self)
prefs.save()
return prefs
@cached_property @cached_property
def forum_infos(self): def forum_infos(self):
try: if hasattr(self, "_forum_infos"):
return self._forum_infos return self._forum_infos
except: from forum.models import ForumUserInfo
from forum.models import ForumUserInfo
infos = ForumUserInfo(user=self) return ForumUserInfo.objects.create(user=self)
infos.save()
return infos
@cached_property @cached_property
def clubs_with_rights(self) -> list[Club]: def clubs_with_rights(self) -> list[Club]:
@ -840,7 +828,7 @@ class AnonymousUser(AuthAnonymousUser):
def favorite_topics(self): def favorite_topics(self):
raise PermissionDenied 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.""" """The anonymous user is only in the public group."""
allowed_id = settings.SITH_GROUP_PUBLIC_ID allowed_id = settings.SITH_GROUP_PUBLIC_ID
if pk is not None: if pk is not None:
@ -867,9 +855,7 @@ class AnonymousUser(AuthAnonymousUser):
and obj.view_groups.filter(id=settings.SITH_GROUP_PUBLIC_ID).exists() and obj.view_groups.filter(id=settings.SITH_GROUP_PUBLIC_ID).exists()
): ):
return True return True
if hasattr(obj, "can_be_viewed_by") and obj.can_be_viewed_by(self): return hasattr(obj, "can_be_viewed_by") and obj.can_be_viewed_by(self)
return True
return False
def get_display_name(self): def get_display_name(self):
return _("Visitor") return _("Visitor")
@ -1070,7 +1056,7 @@ class SithFile(models.Model):
]: ]:
self.file.delete() self.file.delete()
self.file = None self.file = None
except: except: # noqa E722 I don't know the exception that can be raised
self.file = None self.file = None
self.mime_type = "inode/directory" self.mime_type = "inode/directory"
if self.is_file and (self.file is None or self.file == ""): 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() return Album.objects.filter(id=self.id).first()
def get_parent_list(self): def get_parent_list(self):
l = [] parents = []
p = self.parent current = self.parent
while p is not None: while current is not None:
l.append(p) parents.append(current)
p = p.parent current = current.parent
return l return parents
def get_parent_path(self): def get_parent_path(self):
return "/" + "/".join([p.name for p in self.get_parent_list()[::-1]]) 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): if hasattr(self, "club") and self.club.can_be_edited_by(user):
# Override normal behavior for clubs # Override normal behavior for clubs
return True return True
if self.name == settings.SITH_CLUB_ROOT_PAGE and user.is_board_member: return self.name == settings.SITH_CLUB_ROOT_PAGE and user.is_board_member
return True
return False
def can_be_viewed_by(self, user): def can_be_viewed_by(self, user):
if self.is_club_page: return self.is_club_page
return True
return False
def get_parent_list(self): def get_parent_list(self):
l = [] parents = []
p = self.parent current = self.parent
while p is not None: while current is not None:
l.append(p) parents.append(current)
p = p.parent current = current.parent
return l return parents
def is_locked(self): def is_locked(self):
"""Is True if the page is locked, False otherwise. """Is True if the page is locked, False otherwise.
@ -1386,7 +1368,6 @@ class Page(models.Model):
if self.lock_timeout and ( if self.lock_timeout and (
timezone.now() - self.lock_timeout > timedelta(minutes=5) timezone.now() - self.lock_timeout > timedelta(minutes=5)
): ):
# print("Lock timed out")
self.unset_lock() self.unset_lock()
return ( return (
self.lock_user self.lock_user
@ -1401,7 +1382,6 @@ class Page(models.Model):
self.lock_user = user self.lock_user = user
self.lock_timeout = timezone.now() self.lock_timeout = timezone.now()
super().save() super().save()
# print("Locking page")
def set_lock_recursive(self, user): def set_lock_recursive(self, user):
"""Locks recursively all the child pages for editing properties.""" """Locks recursively all the child pages for editing properties."""
@ -1420,7 +1400,6 @@ class Page(models.Model):
self.lock_user = None self.lock_user = None
self.lock_timeout = None self.lock_timeout = None
super().save() super().save()
# print("Unlocking page")
def get_lock(self): def get_lock(self):
"""Returns the page's mutex containing the time and the user in a dict.""" """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: if self.parent is None:
return self.name 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): def get_display_name(self):
try: rev = self.revisions.last()
return self.revisions.last().title return rev.title if rev is not None else self.name
except:
return self.name
@cached_property @cached_property
def is_club_page(self): def is_club_page(self):

View File

@ -28,6 +28,7 @@ input[type="file"] {
font-size: 1.2em; font-size: 1.2em;
border-radius: 5px; border-radius: 5px;
color: black; color: black;
&:hover { &:hover {
background: hsl(0, 0%, 83%); background: hsl(0, 0%, 83%);
} }
@ -63,6 +64,7 @@ textarea[type="text"],
border-radius: 5px; border-radius: 5px;
max-width: 95%; max-width: 95%;
} }
textarea { textarea {
border: none; border: none;
text-decoration: none; text-decoration: none;
@ -72,6 +74,7 @@ textarea {
border-radius: 5px; border-radius: 5px;
font-family: sans-serif; font-family: sans-serif;
} }
select { select {
border: none; border: none;
text-decoration: none; text-decoration: none;
@ -85,9 +88,11 @@ select {
a:not(.button) { a:not(.button) {
text-decoration: none; text-decoration: none;
color: $primary-dark-color; color: $primary-dark-color;
&:hover { &:hover {
color: $primary-light-color; color: $primary-light-color;
} }
&:active { &:active {
color: $primary-color; color: $primary-color;
} }
@ -116,7 +121,9 @@ a:not(.button) {
} }
@keyframes rotate { @keyframes rotate {
100% { transform: rotate(360deg); } 100% {
transform: rotate(360deg);
}
} }
.ib { .ib {
@ -143,11 +150,13 @@ a:not(.button) {
.collapse-header-icon { .collapse-header-icon {
transition: all ease-in-out 150ms; transition: all ease-in-out 150ms;
&.reverse { &.reverse {
transform: rotate(180deg); transform: rotate(180deg);
} }
} }
} }
.collapse-body { .collapse-body {
padding: 10px; padding: 10px;
} }
@ -202,9 +211,11 @@ a:not(.button) {
font-size: 0.9em; font-size: 0.9em;
margin: 0.2em; margin: 0.2em;
border-radius: 0.6em; border-radius: 0.6em;
.markdown { .markdown {
margin: 0.5em; margin: 0.5em;
} }
&:before { &:before {
font-family: FontAwesome; font-family: FontAwesome;
font-size: 4em; font-size: 4em;
@ -212,15 +223,19 @@ a:not(.button) {
margin: 0.2em; margin: 0.2em;
} }
} }
#info_box { #info_box {
background: $primary-neutral-light-color; background: $primary-neutral-light-color;
&:before { &:before {
content: "\f05a"; content: "\f05a";
color: hsl(210, 100%, 56%); color: hsl(210, 100%, 56%);
} }
} }
#alert_box { #alert_box {
background: $second-color; background: $second-color;
&:before { &:before {
content: "\f06a"; content: "\f06a";
color: $white-color; color: $white-color;
@ -240,7 +255,8 @@ a:not(.button) {
#page { #page {
width: 90%; width: 90%;
margin: 20px auto 0; margin: 20px auto 0;
/*---------------------------------NAV---------------------------------*/
/*---------------------------------NAV---------------------------------*/
.btn { .btn {
font-size: 15px; font-size: 15px;
font-weight: normal; font-weight: normal;
@ -252,9 +268,11 @@ a:not(.button) {
&.btn-blue { &.btn-blue {
background-color: $deepblue; background-color: $deepblue;
&:not(:disabled):hover { &:not(:disabled):hover {
background-color: darken($deepblue, 10%); background-color: darken($deepblue, 10%);
} }
&:disabled { &:disabled {
background-color: rgba(70, 90, 126, 0.4); background-color: rgba(70, 90, 126, 0.4);
} }
@ -262,9 +280,11 @@ a:not(.button) {
&.btn-grey { &.btn-grey {
background-color: grey; background-color: grey;
&:not(:disabled):hover { &:not(:disabled):hover {
background-color: darken(gray, 15%); background-color: darken(gray, 15%);
} }
&:disabled { &:disabled {
background-color: lighten(gray, 15%); background-color: lighten(gray, 15%);
} }
@ -273,9 +293,11 @@ a:not(.button) {
&.btn-red { &.btn-red {
background-color: #fc8181; background-color: #fc8181;
color: black; color: black;
&:not(:disabled):hover { &:not(:disabled):hover {
background-color: darken(#fc8181, 15%); background-color: darken(#fc8181, 15%);
} }
&:disabled { &:disabled {
background-color: lighten(#fc8181, 15%); background-color: lighten(#fc8181, 15%);
color: grey; color: grey;
@ -287,12 +309,13 @@ a:not(.button) {
} }
} }
/*--------------------------------CONTENT------------------------------*/ /*--------------------------------CONTENT------------------------------*/
#quick_notif { #quick_notif {
width: 100%; width: 100%;
margin: 0 auto; margin: 0 auto;
list-style-type: none; list-style-type: none;
background: $second-color; background: $second-color;
li { li {
padding: 10px; padding: 10px;
} }
@ -333,14 +356,24 @@ a:not(.button) {
border: #fc8181 1px solid; border: #fc8181 1px solid;
} }
.alert-title {
margin-top: 0;
}
.alert-main { .alert-main {
flex: 2; flex: 2;
} }
.alert-aside {
display: flex;
flex-direction: column;
}
} }
.tool_bar { .tool_bar {
overflow: auto; overflow: auto;
padding: 4px; padding: 4px;
.tools { .tools {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
@ -349,6 +382,7 @@ a:not(.button) {
padding: 5px; padding: 5px;
border-radius: 6px; border-radius: 6px;
text-align: center; text-align: center;
a { a {
padding: 7px; padding: 7px;
display: inline-block; display: inline-block;
@ -358,11 +392,13 @@ a:not(.button) {
flex: 1; flex: 1;
flex-wrap: nowrap; flex-wrap: nowrap;
white-space: nowrap; white-space: nowrap;
&.selected_tab { &.selected_tab {
background: $primary-color; background: $primary-color;
color: $white-color; color: $white-color;
border-radius: 6px; border-radius: 6px;
} }
&:hover { &:hover {
background: $primary-color; background: $primary-color;
color: $white-color; color: $white-color;
@ -372,7 +408,7 @@ a:not(.button) {
} }
} }
/*---------------------------------NEWS--------------------------------*/ /*---------------------------------NEWS--------------------------------*/
#news { #news {
display: flex; display: flex;
@ -385,17 +421,21 @@ a:not(.button) {
margin: 0; margin: 0;
vertical-align: top; vertical-align: top;
} }
#news_admin { #news_admin {
margin-bottom: 1em; margin-bottom: 1em;
} }
#right_column { #right_column {
flex: 20%; flex: 20%;
float: right; float: right;
margin: 0.2em; margin: 0.2em;
} }
#left_column { #left_column {
flex: 79%; flex: 79%;
margin: 0.2em; margin: 0.2em;
h3 { h3 {
background: $second-color; background: $second-color;
box-shadow: $shadow-color 1px 1px 1px; box-shadow: $shadow-color 1px 1px 1px;
@ -403,19 +443,22 @@ a:not(.button) {
margin: 0 0 0.5em 0; margin: 0 0 0.5em 0;
text-transform: uppercase; text-transform: uppercase;
font-size: 1.1em; font-size: 1.1em;
&:not(:first-of-type) { &:not(:first-of-type) {
margin: 2em 0 1em 0; margin: 2em 0 1em 0;
} }
} }
} }
@media screen and (max-width: $small-devices) { @media screen and (max-width: $small-devices) {
#left_column, #left_column,
#right_column { #right_column {
flex: 100%; flex: 100%;
} }
} }
/* AGENDA/BIRTHDAYS */ /* AGENDA/BIRTHDAYS */
#agenda, #agenda,
#birthdays { #birthdays {
display: block; display: block;
@ -423,6 +466,7 @@ a:not(.button) {
background: white; background: white;
font-size: 70%; font-size: 70%;
margin-bottom: 1em; margin-bottom: 1em;
#agenda_title, #agenda_title,
#birthdays_title { #birthdays_title {
margin: 0; margin: 0;
@ -435,39 +479,48 @@ a:not(.button) {
text-transform: uppercase; text-transform: uppercase;
background: $second-color; background: $second-color;
} }
#agenda_content { #agenda_content {
overflow: auto; overflow: auto;
box-shadow: $shadow-color 1px 1px 1px; box-shadow: $shadow-color 1px 1px 1px;
height: 20em; height: 20em;
} }
#agenda_content, #agenda_content,
#birthdays_content { #birthdays_content {
.agenda_item { .agenda_item {
padding: 0.5em; padding: 0.5em;
margin-bottom: 0.5em; margin-bottom: 0.5em;
&:nth-of-type(even) { &:nth-of-type(even) {
background: $secondary-neutral-light-color; background: $secondary-neutral-light-color;
} }
.agenda_time { .agenda_time {
font-size: 90%; font-size: 90%;
color: grey; color: grey;
} }
.agenda_item_content { .agenda_item_content {
p { p {
margin-top: 0.2em; margin-top: 0.2em;
} }
} }
} }
ul.birthdays_year { ul.birthdays_year {
margin: 0; margin: 0;
list-style-type: none; list-style-type: none;
font-weight: bold; font-weight: bold;
> li {
>li {
padding: 0.5em; padding: 0.5em;
&:nth-child(even) { &:nth-child(even) {
background: $secondary-neutral-light-color; background: $secondary-neutral-light-color;
} }
} }
ul { ul {
margin: 0; margin: 0;
margin-left: 1em; 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 { .news_events_group {
box-shadow: $shadow-color 1px 1px 1px; box-shadow: $shadow-color 1px 1px 1px;
margin-left: 1em; margin-left: 1em;
margin-bottom: 0.5em; margin-bottom: 0.5em;
.news_events_group_date { .news_events_group_date {
display: table-cell; display: table-cell;
padding: 0.6em; padding: 0.6em;
@ -500,33 +555,42 @@ a:not(.button) {
div { div {
margin: 0 auto; margin: 0 auto;
.day { .day {
font-size: 1.5em; font-size: 1.5em;
} }
} }
} }
.news_events_group_items { .news_events_group_items {
display: table-cell; display: table-cell;
width: 100%; width: 100%;
.news_event:nth-of-type(odd) { .news_event:nth-of-type(odd) {
background: white; background: white;
} }
.news_event:nth-of-type(even) { .news_event:nth-of-type(even) {
background: $primary-neutral-light-color; background: $primary-neutral-light-color;
} }
.news_event { .news_event {
display: block; display: block;
padding: 0.4em; padding: 0.4em;
&:not(:last-child) { &:not(:last-child) {
border-bottom: 1px solid grey; border-bottom: 1px solid grey;
} }
div { div {
margin: 0.2em; margin: 0.2em;
} }
h4 { h4 {
margin-top: 1em; margin-top: 1em;
text-transform: uppercase; text-transform: uppercase;
} }
.club_logo { .club_logo {
float: left; float: left;
min-width: 7em; min-width: 7em;
@ -534,6 +598,7 @@ a:not(.button) {
margin: 0; margin: 0;
margin-right: 1em; margin-right: 1em;
margin-top: 0.8em; margin-top: 0.8em;
img { img {
max-height: 6em; max-height: 6em;
max-width: 8em; max-width: 8em;
@ -541,16 +606,21 @@ a:not(.button) {
margin: 0 auto; margin: 0 auto;
} }
} }
.news_date { .news_date {
font-size: 100%; font-size: 100%;
} }
.news_content { .news_content {
clear: left; clear: left;
.button_bar { .button_bar {
text-align: right; text-align: right;
.fb { .fb {
color: $faceblue; color: $faceblue;
} }
.twitter { .twitter {
color: $twitblue; 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 { .news_coming_soon {
display: list-item; display: list-item;
list-style-type: square; list-style-type: square;
list-style-position: inside; list-style-position: inside;
margin-left: 1em; margin-left: 1em;
padding-left: 0; padding-left: 0;
a { a {
font-weight: bold; font-weight: bold;
text-transform: uppercase; text-transform: uppercase;
} }
.news_date { .news_date {
font-size: 0.9em; font-size: 0.9em;
} }
} }
/* END COMING SOON */
/* NOTICES */ /* END COMING SOON */
/* NOTICES */
.news_notice { .news_notice {
margin: 0 0 1em 1em; margin: 0 0 1em 1em;
padding: 0.4em; padding: 0.4em;
@ -586,16 +660,19 @@ a:not(.button) {
background: $secondary-neutral-light-color; background: $secondary-neutral-light-color;
box-shadow: $shadow-color 0 0 2px; box-shadow: $shadow-color 0 0 2px;
border-radius: 18px 5px 18px 5px; border-radius: 18px 5px 18px 5px;
h4 { h4 {
margin: 0; margin: 0;
} }
.news_content { .news_content {
margin-left: 1em; margin-left: 1em;
} }
} }
/* END NOTICES */
/* CALLS */ /* END NOTICES */
/* CALLS */
.news_call { .news_call {
margin: 0 0 1em 1em; margin: 0 0 1em 1em;
padding: 0.4em; padding: 0.4em;
@ -603,21 +680,26 @@ a:not(.button) {
background: $secondary-neutral-light-color; background: $secondary-neutral-light-color;
border: 1px solid grey; border: 1px solid grey;
box-shadow: $shadow-color 1px 1px 1px; box-shadow: $shadow-color 1px 1px 1px;
h4 { h4 {
margin: 0; margin: 0;
} }
.news_date { .news_date {
font-size: 0.9em; font-size: 0.9em;
} }
.news_content { .news_content {
margin-left: 1em; margin-left: 1em;
} }
} }
/* END CALLS */
/* END CALLS */
.news_empty { .news_empty {
margin-left: 1em; margin-left: 1em;
} }
.news_date { .news_date {
color: grey; color: grey;
} }
@ -631,8 +713,8 @@ a:not(.button) {
} }
.select2 { .tomselected {
margin: 10px 0!important; margin: 10px 0 !important;
max-width: 100%; max-width: 100%;
min-width: 100%; min-width: 100%;
@ -648,7 +730,9 @@ a:not(.button) {
color: black; color: black;
} }
} }
.select2-results {
.ts-dropdown {
.select-item { .select-item {
display: flex; display: flex;
flex-direction: row; 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 { #news_details {
display: inline-block; display: inline-block;
margin-top: 20px; margin-top: 20px;
padding: 0.4em; padding: 0.4em;
width: 80%; width: 80%;
background: $white-color; background: $white-color;
h4 { h4 {
margin-top: 1em; margin-top: 1em;
text-transform: uppercase; text-transform: uppercase;
} }
.club_logo { .club_logo {
display: inline-block; display: inline-block;
text-align: center; text-align: center;
@ -681,6 +788,7 @@ a:not(.button) {
float: left; float: left;
min-width: 15em; min-width: 15em;
margin: 0; margin: 0;
img { img {
max-height: 15em; max-height: 15em;
max-width: 12em; max-width: 12em;
@ -689,6 +797,7 @@ a:not(.button) {
margin-bottom: 10px; margin-bottom: 10px;
} }
} }
.share_button { .share_button {
border: none; border: none;
color: white; color: white;
@ -700,6 +809,7 @@ a:not(.button) {
float: right; float: right;
display: block; display: block;
margin-left: 0.3em; margin-left: 0.3em;
&:hover { &:hover {
color: lightgrey; color: lightgrey;
} }
@ -731,26 +841,32 @@ a:not(.button) {
#poster_edit, #poster_edit,
#screen_edit { #screen_edit {
position: relative; position: relative;
#title { #title {
position: relative; position: relative;
padding: 10px; padding: 10px;
margin: 10px; margin: 10px;
border-bottom: 2px solid black; border-bottom: 2px solid black;
h3 { h3 {
display: flex; display: flex;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
} }
#links { #links {
position: absolute; position: absolute;
display: flex; display: flex;
bottom: 5px; bottom: 5px;
&.left { &.left {
left: 0; left: 0;
} }
&.right { &.right {
right: 0; right: 0;
} }
.link { .link {
padding: 5px; padding: 5px;
padding-left: 20px; padding-left: 20px;
@ -759,27 +875,32 @@ a:not(.button) {
border-radius: 20px; border-radius: 20px;
background-color: hsl(40, 100%, 50%); background-color: hsl(40, 100%, 50%);
color: black; color: black;
&:hover { &:hover {
color: black; color: black;
background-color: hsl(40, 58%, 50%); background-color: hsl(40, 58%, 50%);
} }
&.delete { &.delete {
background-color: hsl(0, 100%, 40%); background-color: hsl(0, 100%, 40%);
} }
} }
} }
} }
#posters, #posters,
#screens { #screens {
position: relative; position: relative;
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
#no-posters, #no-posters,
#no-screens { #no-screens {
display: flex; display: flex;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
} }
.poster, .poster,
.screen { .screen {
min-width: 10%; min-width: 10%;
@ -791,26 +912,31 @@ a:not(.button) {
border-radius: 4px; border-radius: 4px;
padding: 10px; padding: 10px;
background-color: lightgrey; background-color: lightgrey;
* { * {
display: flex; display: flex;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
} }
.name { .name {
padding-bottom: 5px; padding-bottom: 5px;
margin-bottom: 5px; margin-bottom: 5px;
border-bottom: 1px solid whitesmoke; border-bottom: 1px solid whitesmoke;
} }
.image { .image {
flex-grow: 1; flex-grow: 1;
position: relative; position: relative;
padding-bottom: 5px; padding-bottom: 5px;
margin-bottom: 5px; margin-bottom: 5px;
border-bottom: 1px solid whitesmoke; border-bottom: 1px solid whitesmoke;
img { img {
max-height: 20vw; max-height: 20vw;
max-width: 100%; max-width: 100%;
} }
&:hover { &:hover {
&::before { &::before {
position: absolute; position: absolute;
@ -829,10 +955,12 @@ a:not(.button) {
} }
} }
} }
.dates { .dates {
padding-bottom: 5px; padding-bottom: 5px;
margin-bottom: 5px; margin-bottom: 5px;
border-bottom: 1px solid whitesmoke; border-bottom: 1px solid whitesmoke;
* { * {
display: flex; display: flex;
justify-content: center; justify-content: center;
@ -841,15 +969,18 @@ a:not(.button) {
margin-left: 5px; margin-left: 5px;
margin-right: 5px; margin-right: 5px;
} }
.begin, .begin,
.end { .end {
width: 48%; width: 48%;
} }
.begin { .begin {
border-right: 1px solid whitesmoke; border-right: 1px solid whitesmoke;
padding-right: 2%; padding-right: 2%;
} }
} }
.edit, .edit,
.moderate, .moderate,
.slideshow { .slideshow {
@ -857,15 +988,18 @@ a:not(.button) {
border-radius: 20px; border-radius: 20px;
background-color: hsl(40, 100%, 50%); background-color: hsl(40, 100%, 50%);
color: black; color: black;
&:hover { &:hover {
color: black; color: black;
background-color: hsl(40, 58%, 50%); background-color: hsl(40, 58%, 50%);
} }
&:nth-child(2n) { &:nth-child(2n) {
margin-top: 5px; margin-top: 5px;
margin-bottom: 5px; margin-bottom: 5px;
} }
} }
.tooltip { .tooltip {
visibility: hidden; visibility: hidden;
width: 120px; width: 120px;
@ -876,23 +1010,28 @@ a:not(.button) {
border-radius: 6px; border-radius: 6px;
position: absolute; position: absolute;
z-index: 10; z-index: 10;
ul { ul {
margin-left: 0; margin-left: 0;
display: inline-block; display: inline-block;
li { li {
display: list-item; display: list-item;
list-style-type: none; list-style-type: none;
} }
} }
} }
&.not_moderated { &.not_moderated {
border: 1px solid red; border: 1px solid red;
} }
&:hover .tooltip { &:hover .tooltip {
visibility: visible; visibility: visible;
} }
} }
} }
#view { #view {
position: fixed; position: fixed;
width: 100vw; width: 100vw;
@ -906,9 +1045,11 @@ a:not(.button) {
visibility: hidden; visibility: hidden;
background-color: rgba(10, 10, 10, 0.9); background-color: rgba(10, 10, 10, 0.9);
overflow: hidden; overflow: hidden;
&.active { &.active {
visibility: visible; visibility: visible;
} }
#placeholder { #placeholder {
width: 80vw; width: 80vw;
height: 80vh; height: 80vh;
@ -917,6 +1058,7 @@ a:not(.button) {
align-items: center; align-items: center;
top: 0; top: 0;
left: 0; left: 0;
img { img {
max-width: 100%; max-width: 100%;
max-height: 100%; max-height: 100%;
@ -931,14 +1073,17 @@ a:not(.button) {
tbody { tbody {
.neg-amount { .neg-amount {
color: red; color: red;
&:before { &:before {
font-family: FontAwesome; font-family: FontAwesome;
font-size: 1em; font-size: 1em;
content: "\f063"; content: "\f063";
} }
} }
.pos-amount { .pos-amount {
color: green; color: green;
&:before { &:before {
font-family: FontAwesome; font-family: FontAwesome;
font-size: 1em; font-size: 1em;
@ -1005,6 +1150,7 @@ dt {
.edit-bar { .edit-bar {
display: block; display: block;
margin: 4px; margin: 4px;
a { a {
display: inline-block; display: inline-block;
margin: 4px; margin: 4px;
@ -1044,7 +1190,8 @@ th {
vertical-align: middle; vertical-align: middle;
text-align: center; text-align: center;
padding: 5px 10px; padding: 5px 10px;
> ul {
>ul {
margin-top: 0; margin-top: 0;
} }
} }
@ -1055,7 +1202,8 @@ td {
vertical-align: top; vertical-align: top;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
> ul {
>ul {
margin-top: 0; margin-top: 0;
} }
} }
@ -1071,15 +1219,17 @@ thead {
color: white; color: white;
} }
tbody > tr { tbody>tr {
&:nth-child(even):not(.highlight) { &:nth-child(even):not(.highlight) {
background: $primary-neutral-light-color; background: $primary-neutral-light-color;
} }
&.clickable:hover { &.clickable:hover {
cursor: pointer; cursor: pointer;
background: $secondary-neutral-light-color; background: $secondary-neutral-light-color;
width: 100%; width: 100%;
} }
&.highlight { &.highlight {
color: $primary-dark-color; color: $primary-dark-color;
font-style: italic; font-style: italic;
@ -1139,9 +1289,11 @@ u,
margin: 0.2em; margin: 0.2em;
height: 100%; height: 100%;
background: $secondary-neutral-light-color; background: $secondary-neutral-light-color;
img { img {
max-width: 70%; max-width: 70%;
} }
input { input {
background: white; background: white;
} }
@ -1153,10 +1305,12 @@ u,
.user_mini_profile { .user_mini_profile {
height: 100%; height: 100%;
width: 100%; width: 100%;
img { img {
max-width: 100%; max-width: 100%;
max-height: 100%; max-height: 100%;
} }
.user_mini_profile_infos { .user_mini_profile_infos {
padding: 0.2em; padding: 0.2em;
height: 20%; height: 20%;
@ -1164,16 +1318,20 @@ u,
flex-wrap: nowrap; flex-wrap: nowrap;
justify-content: space-around; justify-content: space-around;
font-size: 0.9em; font-size: 0.9em;
div { div {
max-height: 100%; max-height: 100%;
} }
.user_mini_profile_infos_text { .user_mini_profile_infos_text {
text-align: center; text-align: center;
.user_mini_profile_nick { .user_mini_profile_nick {
font-style: italic; font-style: italic;
} }
} }
} }
.user_mini_profile_picture { .user_mini_profile_picture {
height: 80%; height: 80%;
display: flex; display: flex;
@ -1185,14 +1343,17 @@ u,
.mini_profile_link { .mini_profile_link {
display: block; display: block;
text-decoration: none; text-decoration: none;
span { span {
display: inline-block; display: inline-block;
width: 50px; width: 50px;
vertical-align: middle; vertical-align: middle;
} }
em { em {
vertical-align: middle; vertical-align: middle;
} }
img { img {
max-width: 40px; max-width: 40px;
max-height: 60px; max-height: 60px;
@ -1214,6 +1375,7 @@ u,
border: solid 1px red; border: solid 1px red;
text-align: center; text-align: center;
} }
img { img {
width: 500px; width: 500px;
} }
@ -1223,6 +1385,7 @@ u,
.matmat_results { .matmat_results {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
.matmat_user { .matmat_user {
flex-basis: 14em; flex-basis: 14em;
align-self: flex-start; align-self: flex-start;
@ -1231,10 +1394,12 @@ u,
overflow: hidden; overflow: hidden;
border: 1px solid black; border: 1px solid black;
box-shadow: $shadow-color 1px 1px 1px; box-shadow: $shadow-color 1px 1px 1px;
&:hover { &:hover {
box-shadow: 1px 1px 5px $second-color; box-shadow: 1px 1px 5px $second-color;
} }
} }
.matmat_user a { .matmat_user a {
color: $primary-neutral-dark-color; color: $primary-neutral-dark-color;
height: 100%; height: 100%;
@ -1274,6 +1439,7 @@ footer {
font-size: 90%; font-size: 90%;
text-align: center; text-align: center;
vertical-align: middle; vertical-align: middle;
div { div {
margin: 0.6em 0; margin: 0.6em 0;
color: $white-color; color: $white-color;
@ -1283,18 +1449,20 @@ footer {
align-items: center; align-items: center;
background-color: $primary-neutral-dark-color; background-color: $primary-neutral-dark-color;
box-shadow: $shadow-color 0 0 15px; box-shadow: $shadow-color 0 0 15px;
a { a {
padding: 0.8em; padding: 0.8em;
flex: 1; flex: 1;
font-weight: bold; font-weight: bold;
color: $white-color !important; color: $white-color !important;
&:hover { &:hover {
color: $primary-dark-color; color: $primary-dark-color;
} }
} }
} }
> .version { >.version {
margin-top: 3px; margin-top: 3px;
color: rgba(0, 0, 0, 0.3); color: rgba(0, 0, 0, 0.3);
} }
@ -1326,6 +1494,7 @@ label {
* { * {
text-align: center; text-align: center;
} }
img { img {
width: 100px; width: 100px;
} }
@ -1342,19 +1511,23 @@ label {
padding: 2px; padding: 2px;
display: inline-block; display: inline-block;
font-size: 0.8em; font-size: 0.8em;
span { span {
width: 70px; width: 70px;
float: right; float: right;
} }
img { img {
max-width: 50px; max-width: 50px;
max-height: 50px; max-height: 50px;
float: left; float: left;
} }
strong { strong {
font-weight: bold; font-weight: bold;
font-size: 1.2em; font-size: 1.2em;
} }
button { button {
vertical-align: middle; vertical-align: middle;
} }
@ -1371,6 +1544,7 @@ a.ui-button:active,
background: $primary-color; background: $primary-color;
border-color: $primary-color; border-color: $primary-color;
} }
.ui-corner-all, .ui-corner-all,
.ui-corner-bottom, .ui-corner-bottom,
.ui-corner-right, .ui-corner-right,
@ -1382,6 +1556,7 @@ a.ui-button:active,
#club_detail { #club_detail {
.club_logo { .club_logo {
float: right; float: right;
img { img {
display: block; display: block;
max-height: 10em; max-height: 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 // biome-ignore lint/correctness/noUndeclaredDependencies: shipped by easymde
import "codemirror/lib/codemirror.css"; import "codemirror/lib/codemirror.css";
import "easymde/src/css/easymde.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"; import { markdownRenderMarkdown } from "#openapi";
/** const loadEasyMde = (textarea: HTMLTextAreaElement) => {
* Create a new easymde based textarea new EasyMDE({
* @param {HTMLTextAreaElement} textarea to use
**/
window.easymdeFactory = (textarea) => {
const easymde = new easyMde({
element: textarea, element: textarea,
spellChecker: false, spellChecker: false,
autoDownloadFontAwesome: false, autoDownloadFontAwesome: false,
previewRender: Alpine.debounce(async (plainText, preview) => { previewRender: Alpine.debounce((plainText: string, preview: MarkdownInput) => {
preview.innerHTML = ( const func = async (plainText: string, preview: MarkdownInput): Promise<null> => {
await markdownRenderMarkdown({ body: { text: plainText } }) preview.innerHTML = (
).data; await markdownRenderMarkdown({ body: { text: plainText } })
).data as string;
return null;
};
func(plainText, preview);
return null; return null;
}, 300), }, 300),
forceSync: true, // Avoid validation error on generic create view forceSync: true, // Avoid validation error on generic create view
toolbar: [ toolbar: [
{ {
name: "heading-smaller", name: "heading-smaller",
action: easyMde.toggleHeadingSmaller, action: EasyMDE.toggleHeadingSmaller,
className: "fa fa-header", className: "fa fa-header",
title: gettext("Heading"), title: gettext("Heading"),
}, },
{ {
name: "italic", name: "italic",
action: easyMde.toggleItalic, action: EasyMDE.toggleItalic,
className: "fa fa-italic", className: "fa fa-italic",
title: gettext("Italic"), title: gettext("Italic"),
}, },
{ {
name: "bold", name: "bold",
action: easyMde.toggleBold, action: EasyMDE.toggleBold,
className: "fa fa-bold", className: "fa fa-bold",
title: gettext("Bold"), title: gettext("Bold"),
}, },
{ {
name: "strikethrough", name: "strikethrough",
action: easyMde.toggleStrikethrough, action: EasyMDE.toggleStrikethrough,
className: "fa fa-strikethrough", className: "fa fa-strikethrough",
title: gettext("Strikethrough"), title: gettext("Strikethrough"),
}, },
{ {
name: "underline", name: "underline",
action: function customFunction(editor) { action: function customFunction(editor: { codemirror: CodeMirror.Editor }) {
const cm = editor.codemirror; const cm = editor.codemirror;
cm.replaceSelection(`__${cm.getSelection()}__`); cm.replaceSelection(`__${cm.getSelection()}__`);
}, },
@ -56,7 +60,7 @@ window.easymdeFactory = (textarea) => {
}, },
{ {
name: "superscript", name: "superscript",
action: function customFunction(editor) { action: function customFunction(editor: { codemirror: CodeMirror.Editor }) {
const cm = editor.codemirror; const cm = editor.codemirror;
cm.replaceSelection(`^${cm.getSelection()}^`); cm.replaceSelection(`^${cm.getSelection()}^`);
}, },
@ -65,7 +69,7 @@ window.easymdeFactory = (textarea) => {
}, },
{ {
name: "subscript", name: "subscript",
action: function customFunction(editor) { action: function customFunction(editor: { codemirror: CodeMirror.Editor }) {
const cm = editor.codemirror; const cm = editor.codemirror;
cm.replaceSelection(`~${cm.getSelection()}~`); cm.replaceSelection(`~${cm.getSelection()}~`);
}, },
@ -74,71 +78,71 @@ window.easymdeFactory = (textarea) => {
}, },
{ {
name: "code", name: "code",
action: easyMde.toggleCodeBlock, action: EasyMDE.toggleCodeBlock,
className: "fa fa-code", className: "fa fa-code",
title: gettext("Code"), title: gettext("Code"),
}, },
"|", "|",
{ {
name: "quote", name: "quote",
action: easyMde.toggleBlockquote, action: EasyMDE.toggleBlockquote,
className: "fa fa-quote-left", className: "fa fa-quote-left",
title: gettext("Quote"), title: gettext("Quote"),
}, },
{ {
name: "unordered-list", name: "unordered-list",
action: easyMde.toggleUnorderedList, action: EasyMDE.toggleUnorderedList,
className: "fa fa-list-ul", className: "fa fa-list-ul",
title: gettext("Unordered list"), title: gettext("Unordered list"),
}, },
{ {
name: "ordered-list", name: "ordered-list",
action: easyMde.toggleOrderedList, action: EasyMDE.toggleOrderedList,
className: "fa fa-list-ol", className: "fa fa-list-ol",
title: gettext("Ordered list"), title: gettext("Ordered list"),
}, },
"|", "|",
{ {
name: "link", name: "link",
action: easyMde.drawLink, action: EasyMDE.drawLink,
className: "fa fa-link", className: "fa fa-link",
title: gettext("Insert link"), title: gettext("Insert link"),
}, },
{ {
name: "image", name: "image",
action: easyMde.drawImage, action: EasyMDE.drawImage,
className: "fa-regular fa-image", className: "fa-regular fa-image",
title: gettext("Insert image"), title: gettext("Insert image"),
}, },
{ {
name: "table", name: "table",
action: easyMde.drawTable, action: EasyMDE.drawTable,
className: "fa fa-table", className: "fa fa-table",
title: gettext("Insert table"), title: gettext("Insert table"),
}, },
"|", "|",
{ {
name: "clean-block", name: "clean-block",
action: easyMde.cleanBlock, action: EasyMDE.cleanBlock,
className: "fa fa-eraser fa-clean-block", className: "fa fa-eraser fa-clean-block",
title: gettext("Clean block"), title: gettext("Clean block"),
}, },
"|", "|",
{ {
name: "preview", name: "preview",
action: easyMde.togglePreview, action: EasyMDE.togglePreview,
className: "fa fa-eye no-disable", className: "fa fa-eye no-disable",
title: gettext("Toggle preview"), title: gettext("Toggle preview"),
}, },
{ {
name: "side-by-side", name: "side-by-side",
action: easyMde.toggleSideBySide, action: EasyMDE.toggleSideBySide,
className: "fa fa-columns no-disable no-mobile", className: "fa fa-columns no-disable no-mobile",
title: gettext("Toggle side by side"), title: gettext("Toggle side by side"),
}, },
{ {
name: "fullscreen", name: "fullscreen",
action: easyMde.toggleFullScreen, action: EasyMDE.toggleFullScreen,
className: "fa fa-expand no-mobile", className: "fa fa-expand no-mobile",
title: gettext("Toggle fullscreen"), title: gettext("Toggle fullscreen"),
}, },
@ -152,27 +156,25 @@ window.easymdeFactory = (textarea) => {
], ],
}); });
const submits = textarea.closest("form").querySelectorAll('input[type="submit"]'); const submits: HTMLInputElement[] = Array.from(
const parentDiv = textarea.parentElement; textarea.closest("form").querySelectorAll('input[type="submit"]'),
let submitPressed = false; );
const parentDiv = textarea.parentElement.parentElement;
function checkMarkdownInput() { function checkMarkdownInput(event: Event) {
// an attribute is null if it does not exist, else a string // an attribute is null if it does not exist, else a string
const required = textarea.getAttribute("required") != null; const required = textarea.getAttribute("required") != null;
const length = textarea.value.trim().length; const length = textarea.value.trim().length;
if (required && length === 0) { if (required && length === 0) {
parentDiv.style.boxShadow = "red 0px 0px 1.5px 1px"; parentDiv.style.boxShadow = "red 0px 0px 1.5px 1px";
event.preventDefault();
} else { } else {
parentDiv.style.boxShadow = ""; parentDiv.style.boxShadow = "";
} }
} }
function onSubmitClick(e) { function onSubmitClick(e: Event) {
if (!submitPressed) {
easymde.codemirror.on("change", checkMarkdownInput);
}
submitPressed = true;
checkMarkdownInput(e); checkMarkdownInput(e);
} }
@ -180,3 +182,11 @@ window.easymdeFactory = (textarea) => {
submit.addEventListener("click", onSubmitClick); 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"); 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 * Simple wrapper to solve shorten not being able on legacy pages
* @param {string} selector to be passed to jQuery * @param {string} selector to be passed to jQuery

View File

@ -3,6 +3,7 @@ import type { Alpine as AlpineType } from "alpinejs";
declare global { declare global {
const Alpine: AlpineType; const Alpine: AlpineType;
const gettext: (text: string) => string; 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> <title>{% block title %}{% trans %}Welcome!{% endtrans %}{% endblock %} - Association des Étudiants UTBM</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="shortcut icon" href="{{ static('core/img/favicon.ico') }}"> <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('core/base.css') }}">
<link rel="stylesheet" href="{{ static('ajax_select/css/ajax_select.css') }}"> <link rel="stylesheet" href="{{ static('ajax_select/css/ajax_select.css') }}">
<link rel="stylesheet" href="{{ static('core/style.scss') }}"> <link rel="stylesheet" href="{{ static('core/style.scss') }}">

View File

@ -1,13 +1,7 @@
<div> <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 #} {# The easymde script can be included twice, it's safe in the code #}
<script src="{{ statics.js }}" defer> </script> <script src="{{ statics.js }}" defer> </script>
<link rel="stylesheet" type="text/css" href="{{ statics.css }}" defer> <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> </div>

View File

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

View File

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

View File

@ -73,7 +73,7 @@ class TestFetchFamilyApi(TestCase):
self.client.force_login(self.main_user) self.client.force_login(self.main_user)
response = self.client.get( response = self.client.get(
reverse("api:family_graph", args=[self.main_user.id]) 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 response.status_code == 200
assert [u["id"] for u in response.json()["users"]] == [self.main_user.id] 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) self.client.force_login(self.main_user)
response = self.client.get( response = self.client.get(
reverse("api:family_graph", args=[self.main_user.id]) 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 response.status_code == 200
assert [u["id"] for u in response.json()["users"]] == [ assert [u["id"] for u in response.json()["users"]] == [
@ -126,7 +126,7 @@ class TestFetchFamilyApi(TestCase):
self.client.force_login(self.main_user) self.client.force_login(self.main_user)
response = self.client.get( response = self.client.get(
reverse("api:family_graph", args=[self.main_user.id]) 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 response.status_code == 200
assert [u["id"] for u in response.json()["users"]] == [ assert [u["id"] for u in response.json()["users"]] == [
@ -150,7 +150,7 @@ class TestFetchFamilyApi(TestCase):
self.client.force_login(self.main_user) self.client.force_login(self.main_user)
response = self.client.get( response = self.client.get(
reverse("api:family_graph", args=[self.main_user.id]) 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 response.status_code == 200
assert [u["id"] for u in response.json()["users"]] == [ assert [u["id"] for u in response.json()["users"]] == [

View File

@ -29,13 +29,67 @@ from core.converters import (
FourDigitYearConverter, FourDigitYearConverter,
TwoDigitMonthConverter, 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(FourDigitYearConverter, "yyyy")
register_converter(TwoDigitMonthConverter, "mm") register_converter(TwoDigitMonthConverter, "mm")
register_converter(BooleanStringConverter, "bool") register_converter(BooleanStringConverter, "bool")
urlpatterns = [ urlpatterns = [
path("", index, name="index"), path("", index, name="index"),
path("notifications/", NotificationList.as_view(), name="notification_list"), path("notifications/", NotificationList.as_view(), name="notification_list"),
@ -80,27 +134,17 @@ urlpatterns = [
path("group/new/", GroupCreateView.as_view(), name="group_new"), 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>/", GroupEditView.as_view(), name="group_edit"),
path( path(
"group/<int:group_id>/delete/", "group/<int:group_id>/delete/", GroupDeleteView.as_view(), name="group_delete"
GroupDeleteView.as_view(),
name="group_delete",
), ),
path( path(
"group/<int:group_id>/detail/", "group/<int:group_id>/detail/", GroupTemplateView.as_view(), name="group_detail"
GroupTemplateView.as_view(),
name="group_detail",
), ),
# User views # User views
path("user/", UserListView.as_view(), name="user_list"), path("user/", UserListView.as_view(), name="user_list"),
path( path("user/<int:user_id>/mini/", UserMiniView.as_view(), name="user_profile_mini"),
"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>/", UserView.as_view(), name="user_profile"),
path( path(
"user/<int:user_id>/pictures/", "user/<int:user_id>/pictures/", UserPicturesView.as_view(), name="user_pictures"
UserPicturesView.as_view(),
name="user_pictures",
), ),
path( path(
"user/<int:user_id>/godfathers/", "user/<int:user_id>/godfathers/",
@ -117,28 +161,14 @@ urlpatterns = [
delete_user_godfather, delete_user_godfather,
name="user_godfathers_delete", name="user_godfathers_delete",
), ),
path( path("user/<int:user_id>/edit/", UserUpdateProfileView.as_view(), name="user_edit"),
"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>/clubs/", UserClubView.as_view(), name="user_clubs"),
path("user/<int:user_id>/prefs/", UserPreferencesView.as_view(), name="user_prefs"),
path( path(
"user/<int:user_id>/prefs/", "user/<int:user_id>/groups/", UserUpdateGroupView.as_view(), name="user_groups"
UserPreferencesView.as_view(),
name="user_prefs",
),
path(
"user/<int:user_id>/groups/",
UserUpdateGroupView.as_view(),
name="user_groups",
), ),
path("user/tools/", UserToolsView.as_view(), name="user_tools"), path("user/tools/", UserToolsView.as_view(), name="user_tools"),
path( path("user/<int:user_id>/account/", UserAccountView.as_view(), name="user_account"),
"user/<int:user_id>/account/",
UserAccountView.as_view(),
name="user_account",
),
path( path(
"user/<int:user_id>/account/<yyyy:year>/<mm:month>/", "user/<int:user_id>/account/<yyyy:year>/<mm:month>/",
UserAccountDetailView.as_view(), UserAccountDetailView.as_view(),
@ -179,42 +209,18 @@ urlpatterns = [
), ),
path("file/moderation/", FileModerationView.as_view(), name="file_moderation"), path("file/moderation/", FileModerationView.as_view(), name="file_moderation"),
path( path(
"file/<int:file_id>/moderate/", "file/<int:file_id>/moderate/", FileModerateView.as_view(), name="file_moderate"
FileModerateView.as_view(),
name="file_moderate",
), ),
path("file/<int:file_id>/download/", send_file, name="download"), path("file/<int:file_id>/download/", send_file, name="download"),
# Page views # Page views
path("page/", PageListView.as_view(), name="page_list"), path("page/", PageListView.as_view(), name="page_list"),
path("page/create/", PageCreateView.as_view(), name="page_new"), 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( path(
"page/<int:page_id>/delete/", "page/<path:page_name>/rev/<int:rev>/", PageRevView.as_view(), name="page_rev"
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",
), ),
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): def exif_auto_rotate(image):
for orientation in ExifTags.TAGS.keys(): for orientation in ExifTags.TAGS:
if ExifTags.TAGS[orientation] == "Orientation": if ExifTags.TAGS[orientation] == "Orientation":
break break
exif = dict(image._getexif().items()) exif = dict(image._getexif().items())

View File

@ -25,6 +25,7 @@
import types import types
from typing import Any from typing import Any
from django.conf import settings
from django.contrib.auth.mixins import AccessMixin from django.contrib.auth.mixins import AccessMixin
from django.core.exceptions import ( from django.core.exceptions import (
ImproperlyConfigured, ImproperlyConfigured,
@ -35,6 +36,7 @@ from django.http import (
HttpResponseNotFound, HttpResponseNotFound,
HttpResponseServerError, HttpResponseServerError,
) )
from django.shortcuts import render
from django.utils.functional import cached_property from django.utils.functional import cached_property
from django.views.generic.base import View from django.views.generic.base import View
from django.views.generic.detail import SingleObjectMixin from django.views.generic.detail import SingleObjectMixin
@ -79,9 +81,7 @@ def can_edit_prop(obj: Any, user: User) -> bool:
raise PermissionDenied raise PermissionDenied
``` ```
""" """
if obj is None or user.is_owner(obj): return obj is None or user.is_owner(obj)
return True
return False
def can_edit(obj: Any, user: User) -> bool: def can_edit(obj: Any, user: User) -> bool:
@ -232,7 +232,9 @@ class UserIsRootMixin(GenericContentPermissionMixinBuilder):
PermissionDenied: if the user isn't root 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): class FormerSubscriberMixin(AccessMixin):
@ -304,10 +306,10 @@ class QuickNotifMixin:
kwargs["quick_notifs"] = [] kwargs["quick_notifs"] = []
for n in self.quick_notif_list: for n in self.quick_notif_list:
kwargs["quick_notifs"].append(settings.SITH_QUICK_NOTIF[n]) kwargs["quick_notifs"].append(settings.SITH_QUICK_NOTIF[n])
for k, v in settings.SITH_QUICK_NOTIF.items(): for key, val in settings.SITH_QUICK_NOTIF.items():
for gk in self.request.GET.keys(): for gk in self.request.GET:
if k == gk: if key == gk:
kwargs["quick_notifs"].append(v) kwargs["quick_notifs"].append(val)
return kwargs return kwargs
@ -324,8 +326,10 @@ class DetailFormView(SingleObjectMixin, FormView):
return super().get_object() return super().get_object()
from .files import * # F403: those star-imports would be hellish to refactor
from .group import * # E402: putting those import at the top of the file would also be difficult
from .page import * from .files import * # noqa: F403 E402
from .site import * from .group import * # noqa: F403 E402
from .user import * 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): def get_form_class(self):
fields = ["name", "is_moderated"] fields = ["name", "is_moderated"]
if self.object.is_file: if self.object.is_file:
fields = ["file"] + fields fields = ["file", *fields]
return modelform_factory(SithFile, fields=fields) return modelform_factory(SithFile, fields=fields)
def get_success_url(self): 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 `obj` is the SithFile object you want to put in the clipboard, or
where you want to paste the clipboard 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"): for f_id in request.POST.getlist("file_list"):
sf = SithFile.objects.filter(id=f_id).first() file = SithFile.objects.filter(id=f_id).first()
if sf: if file:
sf.delete() file.delete()
if "clear" in request.POST.keys(): if "clear" in request.POST:
request.session["clipboard"] = [] request.session["clipboard"] = []
if "cut" in request.POST.keys(): if "cut" in request.POST:
for f_id in request.POST.getlist("file_list"): for f_id_str in request.POST.getlist("file_list"):
f_id = int(f_id) f_id = int(f_id_str)
if ( if (
f_id in [c.id for c in obj.children.all()] f_id in [c.id for c in obj.children.all()]
and f_id not in request.session["clipboard"] and f_id not in request.session["clipboard"]
): ):
request.session["clipboard"].append(f_id) request.session["clipboard"].append(f_id)
if "paste" in request.POST.keys(): if "paste" in request.POST:
for f_id in request.session["clipboard"]: for f_id in request.session["clipboard"]:
sf = SithFile.objects.filter(id=f_id).first() file = SithFile.objects.filter(id=f_id).first()
if sf: if file:
sf.move_to(obj) file.move_to(obj)
request.session["clipboard"] = [] request.session["clipboard"] = []
request.session.modified = True request.session.modified = True
def get(self, request, *args, **kwargs): def get(self, request, *args, **kwargs):
self.form = self.get_form() self.form = self.get_form()
if "clipboard" not in request.session.keys(): if "clipboard" not in request.session:
request.session["clipboard"] = [] request.session["clipboard"] = []
return super().get(request, *args, **kwargs) return super().get(request, *args, **kwargs)
def post(self, request, *args, **kwargs): def post(self, request, *args, **kwargs):
self.object = self.get_object() self.object = self.get_object()
if "clipboard" not in request.session.keys(): if "clipboard" not in request.session:
request.session["clipboard"] = [] request.session["clipboard"] = []
if request.user.can_edit(self.object): if request.user.can_edit(self.object):
# XXX this call can fail! # XXX this call can fail!
@ -398,6 +398,6 @@ class FileModerateView(CanEditPropMixin, SingleObjectMixin):
self.object.is_moderated = True self.object.is_moderated = True
self.object.moderator = request.user self.object.moderator = request.user
self.object.save() self.object.save()
if "next" in self.request.GET.keys(): if "next" in self.request.GET:
return redirect(self.request.GET["next"]) return redirect(self.request.GET["next"])
return redirect("core:file_moderation") return redirect("core:file_moderation")

View File

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

View File

@ -55,7 +55,7 @@ class PageView(CanViewMixin, DetailView):
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
context = super().get_context_data(**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"] context["new_page"] = self.kwargs["page_name"]
return context return context
@ -92,22 +92,16 @@ class PageRevView(CanViewMixin, DetailView):
) )
return res return res
def get_object(self): def get_object(self, *args, **kwargs):
self.page = Page.get_page_by_full_name(self.kwargs["page_name"]) self.page = Page.get_page_by_full_name(self.kwargs["page_name"])
return self.page return self.page
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs) context = super().get_context_data(**kwargs)
if self.page is not None: if not self.page:
context["page"] = self.page return context | {"new_page": self.kwargs["page_name"]}
try: context["page"] = self.page
rev = self.page.revisions.get(id=self.kwargs["rev"]) context["rev"] = self.page.revisions.filter(id=self.kwargs["rev"]).first()
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"]
return context return context
@ -118,7 +112,7 @@ class PageCreateView(CanCreateMixin, CreateView):
def get_initial(self): def get_initial(self):
init = {} init = {}
if "page" in self.request.GET.keys(): if "page" in self.request.GET:
page_name = self.request.GET["page"] page_name = self.request.GET["page"]
parent_name = "/".join(page_name.split("/")[:-1]) parent_name = "/".join(page_name.split("/")[:-1])
parent = Page.get_page_by_full_name(parent_name) parent = Page.get_page_by_full_name(parent_name)
@ -145,18 +139,8 @@ class PagePropView(CanEditPagePropMixin, UpdateView):
slug_field = "_full_name" slug_field = "_full_name"
slug_url_kwarg = "page_name" slug_url_kwarg = "page_name"
def get_object(self): def get_object(self, queryset=None):
o = super().get_object() self.page = 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
try: try:
self.page.set_lock_recursive(self.request.user) self.page.set_lock_recursive(self.request.user)
except LockError as e: except LockError as e:

View File

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

View File

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

View File

@ -15,7 +15,19 @@
from django.contrib import admin from django.contrib import admin
from haystack.admin import SearchModelAdmin 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) @admin.register(Product)

View File

@ -1,4 +1,5 @@
import logging import logging
import time
from smtplib import SMTPException from smtplib import SMTPException
from django.conf import settings from django.conf import settings
@ -25,9 +26,34 @@ class Command(BaseCommand):
self.logger.setLevel(logging.INFO) self.logger.setLevel(logging.INFO)
super().__init__(*args, **kwargs) 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): 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") 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 = [] dumps = []
for user in users: for user in users:
is_success = self._send_mail(user) is_success = self._send_mail(user)
@ -38,6 +64,10 @@ class Command(BaseCommand):
warning_mail_error=not is_success, 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) AccountDump.objects.bulk_create(dumps)
self.stdout.write("Finished !") self.stdout.write("Finished !")

View File

@ -154,7 +154,7 @@ class Customer(models.Model):
self.save() self.save()
def get_full_url(self): 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): 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.""" """Method to see if that object can be edited by the given user."""
if user.is_anonymous: if user.is_anonymous:
return False return False
if user.is_in_group(pk=settings.SITH_GROUP_ACCOUNTING_ADMIN_ID): return user.is_in_group(pk=settings.SITH_GROUP_ACCOUNTING_ADMIN_ID)
return True
return False
class Product(models.Model): class Product(models.Model):
@ -346,21 +344,19 @@ class Product(models.Model):
@property @property
def is_record_product(self): def is_record_product(self):
return settings.SITH_ECOCUP_CONS == self.id return self.id == settings.SITH_ECOCUP_CONS
@property @property
def is_unrecord_product(self): 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): def is_owned_by(self, user):
"""Method to see if that object can be edited by the given user.""" """Method to see if that object can be edited by the given user."""
if user.is_anonymous: if user.is_anonymous:
return False return False
if user.is_in_group( return user.is_in_group(
pk=settings.SITH_GROUP_ACCOUNTING_ADMIN_ID pk=settings.SITH_GROUP_ACCOUNTING_ADMIN_ID
) or user.is_in_group(pk=settings.SITH_GROUP_COUNTER_ADMIN_ID): ) or user.is_in_group(pk=settings.SITH_GROUP_COUNTER_ADMIN_ID)
return True
return False
def can_be_sold_to(self, user: User) -> bool: def can_be_sold_to(self, user: User) -> bool:
"""Check if whether the user given in parameter has the right to buy """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()) buying_groups = list(self.buying_groups.all())
if not buying_groups: if not buying_groups:
return True return True
for group in buying_groups: return any(user.is_in_group(pk=group.id) for group in buying_groups)
if user.is_in_group(pk=group.id):
return True
return False
@property @property
def profit(self): 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." "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, "event": event,
"url": "".join( "url": (
( f'<a href="{self.customer.get_full_url()}">'
'<a href="', f"{self.customer.get_full_url()}</a>"
self.customer.get_full_url(),
'">',
self.customer.get_full_url(),
"</a>",
)
), ),
"eticket": "".join( "eticket": (
( f'<a href="{self.get_eticket_full_url()}">'
'<a href="', f"{self.get_eticket_full_url()}</a>"
self.get_eticket_full_url(),
'">',
self.get_eticket_full_url(),
"</a>",
)
), ),
} }
message_txt = _( 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, "event": event,
"url": self.customer.get_full_url(), "url": self.customer.get_full_url(),
@ -919,7 +904,7 @@ class Selling(models.Model):
def get_eticket_full_url(self): def get_eticket_full_url(self):
eticket_url = reverse("counter:eticket_pdf", kwargs={"selling_id": self.id}) 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): class Permanency(models.Model):
@ -1019,15 +1004,15 @@ class CashRegisterSummary(models.Model):
elif name == "hundred_euros": elif name == "hundred_euros":
return self.items.filter(value=100, is_check=False).first() return self.items.filter(value=100, is_check=False).first()
elif name == "check_1": 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": 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": 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": 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": elif name == "check_5":
return checks[4] if 4 < len(checks) else None return checks[4] if len(checks) > 4 else None
else: else:
return object.__getattribute__(self, name) 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.""" """Method to see if that object can be edited by the given user."""
if user.is_anonymous: if user.is_anonymous:
return False return False
if user.is_in_group(pk=settings.SITH_GROUP_COUNTER_ADMIN_ID): return user.is_in_group(pk=settings.SITH_GROUP_COUNTER_ADMIN_ID)
return True
return False
def get_total(self): def get_total(self):
t = 0 t = 0

View File

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

View File

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

View File

@ -13,7 +13,7 @@
<h4>{{ product_type or _("Uncategorized") }}</h4> <h4>{{ product_type or _("Uncategorized") }}</h4>
<ul> <ul>
{%- for product in products -%} {%- 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 -%} {%- endfor -%}
</ul> </ul>
{%- else -%} {%- else -%}

View File

@ -503,7 +503,7 @@ class TestBarmanConnection(TestCase):
) )
response = self.client.get(reverse("counter:activity", args=[self.counter.id])) 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 @pytest.mark.django_db
@ -853,7 +853,7 @@ class TestCustomerAccountId(TestCase):
number = account_id[:-1] number = account_id[:-1]
assert created is True assert created is True
assert number == "12346" assert number == "12346"
assert 6 == len(account_id) assert len(account_id) == 6
assert account_id[-1] in string.ascii_lowercase assert account_id[-1] in string.ascii_lowercase
assert customer.amount == 0 assert customer.amount == 0

View File

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

View File

@ -24,6 +24,12 @@ Si le mot apparaît dans le template Jinja :
{% trans %}Hello{% endtrans %} {% trans %}Hello{% endtrans %}
``` ```
Si on est dans un fichier javascript ou typescript :
```js
gettext("Hello");
```
## Générer le fichier django.po ## Générer le fichier django.po
La traduction se fait en trois étapes. 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 ```bash
./manage.py makemessages --locale=fr -e py,jinja --ignore=node_modules # Pour le backend ./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 ## Éditer le fichier django.po

View File

@ -13,8 +13,9 @@
# #
# #
from django.contrib import admin 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) @admin.register(Basket)

View File

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

View File

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

View File

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

View File

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

View File

@ -25,7 +25,14 @@
from django.urls import path, register_converter from django.urls import path, register_converter
from eboutic.converters import PaymentResultConverter 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") register_converter(PaymentResultConverter, "res")

View File

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

View File

@ -1,6 +1,22 @@
from django.urls import path 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 = [ urlpatterns = [
path("", ElectionsListView.as_view(), name="list"), path("", ElectionsListView.as_view(), name="list"),
@ -19,16 +35,10 @@ urlpatterns = [
name="delete_list", name="delete_list",
), ),
path( path(
"<int:election_id>/role/create/", "<int:election_id>/role/create/", RoleCreateView.as_view(), name="create_role"
RoleCreateView.as_view(),
name="create_role",
), ),
path("<int:role_id>/role/edit/", RoleUpdateView.as_view(), name="update_role"), path("<int:role_id>/role/edit/", RoleUpdateView.as_view(), name="update_role"),
path( path("<int:role_id>/role/delete/", RoleDeleteView.as_view(), name="delete_role"),
"<int:role_id>/role/delete/",
RoleDeleteView.as_view(),
name="delete_role",
),
path( path(
"<int:election_id>/candidate/add/", "<int:election_id>/candidate/add/",
CandidatureCreateView.as_view(), CandidatureCreateView.as_view(),

View File

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

View File

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

View File

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

View File

@ -23,7 +23,26 @@
from django.urls import path 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 = [ urlpatterns = [
path("", ForumMainView.as_view(), name="main"), 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>/", ForumDetailView.as_view(), name="view_forum"),
path("<int:forum_id>/edit/", ForumEditView.as_view(), name="edit_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>/delete/", ForumDeleteView.as_view(), name="delete_forum"),
path( path("<int:forum_id>/new_topic/", ForumTopicCreateView.as_view(), name="new_topic"),
"<int:forum_id>/new_topic/", path("topic/<int:topic_id>/", ForumTopicDetailView.as_view(), name="view_topic"),
ForumTopicCreateView.as_view(), path("topic/<int:topic_id>/edit/", ForumTopicEditView.as_view(), name="edit_topic"),
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( path(
"topic/<int:topic_id>/new_message/", "topic/<int:topic_id>/new_message/",
ForumMessageCreateView.as_view(), ForumMessageCreateView.as_view(),
@ -60,11 +67,7 @@ urlpatterns = [
ForumTopicSubscribeView.as_view(), ForumTopicSubscribeView.as_view(),
name="toggle_subscribe_topic", name="toggle_subscribe_topic",
), ),
path( path("message/<int:message_id>/", ForumMessageView.as_view(), name="view_message"),
"message/<int:message_id>/",
ForumMessageView.as_view(),
name="view_message",
),
path( path(
"message/<int:message_id>/edit/", "message/<int:message_id>/edit/",
ForumMessageEditView.as_view(), ForumMessageEditView.as_view(),

View File

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

View File

@ -23,17 +23,9 @@
from django.urls import path from django.urls import path
from galaxy.views import * from galaxy.views import GalaxyDataView, GalaxyUserView
urlpatterns = [ urlpatterns = [
path( path("<int:user_id>/", GalaxyUserView.as_view(), name="user"),
"<int:user_id>/", path("data.json", GalaxyDataView.as_view(), name="data"),
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 django.contrib import admin
from launderette.models import * from launderette.models import Launderette, Machine, Slot, Token
@admin.register(Launderette) @admin.register(Launderette)

View File

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

View File

@ -15,22 +15,28 @@
from django.urls import path 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 = [ urlpatterns = [
# views # views
path("", LaunderetteMainView.as_view(), name="launderette_main"), path("", LaunderetteMainView.as_view(), name="launderette_main"),
path( path("slot/<int:slot_id>/delete/", SlotDeleteView.as_view(), name="delete_slot"),
"slot/<int:slot_id>/delete/",
SlotDeleteView.as_view(),
name="delete_slot",
),
path("book/", LaunderetteBookMainView.as_view(), name="book_main"), path("book/", LaunderetteBookMainView.as_view(), name="book_main"),
path( path("book/<int:launderette_id>/", LaunderetteBookView.as_view(), name="book_slot"),
"book/<int:launderette_id>/",
LaunderetteBookView.as_view(),
name="book_slot",
),
path( path(
"<int:launderette_id>/click/", "<int:launderette_id>/click/",
LaunderetteMainClickView.as_view(), LaunderetteMainClickView.as_view(),

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

35
package-lock.json generated
View File

@ -28,9 +28,9 @@
"jquery-ui": "^1.14.0", "jquery-ui": "^1.14.0",
"jquery.shorten": "^1.0.0", "jquery.shorten": "^1.0.0",
"native-file-system-adapter": "^3.0.1", "native-file-system-adapter": "^3.0.1",
"select2": "^4.1.0-rc.0",
"three": "^0.169.0", "three": "^0.169.0",
"three-spritetext": "^1.9.0" "three-spritetext": "^1.9.0",
"tom-select": "^2.3.1"
}, },
"devDependencies": { "devDependencies": {
"@babel/core": "^7.25.2", "@babel/core": "^7.25.2",
@ -39,7 +39,6 @@
"@hey-api/openapi-ts": "^0.53.8", "@hey-api/openapi-ts": "^0.53.8",
"@types/alpinejs": "^3.13.10", "@types/alpinejs": "^3.13.10",
"@types/jquery": "^3.5.31", "@types/jquery": "^3.5.31",
"@types/select2": "^4.0.63",
"babel-loader": "^9.2.1", "babel-loader": "^9.2.1",
"css-loader": "^7.1.2", "css-loader": "^7.1.2",
"css-minimizer-webpack-plugin": "^7.0.0", "css-minimizer-webpack-plugin": "^7.0.0",
@ -2112,6 +2111,19 @@
"integrity": "sha512-fuscdXJ9G1qb7W8VdHi+IwRqij3lBkosAm4ydQtEmbY58OzHXqQhvlxqEkoz0yssNVn38bcpRWgA9PP+OGoisw==", "integrity": "sha512-fuscdXJ9G1qb7W8VdHi+IwRqij3lBkosAm4ydQtEmbY58OzHXqQhvlxqEkoz0yssNVn38bcpRWgA9PP+OGoisw==",
"license": "MIT" "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": { "node_modules/@pkgjs/parseargs": {
"version": "0.11.0", "version": "0.11.0",
"resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
@ -6134,6 +6146,7 @@
"integrity": "sha512-Hr9TdhyHCZUtwznEH2CBf7967mEM0idtJ5nMtjvk3Up5tPukOLXbHUNmh10oRfeNIhj+3GD3niu+g6sVK+gK0A==", "integrity": "sha512-Hr9TdhyHCZUtwznEH2CBf7967mEM0idtJ5nMtjvk3Up5tPukOLXbHUNmh10oRfeNIhj+3GD3niu+g6sVK+gK0A==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/semver": { "node_modules/semver": {
"version": "7.6.3", "version": "7.6.3",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz", "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz",
@ -6617,6 +6630,22 @@
"node": ">=8.0" "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": { "node_modules/totalist": {
"version": "3.0.1", "version": "3.0.1",
"resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz",

View File

@ -27,7 +27,6 @@
"@hey-api/openapi-ts": "^0.53.8", "@hey-api/openapi-ts": "^0.53.8",
"@types/alpinejs": "^3.13.10", "@types/alpinejs": "^3.13.10",
"@types/jquery": "^3.5.31", "@types/jquery": "^3.5.31",
"@types/select2": "^4.0.63",
"babel-loader": "^9.2.1", "babel-loader": "^9.2.1",
"css-loader": "^7.1.2", "css-loader": "^7.1.2",
"css-minimizer-webpack-plugin": "^7.0.0", "css-minimizer-webpack-plugin": "^7.0.0",
@ -61,8 +60,8 @@
"jquery-ui": "^1.14.0", "jquery-ui": "^1.14.0",
"jquery.shorten": "^1.0.0", "jquery.shorten": "^1.0.0",
"native-file-system-adapter": "^3.0.1", "native-file-system-adapter": "^3.0.1",
"select2": "^4.1.0-rc.0",
"three": "^0.169.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, unique=True,
validators=[ validators=[
django.core.validators.RegexValidator( 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]+)", regex="([A-Z0-9]+)",
) )
], ],

View File

@ -45,7 +45,8 @@ class UV(models.Model):
validators.RegexValidator( validators.RegexValidator(
regex="([A-Z0-9]+)", regex="([A-Z0-9]+)",
message=_( 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", semester="AUTUMN",
department="GI", department="GI",
manager="francky", 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( uv_recipe.prepare(
code="MT01", code="MT01",
@ -118,7 +121,7 @@ class TestUVSearch(TestCase):
("M", {"MT01", "MT10"}), ("M", {"MT01", "MT10"}),
("mt", {"MT01", "MT10"}), ("mt", {"MT01", "MT10"}),
("MT", {"MT01", "MT10"}), ("MT", {"MT01", "MT10"}),
("algèbre", {"MT01"}), # Title search case insensitive ("algèbre", {"MT01"}), # Title search case insensitive
# Manager search # Manager search
("moss", {"TNEV"}), ("moss", {"TNEV"}),
("francky", {"DA50", "AP4A"}), ("francky", {"DA50", "AP4A"}),

View File

@ -381,7 +381,9 @@ class TestUVCommentCreationAndDisplay(TestCase):
self.assertContains( self.assertContains(
response, 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 django.urls import path
from pedagogy.views import * from pedagogy.views import (
UVCommentDeleteView,
UVCommentReportCreateView,
UVCommentUpdateView,
UVCreateView,
UVDeleteView,
UVDetailFormView,
UVGuideView,
UVModerationFormView,
UVUpdateView,
)
urlpatterns = [ urlpatterns = [
# Urls displaying the actual application for visitors # Urls displaying the actual application for visitors

View File

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

284
poetry.lock generated
View File

@ -343,73 +343,73 @@ files = [
[[package]] [[package]]
name = "coverage" name = "coverage"
version = "7.6.2" version = "7.6.3"
description = "Code coverage measurement for Python" description = "Code coverage measurement for Python"
optional = false optional = false
python-versions = ">=3.9" python-versions = ">=3.9"
files = [ files = [
{file = "coverage-7.6.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:c9df1950fb92d49970cce38100d7e7293c84ed3606eaa16ea0b6bc27175bb667"}, {file = "coverage-7.6.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:6da42bbcec130b188169107ecb6ee7bd7b4c849d24c9370a0c884cf728d8e976"},
{file = "coverage-7.6.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:24500f4b0e03aab60ce575c85365beab64b44d4db837021e08339f61d1fbfe52"}, {file = "coverage-7.6.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c222958f59b0ae091f4535851cbb24eb57fc0baea07ba675af718fb5302dddb2"},
{file = "coverage-7.6.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a663b180b6669c400b4630a24cc776f23a992d38ce7ae72ede2a397ce6b0f170"}, {file = "coverage-7.6.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ab84a8b698ad5a6c365b08061920138e7a7dd9a04b6feb09ba1bfae68346ce6d"},
{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.3-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:70a6756ce66cd6fe8486c775b30889f0dc4cb20c157aa8c35b45fd7868255c5c"},
{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.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.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:9c6b0c1cafd96213a0327cf680acb39f70e452caf8e9a25aeb05316db9c07f89"}, {file = "coverage-7.6.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:921fbe13492caf6a69528f09d5d7c7d518c8d0e7b9f6701b7719715f29a71e6e"},
{file = "coverage-7.6.2-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:6e85830eed5b5263ffa0c62428e43cb844296f3b4461f09e4bdb0d44ec190bc2"}, {file = "coverage-7.6.3-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:6d99198203f0b9cb0b5d1c0393859555bc26b548223a769baf7e321a627ed4fc"},
{file = "coverage-7.6.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:62ab4231c01e156ece1b3a187c87173f31cbeee83a5e1f6dff17f288dca93345"}, {file = "coverage-7.6.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:87cd2e29067ea397a47e352efb13f976eb1b03e18c999270bb50589323294c6e"},
{file = "coverage-7.6.2-cp310-cp310-win32.whl", hash = "sha256:7b80fbb0da3aebde102a37ef0138aeedff45997e22f8962e5f16ae1742852676"}, {file = "coverage-7.6.3-cp310-cp310-win32.whl", hash = "sha256:a3328c3e64ea4ab12b85999eb0779e6139295bbf5485f69d42cf794309e3d007"},
{file = "coverage-7.6.2-cp310-cp310-win_amd64.whl", hash = "sha256:d20c3d1f31f14d6962a4e2f549c21d31e670b90f777ef4171be540fb7fb70f02"}, {file = "coverage-7.6.3-cp310-cp310-win_amd64.whl", hash = "sha256:bca4c8abc50d38f9773c1ec80d43f3768df2e8576807d1656016b9d3eeaa96fd"},
{file = "coverage-7.6.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:bb21bac7783c1bf6f4bbe68b1e0ff0d20e7e7732cfb7995bc8d96e23aa90fc7b"}, {file = "coverage-7.6.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c51ef82302386d686feea1c44dbeef744585da16fcf97deea2a8d6c1556f519b"},
{file = "coverage-7.6.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a7b2e437fbd8fae5bc7716b9c7ff97aecc95f0b4d56e4ca08b3c8d8adcaadb84"}, {file = "coverage-7.6.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:0ca37993206402c6c35dc717f90d4c8f53568a8b80f0bf1a1b2b334f4d488fba"},
{file = "coverage-7.6.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:536f77f2bf5797983652d1d55f1a7272a29afcc89e3ae51caa99b2db4e89d658"}, {file = "coverage-7.6.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c77326300b839c44c3e5a8fe26c15b7e87b2f32dfd2fc9fee1d13604347c9b38"},
{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.3-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6e484e479860e00da1f005cd19d1c5d4a813324e5951319ac3f3eefb497cc549"},
{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.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.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0bbae11c138585c89fb4e991faefb174a80112e1a7557d507aaa07675c62e66b"}, {file = "coverage-7.6.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:37be7b5ea3ff5b7c4a9db16074dc94523b5f10dd1f3b362a827af66a55198175"},
{file = "coverage-7.6.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:fcad7d5d2bbfeae1026b395036a8aa5abf67e8038ae7e6a25c7d0f88b10a8e6a"}, {file = "coverage-7.6.3-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:43b32a06c47539fe275106b376658638b418c7cfdfff0e0259fbf877e845f14b"},
{file = "coverage-7.6.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f01e53575f27097d75d42de33b1b289c74b16891ce576d767ad8c48d17aeb5e0"}, {file = "coverage-7.6.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ee77c7bef0724165e795b6b7bf9c4c22a9b8468a6bdb9c6b4281293c6b22a90f"},
{file = "coverage-7.6.2-cp311-cp311-win32.whl", hash = "sha256:7781f4f70c9b0b39e1b129b10c7d43a4e0c91f90c60435e6da8288efc2b73438"}, {file = "coverage-7.6.3-cp311-cp311-win32.whl", hash = "sha256:43517e1f6b19f610a93d8227e47790722c8bf7422e46b365e0469fc3d3563d97"},
{file = "coverage-7.6.2-cp311-cp311-win_amd64.whl", hash = "sha256:9bcd51eeca35a80e76dc5794a9dd7cb04b97f0e8af620d54711793bfc1fbba4b"}, {file = "coverage-7.6.3-cp311-cp311-win_amd64.whl", hash = "sha256:04f2189716e85ec9192df307f7c255f90e78b6e9863a03223c3b998d24a3c6c6"},
{file = "coverage-7.6.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ebc94fadbd4a3f4215993326a6a00e47d79889391f5659bf310f55fe5d9f581c"}, {file = "coverage-7.6.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:27bd5f18d8f2879e45724b0ce74f61811639a846ff0e5c0395b7818fae87aec6"},
{file = "coverage-7.6.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:9681516288e3dcf0aa7c26231178cc0be6cac9705cac06709f2353c5b406cfea"}, {file = "coverage-7.6.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d546cfa78844b8b9c1c0533de1851569a13f87449897bbc95d698d1d3cb2a30f"},
{file = "coverage-7.6.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8d9c5d13927d77af4fbe453953810db766f75401e764727e73a6ee4f82527b3e"}, {file = "coverage-7.6.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9975442f2e7a5cfcf87299c26b5a45266ab0696348420049b9b94b2ad3d40234"},
{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.3-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:583049c63106c0555e3ae3931edab5669668bbef84c15861421b94e121878d3f"},
{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.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.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:47ccb6e99a3031ffbbd6e7cc041e70770b4fe405370c66a54dbf26a500ded80b"}, {file = "coverage-7.6.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a4fb91d5f72b7e06a14ff4ae5be625a81cd7e5f869d7a54578fc271d08d58ae3"},
{file = "coverage-7.6.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:a867d26f06bcd047ef716175b2696b315cb7571ccb951006d61ca80bbc356e9e"}, {file = "coverage-7.6.3-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:e279f3db904e3b55f520f11f983cc8dc8a4ce9b65f11692d4718ed021ec58b83"},
{file = "coverage-7.6.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:cdfcf2e914e2ba653101157458afd0ad92a16731eeba9a611b5cbb3e7124e74b"}, {file = "coverage-7.6.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:aa23ce39661a3e90eea5f99ec59b763b7d655c2cada10729ed920a38bfc2b167"},
{file = "coverage-7.6.2-cp312-cp312-win32.whl", hash = "sha256:f9035695dadfb397bee9eeaf1dc7fbeda483bf7664a7397a629846800ce6e276"}, {file = "coverage-7.6.3-cp312-cp312-win32.whl", hash = "sha256:52ac29cc72ee7e25ace7807249638f94c9b6a862c56b1df015d2b2e388e51dbd"},
{file = "coverage-7.6.2-cp312-cp312-win_amd64.whl", hash = "sha256:5ed69befa9a9fc796fe015a7040c9398722d6b97df73a6b608e9e275fa0932b0"}, {file = "coverage-7.6.3-cp312-cp312-win_amd64.whl", hash = "sha256:40e8b1983080439d4802d80b951f4a93d991ef3261f69e81095a66f86cf3c3c6"},
{file = "coverage-7.6.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4eea60c79d36a8f39475b1af887663bc3ae4f31289cd216f514ce18d5938df40"}, {file = "coverage-7.6.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:9134032f5aa445ae591c2ba6991d10136a1f533b1d2fa8f8c21126468c5025c6"},
{file = "coverage-7.6.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:aa68a6cdbe1bc6793a9dbfc38302c11599bbe1837392ae9b1d238b9ef3dafcf1"}, {file = "coverage-7.6.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:99670790f21a96665a35849990b1df447993880bb6463a0a1d757897f30da929"},
{file = "coverage-7.6.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3ec528ae69f0a139690fad6deac8a7d33629fa61ccce693fdd07ddf7e9931fba"}, {file = "coverage-7.6.3-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2dc7d6b380ca76f5e817ac9eef0c3686e7834c8346bef30b041a4ad286449990"},
{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.3-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f7b26757b22faf88fcf232f5f0e62f6e0fd9e22a8a5d0d5016888cdfe1f6c1c4"},
{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.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.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:35a51598f29b2a19e26d0908bd196f771a9b1c5d9a07bf20be0adf28f1ad4f77"}, {file = "coverage-7.6.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f263b18692f8ed52c8de7f40a0751e79015983dbd77b16906e5b310a39d3ca21"},
{file = "coverage-7.6.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:c9192925acc33e146864b8cf037e2ed32a91fdf7644ae875f5d46cd2ef086a5f"}, {file = "coverage-7.6.3-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:79644f68a6ff23b251cae1c82b01a0b51bc40c8468ca9585c6c4b1aeee570e0b"},
{file = "coverage-7.6.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:bf4eeecc9e10f5403ec06138978235af79c9a79af494eb6b1d60a50b49ed2869"}, {file = "coverage-7.6.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:71967c35828c9ff94e8c7d405469a1fb68257f686bca7c1ed85ed34e7c2529c4"},
{file = "coverage-7.6.2-cp313-cp313-win32.whl", hash = "sha256:e4ee15b267d2dad3e8759ca441ad450c334f3733304c55210c2a44516e8d5530"}, {file = "coverage-7.6.3-cp313-cp313-win32.whl", hash = "sha256:e266af4da2c1a4cbc6135a570c64577fd3e6eb204607eaff99d8e9b710003c6f"},
{file = "coverage-7.6.2-cp313-cp313-win_amd64.whl", hash = "sha256:c71965d1ced48bf97aab79fad56df82c566b4c498ffc09c2094605727c4b7e36"}, {file = "coverage-7.6.3-cp313-cp313-win_amd64.whl", hash = "sha256:ea52bd218d4ba260399a8ae4bb6b577d82adfc4518b93566ce1fddd4a49d1dce"},
{file = "coverage-7.6.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:7571e8bbecc6ac066256f9de40365ff833553e2e0c0c004f4482facb131820ef"}, {file = "coverage-7.6.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:8d4c6ea0f498c7c79111033a290d060c517853a7bcb2f46516f591dab628ddd3"},
{file = "coverage-7.6.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:078a87519057dacb5d77e333f740708ec2a8f768655f1db07f8dfd28d7a005f0"}, {file = "coverage-7.6.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:331b200ad03dbaa44151d74daeb7da2cf382db424ab923574f6ecca7d3b30de3"},
{file = "coverage-7.6.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1e5e92e3e84a8718d2de36cd8387459cba9a4508337b8c5f450ce42b87a9e760"}, {file = "coverage-7.6.3-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:54356a76b67cf8a3085818026bb556545ebb8353951923b88292556dfa9f812d"},
{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.3-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ebec65f5068e7df2d49466aab9128510c4867e532e07cb6960075b27658dca38"},
{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.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.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:39d3b964abfe1519b9d313ab28abf1d02faea26cd14b27f5283849bf59479ff5"}, {file = "coverage-7.6.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:f7ddb920106bbbbcaf2a274d56f46956bf56ecbde210d88061824a95bdd94e92"},
{file = "coverage-7.6.2-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:84c4315577f7cd511d6250ffd0f695c825efe729f4205c0340f7004eda51191f"}, {file = "coverage-7.6.3-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:70d24936ca6c15a3bbc91ee9c7fc661132c6f4c9d42a23b31b6686c05073bde5"},
{file = "coverage-7.6.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:ff797320dcbff57caa6b2301c3913784a010e13b1f6cf4ab3f563f3c5e7919db"}, {file = "coverage-7.6.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:c30e42ea11badb147f0d2e387115b15e2bd8205a5ad70d6ad79cf37f6ac08c91"},
{file = "coverage-7.6.2-cp313-cp313t-win32.whl", hash = "sha256:2b636a301e53964550e2f3094484fa5a96e699db318d65398cfba438c5c92171"}, {file = "coverage-7.6.3-cp313-cp313t-win32.whl", hash = "sha256:365defc257c687ce3e7d275f39738dcd230777424117a6c76043459db131dd43"},
{file = "coverage-7.6.2-cp313-cp313t-win_amd64.whl", hash = "sha256:d03a060ac1a08e10589c27d509bbdb35b65f2d7f3f8d81cf2fa199877c7bc58a"}, {file = "coverage-7.6.3-cp313-cp313t-win_amd64.whl", hash = "sha256:23bb63ae3f4c645d2d82fa22697364b0046fbafb6261b258a58587441c5f7bd0"},
{file = "coverage-7.6.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:c37faddc8acd826cfc5e2392531aba734b229741d3daec7f4c777a8f0d4993e5"}, {file = "coverage-7.6.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:da29ceabe3025a1e5a5aeeb331c5b1af686daab4ff0fb4f83df18b1180ea83e2"},
{file = "coverage-7.6.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:ab31fdd643f162c467cfe6a86e9cb5f1965b632e5e65c072d90854ff486d02cf"}, {file = "coverage-7.6.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:df8c05a0f574d480947cba11b947dc41b1265d721c3777881da2fb8d3a1ddfba"},
{file = "coverage-7.6.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:97df87e1a20deb75ac7d920c812e9326096aa00a9a4b6d07679b4f1f14b06c90"}, {file = "coverage-7.6.3-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ec1e3b40b82236d100d259854840555469fad4db64f669ab817279eb95cd535c"},
{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.3-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b4adeb878a374126f1e5cf03b87f66279f479e01af0e9a654cf6d1509af46c40"},
{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.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.2-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:7fca4a92c8a7a73dee6946471bce6d1443d94155694b893b79e19ca2a540d86e"}, {file = "coverage-7.6.3-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:1990b1f4e2c402beb317840030bb9f1b6a363f86e14e21b4212e618acdfce7f6"},
{file = "coverage-7.6.2-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:69f251804e052fc46d29d0e7348cdc5fcbfc4861dc4a1ebedef7e78d241ad39e"}, {file = "coverage-7.6.3-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:12f9515d875859faedb4144fd38694a761cd2a61ef9603bf887b13956d0bbfbb"},
{file = "coverage-7.6.2-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:e8ea055b3ea046c0f66217af65bc193bbbeca1c8661dc5fd42698db5795d2627"}, {file = "coverage-7.6.3-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:99ded130555c021d99729fabd4ddb91a6f4cc0707df4b1daf912c7850c373b13"},
{file = "coverage-7.6.2-cp39-cp39-win32.whl", hash = "sha256:6c2ba1e0c24d8fae8f2cf0aeb2fc0a2a7f69b6d20bd8d3749fd6b36ecef5edf0"}, {file = "coverage-7.6.3-cp39-cp39-win32.whl", hash = "sha256:c3a79f56dee9136084cf84a6c7c4341427ef36e05ae6415bf7d787c96ff5eaa3"},
{file = "coverage-7.6.2-cp39-cp39-win_amd64.whl", hash = "sha256:2186369a654a15628e9c1c9921409a6b3eda833e4b91f3ca2a7d9f77abb4987c"}, {file = "coverage-7.6.3-cp39-cp39-win_amd64.whl", hash = "sha256:aac7501ae73d4a02f4b7ac8fcb9dc55342ca98ffb9ed9f2dfb8a25d53eda0e4d"},
{file = "coverage-7.6.2-pp39.pp310-none-any.whl", hash = "sha256:667952739daafe9616db19fbedbdb87917eee253ac4f31d70c7587f7ab531b4e"}, {file = "coverage-7.6.3-pp39.pp310-none-any.whl", hash = "sha256:b9853509b4bf57ba7b1f99b9d866c422c9c5248799ab20e652bbb8a184a38181"},
{file = "coverage-7.6.2.tar.gz", hash = "sha256:a5f81e68aa62bc0cfca04f7b19eaa8f9c826b53fc82ab9e2121976dc74f131f3"}, {file = "coverage-7.6.3.tar.gz", hash = "sha256:bb7d5fe92bd0dc235f63ebe9f8c6e0884f7360f88f3411bfed1350c872ef2054"},
] ]
[package.extras] [package.extras]
@ -491,13 +491,13 @@ tests = ["noseofyeti[black] (==2.4.9)", "pytest (==8.3.2)"]
[[package]] [[package]]
name = "distlib" name = "distlib"
version = "0.3.8" version = "0.3.9"
description = "Distribution utilities" description = "Distribution utilities"
optional = false optional = false
python-versions = "*" python-versions = "*"
files = [ files = [
{file = "distlib-0.3.8-py2.py3-none-any.whl", hash = "sha256:034db59a0b96f8ca18035f36290806a9a6e6bd9d1ff91e45a7f172eb17e51784"}, {file = "distlib-0.3.9-py2.py3-none-any.whl", hash = "sha256:47f8c22fd27c27e25a65601af709b38e4f0a45ea4fc2e710f65755fa8caaaf87"},
{file = "distlib-0.3.8.tar.gz", hash = "sha256:1530ea13e350031b6312d8580ddb6b27a104275a31106523b8f123787f494f64"}, {file = "distlib-0.3.9.tar.gz", hash = "sha256:a60f20dea646b8a33f3e7772f74dc0b2d0772d2837ee1342a00645c81edf9403"},
] ]
[[package]] [[package]]
@ -814,13 +814,13 @@ dev = ["flake8", "markdown", "twine", "wheel"]
[[package]] [[package]]
name = "griffe" 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." 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 optional = false
python-versions = ">=3.8" python-versions = ">=3.9"
files = [ files = [
{file = "griffe-1.3.2-py3-none-any.whl", hash = "sha256:2e34b5e46507d615915c8e6288bb1a2234bd35dee44d01e40a2bc2f25bd4d10c"}, {file = "griffe-1.4.1-py3-none-any.whl", hash = "sha256:84295ee0b27743bd880aea75632830ef02ded65d16124025e4c263bb826ab645"},
{file = "griffe-1.3.2.tar.gz", hash = "sha256:1ec50335aa507ed2445f2dd45a15c9fa3a45f52c9527e880571dfc61912fd60c"}, {file = "griffe-1.4.1.tar.gz", hash = "sha256:911a201b01dc92e08c0e84c38a301e9da5ec067f00e7d9f2e39bc24dbfa3c176"},
] ]
[package.dependencies] [package.dependencies]
@ -1283,13 +1283,13 @@ cache = ["platformdirs"]
[[package]] [[package]]
name = "mkdocs-material" name = "mkdocs-material"
version = "9.5.39" version = "9.5.40"
description = "Documentation that simply works" description = "Documentation that simply works"
optional = false optional = false
python-versions = ">=3.8" python-versions = ">=3.8"
files = [ files = [
{file = "mkdocs_material-9.5.39-py3-none-any.whl", hash = "sha256:0f2f68c8db89523cb4a59705cd01b4acd62b2f71218ccb67e1e004e560410d2b"}, {file = "mkdocs_material-9.5.40-py3-none-any.whl", hash = "sha256:8e7a16ada34e79a7b6459ff2602584222f522c738b6a023d1bea853d5049da6f"},
{file = "mkdocs_material-9.5.39.tar.gz", hash = "sha256:25faa06142afa38549d2b781d475a86fb61de93189f532b88e69bf11e5e5c3be"}, {file = "mkdocs_material-9.5.40.tar.gz", hash = "sha256:b69d70e667ec51fc41f65e006a3184dd00d95b2439d982cb1586e4c018943156"},
] ]
[package.dependencies] [package.dependencies]
@ -1323,13 +1323,13 @@ files = [
[[package]] [[package]]
name = "mkdocstrings" name = "mkdocstrings"
version = "0.26.1" version = "0.26.2"
description = "Automatic documentation from sources, for MkDocs." description = "Automatic documentation from sources, for MkDocs."
optional = false optional = false
python-versions = ">=3.8" python-versions = ">=3.9"
files = [ files = [
{file = "mkdocstrings-0.26.1-py3-none-any.whl", hash = "sha256:29738bfb72b4608e8e55cc50fb8a54f325dc7ebd2014e4e3881a49892d5983cf"}, {file = "mkdocstrings-0.26.2-py3-none-any.whl", hash = "sha256:1248f3228464f3b8d1a15bd91249ce1701fe3104ac517a5f167a0e01ca850ba5"},
{file = "mkdocstrings-0.26.1.tar.gz", hash = "sha256:bb8b8854d6713d5348ad05b069a09f3b79edbc6a0f33a34c6821141adb03fe33"}, {file = "mkdocstrings-0.26.2.tar.gz", hash = "sha256:34a8b50f1e6cfd29546c6c09fbe02154adfb0b361bb758834bf56aa284ba876e"},
] ]
[package.dependencies] [package.dependencies]
@ -1349,13 +1349,13 @@ python-legacy = ["mkdocstrings-python-legacy (>=0.2.1)"]
[[package]] [[package]]
name = "mkdocstrings-python" name = "mkdocstrings-python"
version = "1.11.1" version = "1.12.1"
description = "A Python handler for mkdocstrings." description = "A Python handler for mkdocstrings."
optional = false optional = false
python-versions = ">=3.8" python-versions = ">=3.9"
files = [ files = [
{file = "mkdocstrings_python-1.11.1-py3-none-any.whl", hash = "sha256:a21a1c05acef129a618517bb5aae3e33114f569b11588b1e7af3e9d4061a71af"}, {file = "mkdocstrings_python-1.12.1-py3-none-any.whl", hash = "sha256:205244488199c9aa2a39787ad6a0c862d39b74078ea9aa2be817bc972399563f"},
{file = "mkdocstrings_python-1.11.1.tar.gz", hash = "sha256:8824b115c5359304ab0b5378a91f6202324a849e1da907a3485b59208b797322"}, {file = "mkdocstrings_python-1.12.1.tar.gz", hash = "sha256:60d6a5ca912c9af4ad431db6d0111ce9f79c6c48d33377dde6a05a8f5f48d792"},
] ]
[package.dependencies] [package.dependencies]
@ -1365,13 +1365,13 @@ mkdocstrings = ">=0.26"
[[package]] [[package]]
name = "model-bakery" name = "model-bakery"
version = "1.19.5" version = "1.20.0"
description = "Smart object creation facility for Django." description = "Smart object creation facility for Django."
optional = false optional = false
python-versions = ">=3.8" python-versions = ">=3.8"
files = [ files = [
{file = "model_bakery-1.19.5-py3-none-any.whl", hash = "sha256:09ecbbf124d32614339581b642c82ac4a73147442f598c7bad23eece24187e5c"}, {file = "model_bakery-1.20.0-py3-none-any.whl", hash = "sha256:875326466f5982ee8f0281abdfa774d78893d5473562575dfd5a9304ac7c5b8c"},
{file = "model_bakery-1.19.5.tar.gz", hash = "sha256:37cece544a33f8899ed8f0488cd6a9d2b0b6925e7b478a4ff2786dece8c63745"}, {file = "model_bakery-1.20.0.tar.gz", hash = "sha256:ec9dc846b9a00b20f92df38fac310263323ab61b59b6eeebf77a4aefb0412724"},
] ]
[package.dependencies] [package.dependencies]
@ -1599,13 +1599,13 @@ testing = ["pytest", "pytest-benchmark"]
[[package]] [[package]]
name = "pre-commit" name = "pre-commit"
version = "3.8.0" version = "4.0.1"
description = "A framework for managing and maintaining multi-language pre-commit hooks." description = "A framework for managing and maintaining multi-language pre-commit hooks."
optional = false optional = false
python-versions = ">=3.9" python-versions = ">=3.9"
files = [ files = [
{file = "pre_commit-3.8.0-py2.py3-none-any.whl", hash = "sha256:9a90a53bf82fdd8778d58085faf8d83df56e40dfe18f45b19446e26bf1b3a63f"}, {file = "pre_commit-4.0.1-py2.py3-none-any.whl", hash = "sha256:efde913840816312445dc98787724647c65473daefe420785f885e8ed9a06878"},
{file = "pre_commit-3.8.0.tar.gz", hash = "sha256:8bb6494d4a20423842e198980c9ecf9f96607a07ea29549e180eef9ae80fe7af"}, {file = "pre_commit-4.0.1.tar.gz", hash = "sha256:80905ac375958c0444c65e9cebebd948b3cdb518f335a091a670a89d652139d2"},
] ]
[package.dependencies] [package.dependencies]
@ -2192,58 +2192,70 @@ use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"]
[[package]] [[package]]
name = "rjsmin" name = "rjsmin"
version = "1.2.2" version = "1.2.3"
description = "Javascript Minifier" description = "Javascript Minifier"
optional = false optional = false
python-versions = "*" python-versions = "*"
files = [ files = [
{file = "rjsmin-1.2.2-cp27-cp27m-manylinux1_i686.whl", hash = "sha256:4420107304ba7a00b5b9b56cdcd166b9876b34e626829fc4552c85d8fdc3737a"}, {file = "rjsmin-1.2.3-cp27-cp27m-manylinux1_i686.whl", hash = "sha256:86e4257875d89b0f7968af9e7c0292e72454f6c75031d1818997782b2e8425a8"},
{file = "rjsmin-1.2.2-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:155a2f3312c1f8c6cec7b5080581cafc761dc0e41d64bfb5d46a772c5230ded8"}, {file = "rjsmin-1.2.3-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:f2ab72093591127e627b13c1243d4fef40c10593c733517999682f7f2ebf47ee"},
{file = "rjsmin-1.2.2-cp27-cp27mu-manylinux1_i686.whl", hash = "sha256:88fcb58d65f88cbfa752d51c1ebe5845553f9706def6d9671e98283411575e3e"}, {file = "rjsmin-1.2.3-cp27-cp27mu-manylinux1_i686.whl", hash = "sha256:448b9eb9fd7b6a70beb5c728a41bc23561dd011f0b8fcf7ed9855b6be198c9a2"},
{file = "rjsmin-1.2.2-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:6eae13608b88f4ce32e0557c8fdef58e69bb4d293182202a03e800f0d33b5268"}, {file = "rjsmin-1.2.3-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:ebd3948f9bc912525bab93f61c694b11410296b5fd0806e988d42378ef302b8e"},
{file = "rjsmin-1.2.2-cp310-cp310-manylinux1_i686.whl", hash = "sha256:81f92fb855fb613ebd04a6d6d46483e71fe3c4f22042dc30dcc938fbd748e59c"}, {file = "rjsmin-1.2.3-cp310-cp310-manylinux1_i686.whl", hash = "sha256:823f856b40681328157e5dffc0a588dddefb4b6ce49f79de994dfca6084617be"},
{file = "rjsmin-1.2.2-cp310-cp310-manylinux1_x86_64.whl", hash = "sha256:897db9bf25538047e9388951d532dc291a629b5d041180a8a1a8c102e9d44b90"}, {file = "rjsmin-1.2.3-cp310-cp310-manylinux1_x86_64.whl", hash = "sha256:15e3019f0823a003741ddb93e0c70c5d22567acd0757a7edacc40face1517029"},
{file = "rjsmin-1.2.2-cp310-cp310-manylinux2014_aarch64.whl", hash = "sha256:5938af8c46734f92f74fdc4d0b6324137c0e09f0a8c3825c83e4cfca1b532e40"}, {file = "rjsmin-1.2.3-cp310-cp310-manylinux2014_aarch64.whl", hash = "sha256:dece04e309e29879c12dca8af166ea5d77c497ec932cf82e4a1eb24d1489c398"},
{file = "rjsmin-1.2.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:0424a7b9096fa2b0ab577c4dc7acd683e6cfb5c718ad39a9fb293cb6cbaba95b"}, {file = "rjsmin-1.2.3-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:dd4a1e527568c3a9711ff1d5251763645c14df02d52a45aec089836600b664ea"},
{file = "rjsmin-1.2.2-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:1714ed93c2bd40c5f970905d2eeda4a6844e09087ae11277d4d43b3e68c32a47"}, {file = "rjsmin-1.2.3-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:78aaa3b79a244a4e21164ce355ce22a5a0d7f2d7841a10343009406a3d34d9bb"},
{file = "rjsmin-1.2.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:35596fa6d2d44a5471715c464657123995da78aa6f79bccfbb4b8d6ff7d0a4b4"}, {file = "rjsmin-1.2.3-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:8ea4617618cbf78d98756878a292309f6f54fb4ea1b1ea406f79e88eda4d5d50"},
{file = "rjsmin-1.2.2-cp311-cp311-manylinux1_i686.whl", hash = "sha256:3968667158948355b9a62e9641497aac7ac069c076a595e93199d0fe3a40217a"}, {file = "rjsmin-1.2.3-cp311-cp311-manylinux1_i686.whl", hash = "sha256:85957171184ef2dee1957cef5e4adb93a7e2702c12c30bd74420ebace1756e89"},
{file = "rjsmin-1.2.2-cp311-cp311-manylinux1_x86_64.whl", hash = "sha256:d07d14354694f6a47f572f2aa2a1ad74b76723e62a0d2b6df796138b71888247"}, {file = "rjsmin-1.2.3-cp311-cp311-manylinux1_x86_64.whl", hash = "sha256:b6485014e9cbec9a41fb4a7b96ce511ab45a5db8c54ca57ad610f53747e7bab1"},
{file = "rjsmin-1.2.2-cp311-cp311-manylinux2014_aarch64.whl", hash = "sha256:a78dfa6009235b902454ac53264252b7b94f1e43e3a9e97c4cadae88e409b882"}, {file = "rjsmin-1.2.3-cp311-cp311-manylinux2014_aarch64.whl", hash = "sha256:64ac6ef8753c56179a53e237ea4d2b3ccdef88b8b51141618311d48e31013207"},
{file = "rjsmin-1.2.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:9b7a45001e58243a455d11d2de925cadb8c2a0dc737001de646a0f4d90cf0034"}, {file = "rjsmin-1.2.3-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:dbd5f653b5ebcd4920793009ffa210ad5523c523e39e45ee1a0770e4323126dc"},
{file = "rjsmin-1.2.2-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:86c5e657b74b6c9482bb96f18a79d61750f4e8204759cce179f7eb17d395c683"}, {file = "rjsmin-1.2.3-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:b0174d7786dcebab808485d1c27f049c74b97590cddcd62f6ed54796a2c6503b"},
{file = "rjsmin-1.2.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:8c2c30b86c7232443a4a726e1bbee34f800556e581e95fc07194ecbf8e02d1d2"}, {file = "rjsmin-1.2.3-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:6cf24720ea214cbffa0ed96ba0dc24a5cf3ff3cbf59d44a1018292424b48452a"},
{file = "rjsmin-1.2.2-cp312-cp312-manylinux1_i686.whl", hash = "sha256:8982c3ef27fac26dd6b7d0c55ae98fa550fee72da2db010b87211e4b5dd78a67"}, {file = "rjsmin-1.2.3-cp312-cp312-manylinux1_i686.whl", hash = "sha256:ac911d1a12a6d7879ba52e08c56b0ad1a74377bae52610ea74f0f9d936d41785"},
{file = "rjsmin-1.2.2-cp312-cp312-manylinux1_x86_64.whl", hash = "sha256:3fc27ae4ece99e2c994cd79df2f0d3f7ac650249f632d19aa8ce85118e33bf0f"}, {file = "rjsmin-1.2.3-cp312-cp312-manylinux1_x86_64.whl", hash = "sha256:57a0b2f13402623e4ec44eb7ad8846387b2d5605aa8732a05ebefb2289c24b96"},
{file = "rjsmin-1.2.2-cp312-cp312-manylinux2014_aarch64.whl", hash = "sha256:41113d8d6cae7f7406b30143cc49cc045bbb3fadc2f28df398cea30e1daa60b1"}, {file = "rjsmin-1.2.3-cp312-cp312-manylinux2014_aarch64.whl", hash = "sha256:e28610cca3ab03e43113eadad4f7dd9ea235ddc29a8dc5462bb161a80e5d251f"},
{file = "rjsmin-1.2.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:3aa09a89b2b7aa2b9251329fe0c3e36c2dc2f10f78b8811e5be92a072596348b"}, {file = "rjsmin-1.2.3-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:d4afb4fc3624dc44a7fbae4e41c0b5dc5d861a7f5de865ad463041ec1b5d835c"},
{file = "rjsmin-1.2.2-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:5abb8d1241f4ea97950b872fa97a422ba8413fe02358f64128ff0cf745017f07"}, {file = "rjsmin-1.2.3-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:ca26b80c7e63cf0788b41571a4bd08d175df7719364e0dd9a3cf7b6cb1ab834c"},
{file = "rjsmin-1.2.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:5abc686a9ef7eaf208f9ad1fb5fb949556ecb7cc1fee27290eb7f194e01d97bd"}, {file = "rjsmin-1.2.3-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:fcc22001588b84d34bbf2c77afa519894244150c4b0754a6e573298ffac24666"},
{file = "rjsmin-1.2.2-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:076adcf04c34f712c9427fd9ba6a75bbf7aab975650dfc78cbdd0fbdbe49ca63"}, {file = "rjsmin-1.2.3-cp313-cp313-manylinux1_i686.whl", hash = "sha256:624d1a0a35122f3f8955d160a39305cf6f786a5b346ee34c516b391cb153a106"},
{file = "rjsmin-1.2.2-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:8cb8947ddd250fce58261b0357846cd5d55419419c0f7dfb131dc4b733579a26"}, {file = "rjsmin-1.2.3-cp313-cp313-manylinux1_x86_64.whl", hash = "sha256:72bd04b7db6190339d8214a5fd289ca31fc1ed30a240f8b0ca13acb9ce3a88af"},
{file = "rjsmin-1.2.2-cp36-cp36m-manylinux2014_aarch64.whl", hash = "sha256:9069c48b6508b9c5b05435e2c6042c2a0e2f97b35d7b9c27ceaea5fd377ffdc5"}, {file = "rjsmin-1.2.3-cp313-cp313-manylinux2014_aarch64.whl", hash = "sha256:7559f59f4749519b92d72bb68e33b68463f479a82a2a739f1b28a853067aa0e7"},
{file = "rjsmin-1.2.2-cp36-cp36m-musllinux_1_1_aarch64.whl", hash = "sha256:02b61cf9b6bc518fdac667f3ca3dab051cb8bd1bf4cba28b6d29153ec27990ad"}, {file = "rjsmin-1.2.3-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:aa8bdecf278f754d1a133ab51119a63a4d38500557912bb0930ae0fd61437ec6"},
{file = "rjsmin-1.2.2-cp36-cp36m-musllinux_1_1_i686.whl", hash = "sha256:09eca8581797244587916e5e07e36c4c86d54a4b7e5c7697484a95b75803515d"}, {file = "rjsmin-1.2.3-cp313-cp313-musllinux_1_1_i686.whl", hash = "sha256:2078acc2d02a005ef122eb330e941462c8c3102cf798ad49f1c5ec18ac714240"},
{file = "rjsmin-1.2.2-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:c52b9dd45c837f1c5c2e8d40776f9e63257f8dbd5f79b85f648cc70da6c1e4e9"}, {file = "rjsmin-1.2.3-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:fa40584fddb4f1d2236119505f6c2fe2b57a1ebaf6eaee2bb2eaac33d2a4ca73"},
{file = "rjsmin-1.2.2-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:4fe4ce990412c053a6bcd47d55133927e22fd3d100233d73355f60f9053054c5"}, {file = "rjsmin-1.2.3-cp313-cp313t-manylinux1_i686.whl", hash = "sha256:bbe5d8340878b38dd4f7b879ed7728f6fc3d7524ad81a5cfbe4eb8ae63951407"},
{file = "rjsmin-1.2.2-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:aa883b9363b5134239066060879d5eb422a0d4ccf24ccf871f65a5b34c64926f"}, {file = "rjsmin-1.2.3-cp313-cp313t-manylinux1_x86_64.whl", hash = "sha256:c298c93f5633cf894325907cf49fc7fb010c0f75dc9cda90b0fc1684ad19e5a3"},
{file = "rjsmin-1.2.2-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:6f4e95c5ac95b4cbb519917b3aa1d3d92fc6939c371637674c4a42b67b2b3f44"}, {file = "rjsmin-1.2.3-cp313-cp313t-manylinux2014_aarch64.whl", hash = "sha256:35f18cffe3f1bf6d96bcfd977199378ebfd641d823b08e235d1e0bb0fbaa5532"},
{file = "rjsmin-1.2.2-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:ae3cd64e18e62aa330b24dd6f7b9809ce0a694afd1f01fe99c21f9acd1cb0ea6"}, {file = "rjsmin-1.2.3-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:9aeadf4dd5f941bebf110fe83960a4bafdac176647537819bb7662f5e9a37aaa"},
{file = "rjsmin-1.2.2-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:7999d797fcf805844d2d91598651785497249f592f31674da0964e794b3be019"}, {file = "rjsmin-1.2.3-cp313-cp313t-musllinux_1_1_i686.whl", hash = "sha256:c3219e6e22897b31c8598cb412ed56bc12a722c1d4f88a71710c16efe8c07d0c"},
{file = "rjsmin-1.2.2-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:e733fea039a7b5ad7c06cc8bf215ee7afac81d462e273b3ab55c1ccc906cf127"}, {file = "rjsmin-1.2.3-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:bceccb06b118be890fe735fc09ee256851f4993708cb3647f6c71dd0151cce89"},
{file = "rjsmin-1.2.2-cp38-cp38-manylinux1_i686.whl", hash = "sha256:ccca74461bd53a99ff3304fcf299ea861df89846be3207329cb82d717ce47ea6"}, {file = "rjsmin-1.2.3-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:f3620271f00b8ba3c7c5134ca1d99cde5fd1bf1e84aa96aa65c177ee634122f7"},
{file = "rjsmin-1.2.2-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:88f59ad24f91bf9c25d5c2ca3c84a72eed0028f57a98e3b85a915ece5c25be1e"}, {file = "rjsmin-1.2.3-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:f3d86f70fcca5f68b65eabbce365d07d80404ecd6aa9c55ba9e9f1042a3514c7"},
{file = "rjsmin-1.2.2-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:7a8b56fbd64adcc4402637f0e07b90b441e9981d720a10eb6265118018b42682"}, {file = "rjsmin-1.2.3-cp36-cp36m-manylinux2014_aarch64.whl", hash = "sha256:1dae9230eae6d7eb2820a511cb640ca6f2e5b91ff78805d71332e8a65a898ea1"},
{file = "rjsmin-1.2.2-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:2c24686cfdf86e55692183f7867e72c9e982add479c244eda7b8390f96db2c6c"}, {file = "rjsmin-1.2.3-cp36-cp36m-musllinux_1_1_aarch64.whl", hash = "sha256:b788c3ec9d68d8fda2240eb7831bdfb2cc0c88d5fb38c9ed6e0fd090eb5d1490"},
{file = "rjsmin-1.2.2-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:6c0d9f9ea8d9cd48cbcdc74a1c2e85d4d588af12bb8f0b672070ae7c9b6e6306"}, {file = "rjsmin-1.2.3-cp36-cp36m-musllinux_1_1_i686.whl", hash = "sha256:4763efbfad7fbf3240a33f08f64991bf0db07453caf283eea51ade84053e9bb7"},
{file = "rjsmin-1.2.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:27abd32c9f5b6e0c0a3bcad43e8e24108c6d6c13a4e6c50c97497ea2b4614bb4"}, {file = "rjsmin-1.2.3-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:e1379e448da75e2426205c756e79d7b9ba1b7ed616fe97122d72c3fe054e8cac"},
{file = "rjsmin-1.2.2-cp39-cp39-manylinux1_i686.whl", hash = "sha256:e0e009f6f8460901f5144b34ac2948f94af2f9b8c9b5425da705dbc8152c36c2"}, {file = "rjsmin-1.2.3-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:27e134f4d91a5986cba6dced5cb539947a3ec61544ab5ef31b74b384ddc03931"},
{file = "rjsmin-1.2.2-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:41e6013cb37a5b3563c19aa35f8e659fa536aa4197a0e3b6a57a381638294a15"}, {file = "rjsmin-1.2.3-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:2674fcad70d0fab4c1c71e4ac1d4d67935f67e6ecc3924de0dd1264c80a9f9a2"},
{file = "rjsmin-1.2.2-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:62cbd38c9f5090f0a6378a45c415b4f96ae871216cedab0dfa21965620c0be4c"}, {file = "rjsmin-1.2.3-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:b1f2540bd0ce7eda326df7b3bfa360f6edd526bfcb959b5d136afdbccddf0765"},
{file = "rjsmin-1.2.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:2fd5254d36f10a17564b63e8bf9ac579c7b5f211364e11e9753ff5b562843c67"}, {file = "rjsmin-1.2.3-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:23f3b4adde995a0d0b7835558840dd4673adf99d2473b6d40474d30801d6c57b"},
{file = "rjsmin-1.2.2-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:6cf0309d001a0d45d731dbaab1afd0c23d135c9e029fe56c935c1798094686fc"}, {file = "rjsmin-1.2.3-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:c5fb0574eb541d374a2751e9c0ae019fdd86c9e3eb2e7cf893756886e7b3923f"},
{file = "rjsmin-1.2.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:bfbe333dab8d23f0a71da90e2d8e8b762a739cbd55a6f948b2dfda089b6d5853"}, {file = "rjsmin-1.2.3-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:18d6a3229d1ed511a0b0a9a7271ef58ff3b02ba408b92b426857b33b137e7f15"},
{file = "rjsmin-1.2.2.tar.gz", hash = "sha256:8c1bcd821143fecf23242012b55e13610840a839cd467b358f16359010d62dae"}, {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]] [[package]]
@ -2679,4 +2691,4 @@ filelock = ">=3.4"
[metadata] [metadata]
lock-version = "2.0" lock-version = "2.0"
python-versions = "^3.12" 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 # deps used for development purposes, but unneeded in prod
django-debug-toolbar = "^4.4.6" django-debug-toolbar = "^4.4.6"
ipython = "^8.26.0" 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 ruff = "^0.6.9" # Version used in pipeline is controlled by pre-commit hooks in .pre-commit.config.yaml
djhtml = "^3.0.6" djhtml = "^3.0.6"
faker = "^30.3.0" faker = "^30.3.0"
@ -77,14 +77,14 @@ freezegun = "^1.5.1" # used to test time-dependent code
pytest = "^8.3.2" pytest = "^8.3.2"
pytest-cov = "^5.0.0" pytest-cov = "^5.0.0"
pytest-django = "^4.9.0" pytest-django = "^4.9.0"
model-bakery = "^1.19.5" model-bakery = "^1.20.0"
[tool.poetry.group.docs.dependencies] [tool.poetry.group.docs.dependencies]
# deps used to work on the documentation # deps used to work on the documentation
mkdocs = "^1.6.1" mkdocs = "^1.6.1"
mkdocs-material = "^9.5.39" mkdocs-material = "^9.5.40"
mkdocstrings = "^0.26.1" mkdocstrings = "^0.26.2"
mkdocstrings-python = "^1.11.1" mkdocstrings-python = "^1.12.0"
mkdocs-include-markdown-plugin = "^6.2.2" mkdocs-include-markdown-plugin = "^6.2.2"
[tool.poetry.group.docs] [tool.poetry.group.docs]
@ -101,17 +101,30 @@ select = [
"A", # shadowing of Python builtins "A", # shadowing of Python builtins
"B", "B",
"C4", # use comprehensions when possible "C4", # use comprehensions when possible
"I", # isort
"DJ", # django-specific rules, "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 "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) "UP008", # Use super() instead of super(__class__, self)
"UP009", # utf-8 encoding declaration is unnecessary "UP009", # utf-8 encoding declaration is unnecessary
"T2", # print statements
] ]
ignore = [ ignore = [
"DJ001", # null=True in CharField/TextField. this one would require a migration "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] [tool.ruff.lint.pydocstyle]

View File

@ -44,8 +44,8 @@ class Command(BaseCommand):
exit(1) exit(1)
confirm = input( confirm = input(
"User selected: %s\nDo you really want to delete all message from this user ? [y/N] " "User selected: %s\nDo you really want "
% (user,) "to delete all message from this user ? [y/N] " % (user,)
) )
if not confirm.lower().startswith("y"): 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) self.to_keep = User.objects.get(pk=self.to_keep.pk)
# fields of to_delete should be assigned to to_keep # fields of to_delete should be assigned to to_keep
# if they were not set beforehand # if they were not set beforehand
assert "Biggus" == self.to_keep.first_name assert self.to_keep.first_name == "Biggus"
assert "Dickus" == self.to_keep.last_name assert self.to_keep.last_name == "Dickus"
assert "B'ian" == self.to_keep.nick_name assert self.to_keep.nick_name == "B'ian"
assert "Jerusalem" == self.to_keep.address assert self.to_keep.address == "Jerusalem"
assert "Rome" == self.to_keep.parent_address assert self.to_keep.parent_address == "Rome"
assert self.to_keep.groups.count() == 3 assert self.to_keep.groups.count() == 3
groups = sorted(self.to_keep.groups.all(), key=lambda i: i.id) groups = sorted(self.to_keep.groups.all(), key=lambda i: i.id)
expected = sorted([subscribers, mde_admin, sas_admin], 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 django.urls import path
from rootplace.views import * from rootplace.views import (
DeleteAllForumUserMessagesView,
MergeUsersView,
OperationLogListView,
)
urlpatterns = [ urlpatterns = [
path("merge/", MergeUsersView.as_view(), name="merge"), path("merge/", MergeUsersView.as_view(), name="merge"),

View File

@ -48,7 +48,8 @@ def __merge_subscriptions(u1: User, u2: User):
Some examples : Some examples :
- if u1 is not subscribed, his subscription end date become the one of u2 - 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 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 he shall then be subscribed for 5 months
""" """
last_subscription = ( last_subscription = (

View File

@ -15,7 +15,7 @@
from django.contrib import admin from django.contrib import admin
from sas.models import Album, PeoplePictureRelation, Picture from sas.models import Album, PeoplePictureRelation, Picture, PictureModerationRequest
@admin.register(Picture) @admin.register(Picture)
@ -31,4 +31,15 @@ class PeoplePictureRelationAdmin(admin.ModelAdmin):
autocomplete_fields = ("picture", "user") 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 ninja_extra.schemas import PaginatedResponseSchema
from pydantic import NonNegativeInt 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 core.models import Notification, User
from sas.models import PeoplePictureRelation, Picture 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") @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): def delete_picture(self, picture_id: int):
self.get_object_or_exception(Picture, pk=picture_id).delete() 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): 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 = self.get_object_or_exception(Picture, pk=picture_id)
picture.moderation_requests.all().delete()
picture.is_moderated = True picture.is_moderated = True
picture.moderator = self.context.request.user picture.moderator = self.context.request.user
picture.asked_for_removal = False picture.asked_for_removal = False
picture.save() 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") @api_controller("/sas/relation", tags="User identification on SAS pictures")
class UsersIdentifiedController(ControllerBase): 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 from __future__ import annotations
import contextlib
from io import BytesIO from io import BytesIO
from pathlib import Path from pathlib import Path
from typing import ClassVar, Self from typing import ClassVar, Self
@ -108,10 +109,8 @@ class Picture(SasFile):
def generate_thumbnails(self, *, overwrite=False): def generate_thumbnails(self, *, overwrite=False):
im = Image.open(BytesIO(self.file.read())) im = Image.open(BytesIO(self.file.read()))
try: with contextlib.suppress(Exception):
im = exif_auto_rotate(im) im = exif_auto_rotate(im)
except:
pass
# convert the compressed image and the thumbnail into webp # convert the compressed image and the thumbnail into webp
# The original image keeps its original type, because it's not # 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 # meant to be shown on the website, but rather to keep the real image
@ -273,16 +272,12 @@ class PeoplePictureRelation(models.Model):
User, User,
verbose_name=_("user"), verbose_name=_("user"),
related_name="pictures", related_name="pictures",
null=False,
blank=False,
on_delete=models.CASCADE, on_delete=models.CASCADE,
) )
picture = models.ForeignKey( picture = models.ForeignKey(
Picture, Picture,
verbose_name=_("picture"), verbose_name=_("picture"),
related_name="people", related_name="people",
null=False,
blank=False,
on_delete=models.CASCADE, on_delete=models.CASCADE,
) )
@ -290,4 +285,39 @@ class PeoplePictureRelation(models.Model):
unique_together = ["user", "picture"] unique_together = ["user", "picture"]
def __str__(self): 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 ninja import FilterSchema, ModelSchema, Schema
from pydantic import Field, NonNegativeInt from pydantic import Field, NonNegativeInt
from core.schemas import UserProfileSchema from core.schemas import SimpleUserSchema, UserProfileSchema
from sas.models import Picture from sas.models import Picture, PictureModerationRequest
class PictureFilterSchema(FilterSchema): class PictureFilterSchema(FilterSchema):
@ -52,3 +52,11 @@ class PictureRelationCreationSchema(Schema):
class IdentifiedUserSchema(Schema): class IdentifiedUserSchema(Schema):
id: int id: int
user: UserProfileSchema user: UserProfileSchema
class ModerationRequestSchema(ModelSchema):
author: SimpleUserSchema
class Meta:
model = PictureModerationRequest
fields = ["id", "created_at", "reason"]

View File

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

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 { exportToHtml } from "#core:utils/globals";
import { History } from "#core:utils/history"; import { History } from "#core:utils/history";
import { import type TomSelect from "tom-select";
type AjaxResponse,
type RemoteResult,
remoteDataSource,
sithSelect2,
} from "#core:utils/select2";
import { import {
type IdentifiedUserSchema, type IdentifiedUserSchema,
type PictureSchema, type PictureSchema,
type PicturesFetchIdentificationsResponse, type PicturesFetchIdentificationsResponse,
type PicturesFetchModerationRequestsResponse,
type PicturesFetchPicturesData, type PicturesFetchPicturesData,
type UserProfileSchema, type UserProfileSchema,
picturesDeletePicture, picturesDeletePicture,
picturesFetchIdentifications, picturesFetchIdentifications,
picturesFetchModerationRequests,
picturesFetchPictures, picturesFetchPictures,
picturesIdentifyUsers, picturesIdentifyUsers,
picturesModeratePicture, picturesModeratePicture,
userSearchUsers,
usersidentifiedDeleteRelation, usersidentifiedDeleteRelation,
} from "#openapi"; } from "#openapi";
@ -27,18 +23,20 @@ import {
* able to prefetch its data. * able to prefetch its data.
*/ */
class PictureWithIdentifications { class PictureWithIdentifications {
identifications: PicturesFetchIdentificationsResponse | null = null; identifications: PicturesFetchIdentificationsResponse = null;
imageLoading = false; imageLoading = false;
identificationsLoading = false; identificationsLoading = false;
moderationLoading = false;
id: number; id: number;
// biome-ignore lint/style/useNamingConvention: api is in snake_case // biome-ignore lint/style/useNamingConvention: api is in snake_case
compressed_url: string; compressed_url: string;
moderationRequests: PicturesFetchModerationRequestsResponse = null;
constructor(picture: PictureSchema) { constructor(picture: PictureSchema) {
Object.assign(this, picture); Object.assign(this, picture);
} }
static fromPicture(picture: PictureSchema) { static fromPicture(picture: PictureSchema): PictureWithIdentifications {
return new PictureWithIdentifications(picture); return new PictureWithIdentifications(picture);
} }
@ -46,7 +44,7 @@ class PictureWithIdentifications {
* If not already done, fetch the users identified on this picture and * If not already done, fetch the users identified on this picture and
* populate the identifications field * populate the identifications field
*/ */
async loadIdentifications(options?: { forceReload: boolean }) { async loadIdentifications(options?: { forceReload: boolean }): Promise<void> {
if (this.identificationsLoading) { if (this.identificationsLoading) {
return; // The users are already being fetched. return; // The users are already being fetched.
} }
@ -65,11 +63,29 @@ class PictureWithIdentifications {
this.identificationsLoading = false; 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 * Preload the photo and the identifications
* @return {Promise<void>}
*/ */
async preload() { async preload(): Promise<void> {
const img = new Image(); const img = new Image();
img.src = this.compressed_url; img.src = this.compressed_url;
if (!img.complete) { if (!img.complete) {
@ -87,12 +103,12 @@ interface ViewerConfig {
userId: number; userId: number;
/** Url of the current album */ /** Url of the current album */
albumUrl: string; albumUrl: string;
/** Id of the album to displlay */ /** Id of the album to display */
albumId: number; albumId: number;
/** id of the first picture to load on the page */ /** id of the first picture to load on the page */
firstPictureId: number; firstPictureId: number;
/** if the user is sas admin */ /** if the user is sas admin */
userIsSasAdmin: number; userIsSasAdmin: boolean;
} }
/** /**
@ -103,9 +119,8 @@ exportToHtml("loadViewer", (config: ViewerConfig) => {
Alpine.data("picture_viewer", () => ({ Alpine.data("picture_viewer", () => ({
/** /**
* All the pictures that can be displayed on this picture viewer * All the pictures that can be displayed on this picture viewer
* @type PictureWithIdentifications[]
**/ **/
pictures: [], pictures: [] as PictureWithIdentifications[],
/** /**
* The currently displayed picture * The currently displayed picture
* Default dummy data are pre-loaded to avoid javascript error * Default dummy data are pre-loaded to avoid javascript error
@ -131,14 +146,12 @@ exportToHtml("loadViewer", (config: ViewerConfig) => {
}, },
/** /**
* The picture which will be displayed next if the user press the "next" button * The picture which will be displayed next if the user press the "next" button
* @type ?PictureWithIdentifications
**/ **/
nextPicture: null, nextPicture: null as PictureWithIdentifications,
/** /**
* The picture which will be displayed next if the user press the "previous" button * The picture which will be displayed next if the user press the "previous" button
* @type ?PictureWithIdentifications
**/ **/
previousPicture: null, previousPicture: null as PictureWithIdentifications,
/** /**
* The select2 component used to identify users * The select2 component used to identify users
**/ **/
@ -148,13 +161,11 @@ exportToHtml("loadViewer", (config: ViewerConfig) => {
**/ **/
/** /**
* Error message when a moderation operation fails * Error message when a moderation operation fails
* @type string
**/ **/
moderationError: "", moderationError: "",
/** /**
* Method of pushing new url to the browser history * Method of pushing new url to the browser history
* Used by popstate event and always reset to it's default value when used * Used by popstate event and always reset to it's default value when used
* @type History
**/ **/
pushstate: History.Push, pushstate: History.Push,
@ -165,20 +176,21 @@ exportToHtml("loadViewer", (config: ViewerConfig) => {
query: { album_id: config.albumId }, query: { album_id: config.albumId },
} as PicturesFetchPicturesData) } as PicturesFetchPicturesData)
).map(PictureWithIdentifications.fromPicture); ).map(PictureWithIdentifications.fromPicture);
this.selector = sithSelect2({ this.selector = this.$refs.search;
element: $(this.$refs.search) as unknown as HTMLElement, this.selector.filter = (users: UserProfileSchema[]) => {
dataSource: remoteDataSource(await makeUrl(userSearchUsers), { const resp: UserProfileSchema[] = [];
excluded: () => [ const ids = [
...(this.currentPicture.identifications || []).map( ...(this.currentPicture.identifications || []).map(
(i: IdentifiedUserSchema) => i.user.id, (i: IdentifiedUserSchema) => i.user.id,
), ),
], ];
resultConverter: (obj: AjaxResponse) => { for (const user of users) {
return { ...obj, text: (obj as UserProfileSchema).display_name }; if (!ids.includes(user.id)) {
}, resp.push(user);
}), }
pictureGetter: (user: RemoteResult) => user.profile_pict, }
}); return resp;
};
this.currentPicture = this.pictures.find( this.currentPicture = this.pictures.find(
(i: PictureSchema) => i.id === config.firstPictureId, (i: PictureSchema) => i.id === config.firstPictureId,
); );
@ -213,7 +225,7 @@ exportToHtml("loadViewer", (config: ViewerConfig) => {
* and the previous picture, the next picture and * and the previous picture, the next picture and
* the list of identified users are updated. * the list of identified users are updated.
*/ */
async updatePicture() { async updatePicture(): Promise<void> {
const updateArgs = { const updateArgs = {
data: { sasPictureId: this.currentPicture.id }, data: { sasPictureId: this.currentPicture.id },
unused: "", unused: "",
@ -231,16 +243,23 @@ exportToHtml("loadViewer", (config: ViewerConfig) => {
} }
this.moderationError = ""; 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.previousPicture = this.pictures[index - 1] || null;
this.nextPicture = this.pictures[index + 1] || null; this.nextPicture = this.pictures[index + 1] || null;
await this.currentPicture.loadIdentifications();
this.$refs.mainPicture?.addEventListener("load", () => { this.$refs.mainPicture?.addEventListener("load", () => {
// once the current picture is loaded, // once the current picture is loaded,
// start preloading the next and previous pictures // start preloading the next and previous pictures
this.nextPicture?.preload(); this.nextPicture?.preload();
this.previousPicture?.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() { async moderatePicture() {
@ -253,7 +272,7 @@ exportToHtml("loadViewer", (config: ViewerConfig) => {
return; return;
} }
this.currentPicture.is_moderated = true; this.currentPicture.is_moderated = true;
this.currentPicture.askedForRemoval = false; this.currentPicture.asked_for_removal = false;
}, },
async deletePicture() { async deletePicture() {
@ -277,33 +296,34 @@ exportToHtml("loadViewer", (config: ViewerConfig) => {
/** /**
* Send the identification request and update the list of identified users. * 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({ await picturesIdentifyUsers({
path: { path: {
// biome-ignore lint/style/useNamingConvention: api is in snake_case // biome-ignore lint/style/useNamingConvention: api is in snake_case
picture_id: this.currentPicture.id, 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 // refresh the identified users list
await this.currentPicture.loadIdentifications({ forceReload: true }); 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 * 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; return config.userIsSasAdmin || identification.user.id === config.userId;
}, },
/** /**
* Untag a user from the current picture * Untag a user from the current picture
* @param {PictureIdentification} identification
*/ */
async removeIdentification(identification: IdentifiedUserSchema) { async removeIdentification(identification: IdentifiedUserSchema): Promise<void> {
const res = await usersidentifiedDeleteRelation({ const res = await usersidentifiedDeleteRelation({
// biome-ignore lint/style/useNamingConvention: api is in snake_case // biome-ignore lint/style/useNamingConvention: api is in snake_case
path: { relation_id: identification.id }, 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" %} {% extends "core/base.jinja" %}
{%- block additional_css -%} {%- 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('sas/css/picture.scss') }}">
<link rel="stylesheet" href="{{ static('webpack/sas/viewer-index.css') }}" defer>
{%- endblock -%} {%- endblock -%}
{%- block additional_js -%} {%- block additional_js -%}
<script defer src="{{ static('webpack/ajax-select-index.ts') }}"></script>
<script defer src="{{ static("webpack/sas/viewer-index.ts") }}"></script> <script defer src="{{ static("webpack/sas/viewer-index.ts") }}"></script>
{%- endblock -%} {%- endblock -%}
@ -30,10 +31,10 @@
<br> <br>
<template x-if="!currentPicture.is_moderated"> <template x-if="!currentPicture.is_moderated">
<div class="alert alert-red"> <div class="alert alert-red" @click="console.log(currentPicture)">
<div class="alert-main"> <div class="alert-main">
<template x-if="currentPicture.askedForRemoval"> <template x-if="currentPicture.asked_for_removal">
<span class="important">{% trans %}Asked for removal{% endtrans %}</span> <h3 class="alert-title">{% trans %}Asked for removal{% endtrans %}</h3>
</template> </template>
<p> <p>
{% trans trimmed %} {% trans trimmed %}
@ -41,16 +42,33 @@
It will be hidden to other users until it has been moderated. It will be hidden to other users until it has been moderated.
{% endtrans %} {% endtrans %}
</p> </p>
<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> <div class="alert-aside">
<div> <button class="btn btn-blue" @click="moderatePicture()">
<button class="btn btn-blue" @click="moderatePicture()"> {% trans %}Moderate{% endtrans %}
{% trans %}Moderate{% endtrans %} </button>
</button> <button class="btn btn-red" @click.prevent="deletePicture()">
<button class="btn btn-red" @click.prevent="deletePicture()"> {% trans %}Delete{% endtrans %}
{% trans %}Delete{% endtrans %} </button>
</button>
</div>
<p x-show="!!moderationError" x-text="moderationError"></p> <p x-show="!!moderationError" x-text="moderationError"></p>
</div> </div>
</div> </div>
@ -58,7 +76,6 @@
<div class="container" id="pict"> <div class="container" id="pict">
<div class="main"> <div class="main">
<div class="photo" :aria-busy="currentPicture.imageLoading"> <div class="photo" :aria-busy="currentPicture.imageLoading">
<img <img
:src="currentPicture.compressed_url" :src="currentPicture.compressed_url"
@ -96,7 +113,9 @@
{% trans %}HD version{% endtrans %} {% trans %}HD version{% endtrans %}
</a> </a>
<br> <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>
<div class="buttons"> <div class="buttons">
<a class="button" :href="`/sas/picture/${currentPicture.id}/edit/`"><i class="fa-regular fa-pen-to-square edit-action"></i></a> <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> <h5>{% trans %}People{% endtrans %}</h5>
{% if user.was_subscribed %} {% if user.was_subscribed %}
<form @submit.prevent="submitIdentification" x-show="!!selector"> <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 %}"/> <input type="submit" value="{% trans %}Go{% endtrans %}"/>
</form> </form>
{% endif %} {% endif %}

View File

@ -3,35 +3,45 @@ from django.db import transaction
from django.test import TestCase from django.test import TestCase
from django.urls import reverse from django.urls import reverse
from model_bakery import baker from model_bakery import baker
from model_bakery.recipe import Recipe
from pytest_django.asserts import assertNumQueries from pytest_django.asserts import assertNumQueries
from core.baker_recipes import old_subscriber_user, subscriber_user 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.baker_recipes import picture_recipe
from sas.models import Album, PeoplePictureRelation, Picture from sas.models import Album, PeoplePictureRelation, Picture, PictureModerationRequest
class TestSas(TestCase): class TestSas(TestCase):
@classmethod @classmethod
def setUpTestData(cls): 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") owner = User.objects.get(username="root")
cls.user_a = old_subscriber_user.make() cls.user_a = old_subscriber_user.make()
cls.user_b, cls.user_c = subscriber_user.make(_quantity=2) cls.user_b, cls.user_c = subscriber_user.make(_quantity=2)
picture = picture_recipe.extend(owner=owner) picture = picture_recipe.extend(owner=owner)
cls.album_a = baker.make(Album, is_in_sas=True) cls.album_a = baker.make(Album, is_in_sas=True, parent=sas)
cls.album_b = baker.make(Album, is_in_sas=True) 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: for album in cls.album_a, cls.album_b:
pictures = picture.make(parent=album, _quantity=5, _bulk_create=True) pictures = picture.make(parent=album, _quantity=5, _bulk_create=True)
baker.make(PeoplePictureRelation, picture=pictures[1], user=cls.user_a) relations.extend(
baker.make(PeoplePictureRelation, picture=pictures[2], user=cls.user_a) [
baker.make(PeoplePictureRelation, picture=pictures[2], user=cls.user_b) relation_recipe.prepare(picture=pictures[1], user=cls.user_a),
baker.make(PeoplePictureRelation, picture=pictures[3], user=cls.user_b) relation_recipe.prepare(picture=pictures[2], user=cls.user_a),
baker.make(PeoplePictureRelation, picture=pictures[4], user=cls.user_a) relation_recipe.prepare(picture=pictures[2], user=cls.user_b),
baker.make(PeoplePictureRelation, picture=pictures[4], user=cls.user_b) relation_recipe.prepare(picture=pictures[3], user=cls.user_b),
baker.make(PeoplePictureRelation, picture=pictures[4], user=cls.user_c) 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): class TestPictureSearch(TestSas):
@ -170,3 +180,49 @@ class TestPictureRelation(TestSas):
res = self.client.delete(f"/api/sas/relation/{relation.id}") res = self.client.delete(f"/api/sas/relation/{relation.id}")
assert res.status_code == 404 assert res.status_code == 404
assert PeoplePictureRelation.objects.count() == relation_count 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.test import Client, TestCase
from django.urls import reverse from django.urls import reverse
from model_bakery import baker from model_bakery import baker
from pytest_django.asserts import assertRedirects from pytest_django.asserts import assertInHTML, assertRedirects
from core.baker_recipes import old_subscriber_user, subscriber_user from core.baker_recipes import old_subscriber_user, subscriber_user
from core.models import RealGroup, User from core.models import RealGroup, User
@ -70,7 +70,9 @@ def test_album_access_non_subscriber(client: Client):
class TestSasModeration(TestCase): class TestSasModeration(TestCase):
@classmethod @classmethod
def setUpTestData(cls): 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( cls.pictures = picture_recipe.make(
parent=album, _quantity=10, _bulk_create=True parent=album, _quantity=10, _bulk_create=True
) )
@ -82,6 +84,9 @@ class TestSasModeration(TestCase):
) )
cls.simple_user = subscriber_user.make() cls.simple_user = subscriber_user.make()
def setUp(self):
cache.clear()
def test_moderation_page_sas_admin(self): def test_moderation_page_sas_admin(self):
"""Test that a moderator can see the pictures needing moderation.""" """Test that a moderator can see the pictures needing moderation."""
self.client.force_login(self.moderator) self.client.force_login(self.moderator)
@ -132,3 +137,37 @@ class TestSasModeration(TestCase):
) )
assert res.status_code == 403 assert res.status_code == 403
assert Picture.objects.filter(pk=self.to_moderate.id).exists() 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 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 = [ urlpatterns = [
path("", SASMainView.as_view(), name="main"), path("", SASMainView.as_view(), name="main"),
path("moderation/", ModerationView.as_view(), name="moderation"), path("moderation/", ModerationView.as_view(), name="moderation"),
path("album/<int:album_id>/", AlbumView.as_view(), name="album"), path("album/<int:album_id>/", AlbumView.as_view(), name="album"),
path( path(
"album/<int:album_id>/upload/", "album/<int:album_id>/upload/", AlbumUploadView.as_view(), name="album_upload"
AlbumUploadView.as_view(),
name="album_upload",
), ),
path("album/<int:album_id>/edit/", AlbumEditView.as_view(), name="album_edit"), path("album/<int:album_id>/edit/", AlbumEditView.as_view(), name="album_edit"),
path("album/<int:album_id>/preview/", send_album, name="album_preview"), 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>/", PictureView.as_view(), name="picture"),
path( path(
"picture/<int:picture_id>/edit/", "picture/<int:picture_id>/edit/", PictureEditView.as_view(), name="picture_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("picture/<int:picture_id>/download/", send_pict, name="download"),
path( path(
@ -40,9 +54,5 @@ urlpatterns = [
send_compressed, send_compressed,
name="download_compressed", name="download_compressed",
), ),
path( path("picture/<int:picture_id>/download/thumb/", send_thumb, name="download_thumb"),
"picture/<int:picture_id>/download/thumb/",
send_thumb,
name="download_thumb",
),
] ]

View File

@ -12,14 +12,12 @@
# OR WITHIN THE LOCAL FILE "LICENSE" # 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.conf import settings
from django.core.exceptions import PermissionDenied from django.core.exceptions import PermissionDenied
from django.http import Http404, HttpResponse from django.http import Http404, HttpResponse, HttpResponseRedirect
from django.shortcuts import get_object_or_404, redirect from django.shortcuts import get_object_or_404
from django.urls import reverse, reverse_lazy from django.urls import reverse, reverse_lazy
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from django.views.generic import DetailView, TemplateView 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.models import SithFile, User
from core.views import CanEditMixin, CanViewMixin from core.views import CanEditMixin, CanViewMixin
from core.views.files import FileView, MultipleImageField, send_file from core.views.files import FileView, send_file
from core.views.forms import SelectDate from sas.forms import (
from sas.models import Album, PeoplePictureRelation, Picture AlbumEditForm,
PictureEditForm,
PictureModerationRequestForm,
class SASForm(forms.Form): SASForm,
album_name = forms.CharField( )
label=_("Add a new album"), max_length=Album.NAME_MAX_LENGTH, required=False from sas.models import Album, Picture
)
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 SASMainView(FormView): class SASMainView(FormView):
@ -138,11 +79,6 @@ class PictureView(CanViewMixin, DetailView):
self.object.rotate(270) self.object.rotate(270)
if "rotate_left" in request.GET: if "rotate_left" in request.GET:
self.object.rotate(90) 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) return super().get(request, *args, **kwargs)
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
@ -179,19 +115,18 @@ class AlbumUploadView(CanViewMixin, DetailView, FormMixin):
self.form = self.get_form() self.form = self.get_form()
parent = SithFile.objects.filter(id=self.object.id).first() parent = SithFile.objects.filter(id=self.object.id).first()
files = request.FILES.getlist("images") 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(): if self.form.is_valid():
self.form.process( return HttpResponse(str(self.form.errors), status=200)
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=500) return HttpResponse(str(self.form.errors), status=500)
@ -210,7 +145,7 @@ class AlbumView(CanViewMixin, DetailView, FormMixin):
def get(self, request, *args, **kwargs): def get(self, request, *args, **kwargs):
self.form = self.get_form() self.form = self.get_form()
if "clipboard" not in request.session.keys(): if "clipboard" not in request.session:
request.session["clipboard"] = [] request.session["clipboard"] = []
return super().get(request, *args, **kwargs) return super().get(request, *args, **kwargs)
@ -219,7 +154,7 @@ class AlbumView(CanViewMixin, DetailView, FormMixin):
if not self.object.file: if not self.object.file:
self.object.generate_thumbnail() self.object.generate_thumbnail()
self.form = self.get_form() self.form = self.get_form()
if "clipboard" not in request.session.keys(): if "clipboard" not in request.session:
request.session["clipboard"] = [] request.session["clipboard"] = []
if request.user.can_edit(self.object): # Handle the copy-paste functions if request.user.can_edit(self.object): # Handle the copy-paste functions
FileView.handle_clipboard(request, self.object) FileView.handle_clipboard(request, self.object)
@ -293,26 +228,6 @@ class ModerationView(TemplateView):
return kwargs 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): class PictureEditView(CanEditMixin, UpdateView):
model = Picture model = Picture
form_class = PictureEditForm form_class = PictureEditForm
@ -320,6 +235,41 @@ class PictureEditView(CanEditMixin, UpdateView):
pk_url_kwarg = "picture_id" 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): class AlbumEditView(CanEditMixin, UpdateView):
model = Album model = Album
form_class = AlbumEditForm form_class = AlbumEditForm

View File

@ -346,8 +346,8 @@ SITH_LAUNDERETTE_MANAGER = {
# Main root for club pages # Main root for club pages
SITH_CLUB_ROOT_PAGE = "clubs" SITH_CLUB_ROOT_PAGE = "clubs"
# Define the date in the year serving as reference for the subscriptions calendar # Define the date in the year serving as
# (month, day) # reference for the subscriptions calendar (month, day)
SITH_SEMESTER_START_AUTUMN = (8, 15) # 15 August SITH_SEMESTER_START_AUTUMN = (8, 15) # 15 August
SITH_SEMESTER_START_SPRING = (2, 15) # 15 February 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) SITH_ACCOUNT_DUMP_DELTA = timedelta(days=30)
"""timedelta between the warning mail and the actual account dump""" """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 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_ONE_SEMESTER = 1
SITH_PRODUCT_SUBSCRIPTION_TWO_SEMESTERS = 2 SITH_PRODUCT_SUBSCRIPTION_TWO_SEMESTERS = 2
SITH_PRODUCTTYPE_SUBSCRIPTION = 2 SITH_PRODUCTTYPE_SUBSCRIPTION = 2
@ -701,15 +703,15 @@ TOXIC_DOMAINS_PROVIDERS = [
] ]
try: try:
from .settings_custom import * from .settings_custom import * # noqa F403 (this star-import is actually useful)
logging.getLogger("django").info("Custom settings imported") logging.getLogger("django").info("Custom settings imported")
except: except ImportError:
logging.getLogger("django").warning("Custom settings failed") logging.getLogger("django").warning("Custom settings failed")
if DEBUG: if DEBUG:
INSTALLED_APPS += ("debug_toolbar",) INSTALLED_APPS += ("debug_toolbar",)
MIDDLEWARE = ("debug_toolbar.middleware.DebugToolbarMiddleware",) + MIDDLEWARE MIDDLEWARE = ("debug_toolbar.middleware.DebugToolbarMiddleware", *MIDDLEWARE)
DEBUG_TOOLBAR_PANELS = [ DEBUG_TOOLBAR_PANELS = [
"debug_toolbar.panels.versions.VersionsPanel", "debug_toolbar.panels.versions.VersionsPanel",
"debug_toolbar.panels.timer.TimerPanel", "debug_toolbar.panels.timer.TimerPanel",
@ -723,7 +725,8 @@ if DEBUG:
"debug_toolbar.panels.signals.SignalsPanel", "debug_toolbar.panels.signals.SignalsPanel",
"debug_toolbar.panels.redirects.RedirectsPanel", "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: if TESTING:
CAPTCHA_TEST_MODE = True 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 import settings
from django.conf.urls.static import static from django.conf.urls.static import static
from django.contrib import admin from django.contrib import admin
from django.http import Http404
from django.urls import include, path from django.urls import include, path
from django.views.i18n import JavaScriptCatalog from django.views.i18n import JavaScriptCatalog
from ninja_extra import NinjaExtraAPI from ninja_extra import NinjaExtraAPI
@ -71,7 +72,8 @@ if settings.DEBUG:
urlpatterns += [path("__debug__/", include(debug_toolbar.urls))] urlpatterns += [path("__debug__/", include(debug_toolbar.urls))]
if settings.SENTRY_ENV == "development":
def sentry_debug(request):
"""Sentry debug endpoint """Sentry debug endpoint
This function always crash and allows us to test This function always crash and allows us to test
@ -83,8 +85,9 @@ if settings.SENTRY_ENV == "development":
NOTE : you need to specify the SENTRY_DSN setting in settings_custom.py 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