mirror of
https://github.com/ae-utbm/sith.git
synced 2024-11-22 14:13:21 +00:00
Merge branch 'taiste' into counter-activity-stats
This commit is contained in:
commit
aa3123f02a
@ -15,7 +15,16 @@
|
||||
|
||||
from django.contrib import admin
|
||||
|
||||
from accounting.models import *
|
||||
from accounting.models import (
|
||||
AccountingType,
|
||||
BankAccount,
|
||||
ClubAccount,
|
||||
Company,
|
||||
GeneralJournal,
|
||||
Label,
|
||||
Operation,
|
||||
SimplifiedAccountingType,
|
||||
)
|
||||
|
||||
admin.site.register(BankAccount)
|
||||
admin.site.register(ClubAccount)
|
||||
|
@ -82,9 +82,7 @@ class Company(models.Model):
|
||||
|
||||
def is_owned_by(self, user):
|
||||
"""Check if that object can be edited by the given user."""
|
||||
if user.is_in_group(pk=settings.SITH_GROUP_ACCOUNTING_ADMIN_ID):
|
||||
return True
|
||||
return False
|
||||
return user.is_in_group(pk=settings.SITH_GROUP_ACCOUNTING_ADMIN_ID)
|
||||
|
||||
def can_be_edited_by(self, user):
|
||||
"""Check if that object can be edited by the given user."""
|
||||
@ -127,9 +125,7 @@ class BankAccount(models.Model):
|
||||
if user.is_in_group(pk=settings.SITH_GROUP_ACCOUNTING_ADMIN_ID):
|
||||
return True
|
||||
m = self.club.get_membership_for(user)
|
||||
if m is not None and m.role >= settings.SITH_CLUB_ROLES_ID["Treasurer"]:
|
||||
return True
|
||||
return False
|
||||
return m is not None and m.role >= settings.SITH_CLUB_ROLES_ID["Treasurer"]
|
||||
|
||||
|
||||
class ClubAccount(models.Model):
|
||||
@ -161,29 +157,20 @@ class ClubAccount(models.Model):
|
||||
"""Check if that object can be edited by the given user."""
|
||||
if user.is_anonymous:
|
||||
return False
|
||||
if user.is_in_group(pk=settings.SITH_GROUP_ACCOUNTING_ADMIN_ID):
|
||||
return True
|
||||
return False
|
||||
return user.is_in_group(pk=settings.SITH_GROUP_ACCOUNTING_ADMIN_ID)
|
||||
|
||||
def can_be_edited_by(self, user):
|
||||
"""Check if that object can be edited by the given user."""
|
||||
m = self.club.get_membership_for(user)
|
||||
if m and m.role == settings.SITH_CLUB_ROLES_ID["Treasurer"]:
|
||||
return True
|
||||
return False
|
||||
return m and m.role == settings.SITH_CLUB_ROLES_ID["Treasurer"]
|
||||
|
||||
def can_be_viewed_by(self, user):
|
||||
"""Check if that object can be viewed by the given user."""
|
||||
m = self.club.get_membership_for(user)
|
||||
if m and m.role >= settings.SITH_CLUB_ROLES_ID["Treasurer"]:
|
||||
return True
|
||||
return False
|
||||
return m and m.role >= settings.SITH_CLUB_ROLES_ID["Treasurer"]
|
||||
|
||||
def has_open_journal(self):
|
||||
for j in self.journals.all():
|
||||
if not j.closed:
|
||||
return True
|
||||
return False
|
||||
return self.journals.filter(closed=False).exists()
|
||||
|
||||
def get_open_journal(self):
|
||||
return self.journals.filter(closed=False).first()
|
||||
@ -228,17 +215,13 @@ class GeneralJournal(models.Model):
|
||||
return False
|
||||
if user.is_in_group(pk=settings.SITH_GROUP_ACCOUNTING_ADMIN_ID):
|
||||
return True
|
||||
if self.club_account.can_be_edited_by(user):
|
||||
return True
|
||||
return False
|
||||
return self.club_account.can_be_edited_by(user)
|
||||
|
||||
def can_be_edited_by(self, user):
|
||||
"""Check if that object can be edited by the given user."""
|
||||
if user.is_in_group(pk=settings.SITH_GROUP_ACCOUNTING_ADMIN_ID):
|
||||
return True
|
||||
if self.club_account.can_be_edited_by(user):
|
||||
return True
|
||||
return False
|
||||
return self.club_account.can_be_edited_by(user)
|
||||
|
||||
def can_be_viewed_by(self, user):
|
||||
return self.club_account.can_be_viewed_by(user)
|
||||
@ -416,9 +399,7 @@ class Operation(models.Model):
|
||||
if self.journal.closed:
|
||||
return False
|
||||
m = self.journal.club_account.club.get_membership_for(user)
|
||||
if m is not None and m.role >= settings.SITH_CLUB_ROLES_ID["Treasurer"]:
|
||||
return True
|
||||
return False
|
||||
return m is not None and m.role >= settings.SITH_CLUB_ROLES_ID["Treasurer"]
|
||||
|
||||
def can_be_edited_by(self, user):
|
||||
"""Check if that object can be edited by the given user."""
|
||||
@ -427,9 +408,7 @@ class Operation(models.Model):
|
||||
if self.journal.closed:
|
||||
return False
|
||||
m = self.journal.club_account.club.get_membership_for(user)
|
||||
if m is not None and m.role == settings.SITH_CLUB_ROLES_ID["Treasurer"]:
|
||||
return True
|
||||
return False
|
||||
return m is not None and m.role == settings.SITH_CLUB_ROLES_ID["Treasurer"]
|
||||
|
||||
|
||||
class AccountingType(models.Model):
|
||||
@ -472,9 +451,7 @@ class AccountingType(models.Model):
|
||||
"""Check if that object can be edited by the given user."""
|
||||
if user.is_anonymous:
|
||||
return False
|
||||
if user.is_in_group(pk=settings.SITH_GROUP_ACCOUNTING_ADMIN_ID):
|
||||
return True
|
||||
return False
|
||||
return user.is_in_group(pk=settings.SITH_GROUP_ACCOUNTING_ADMIN_ID)
|
||||
|
||||
|
||||
class SimplifiedAccountingType(models.Model):
|
||||
|
@ -102,7 +102,7 @@ class TestOperation(TestCase):
|
||||
code="443", label="Ce code n'existe pas", movement_type="CREDIT"
|
||||
)
|
||||
at.save()
|
||||
l = Label.objects.create(club_account=self.journal.club_account, name="bob")
|
||||
label = Label.objects.create(club_account=self.journal.club_account, name="bob")
|
||||
self.client.force_login(User.objects.get(username="comptable"))
|
||||
self.op1 = Operation(
|
||||
journal=self.journal,
|
||||
@ -111,7 +111,7 @@ class TestOperation(TestCase):
|
||||
remark="Test bilan",
|
||||
mode="CASH",
|
||||
done=True,
|
||||
label=l,
|
||||
label=label,
|
||||
accounting_type=at,
|
||||
target_type="USER",
|
||||
target_id=self.skia.id,
|
||||
@ -124,7 +124,7 @@ class TestOperation(TestCase):
|
||||
remark="Test bilan",
|
||||
mode="CASH",
|
||||
done=True,
|
||||
label=l,
|
||||
label=label,
|
||||
accounting_type=at,
|
||||
target_type="USER",
|
||||
target_id=self.skia.id,
|
||||
|
@ -15,7 +15,41 @@
|
||||
|
||||
from django.urls import path
|
||||
|
||||
from accounting.views import *
|
||||
from accounting.views import (
|
||||
AccountingTypeCreateView,
|
||||
AccountingTypeEditView,
|
||||
AccountingTypeListView,
|
||||
BankAccountCreateView,
|
||||
BankAccountDeleteView,
|
||||
BankAccountDetailView,
|
||||
BankAccountEditView,
|
||||
BankAccountListView,
|
||||
ClubAccountCreateView,
|
||||
ClubAccountDeleteView,
|
||||
ClubAccountDetailView,
|
||||
ClubAccountEditView,
|
||||
CompanyCreateView,
|
||||
CompanyEditView,
|
||||
CompanyListView,
|
||||
JournalAccountingStatementView,
|
||||
JournalCreateView,
|
||||
JournalDeleteView,
|
||||
JournalDetailView,
|
||||
JournalEditView,
|
||||
JournalNatureStatementView,
|
||||
JournalPersonStatementView,
|
||||
LabelCreateView,
|
||||
LabelDeleteView,
|
||||
LabelEditView,
|
||||
LabelListView,
|
||||
OperationCreateView,
|
||||
OperationEditView,
|
||||
OperationPDFView,
|
||||
RefoundAccountView,
|
||||
SimplifiedAccountingTypeCreateView,
|
||||
SimplifiedAccountingTypeEditView,
|
||||
SimplifiedAccountingTypeListView,
|
||||
)
|
||||
|
||||
urlpatterns = [
|
||||
# Accounting types
|
||||
|
@ -182,7 +182,7 @@ class ClubAccountCreateView(CanCreateMixin, CreateView):
|
||||
|
||||
def get_initial(self):
|
||||
ret = super().get_initial()
|
||||
if "parent" in self.request.GET.keys():
|
||||
if "parent" in self.request.GET:
|
||||
obj = BankAccount.objects.filter(id=int(self.request.GET["parent"])).first()
|
||||
if obj is not None:
|
||||
ret["bank_account"] = obj.id
|
||||
@ -264,7 +264,7 @@ class JournalCreateView(CanCreateMixin, CreateView):
|
||||
|
||||
def get_initial(self):
|
||||
ret = super().get_initial()
|
||||
if "parent" in self.request.GET.keys():
|
||||
if "parent" in self.request.GET:
|
||||
obj = ClubAccount.objects.filter(id=int(self.request.GET["parent"])).first()
|
||||
if obj is not None:
|
||||
ret["club_account"] = obj.id
|
||||
@ -362,7 +362,7 @@ class OperationForm(forms.ModelForm):
|
||||
|
||||
def clean(self):
|
||||
self.cleaned_data = super().clean()
|
||||
if "target_type" in self.cleaned_data.keys():
|
||||
if "target_type" in self.cleaned_data:
|
||||
if (
|
||||
self.cleaned_data.get("user") is None
|
||||
and self.cleaned_data.get("club") is None
|
||||
@ -633,19 +633,17 @@ class JournalNatureStatementView(JournalTabsMixin, CanViewMixin, DetailView):
|
||||
ret = collections.OrderedDict()
|
||||
statement = collections.OrderedDict()
|
||||
total_sum = 0
|
||||
for sat in [None] + list(
|
||||
SimplifiedAccountingType.objects.order_by("label").all()
|
||||
):
|
||||
for sat in [
|
||||
None,
|
||||
*list(SimplifiedAccountingType.objects.order_by("label")),
|
||||
]:
|
||||
amount = queryset.filter(
|
||||
accounting_type__movement_type=movement_type, simpleaccounting_type=sat
|
||||
).aggregate(amount_sum=Sum("amount"))["amount_sum"]
|
||||
if sat:
|
||||
sat = sat.label
|
||||
else:
|
||||
sat = ""
|
||||
label = sat.label if sat is not None else ""
|
||||
if amount:
|
||||
total_sum += amount
|
||||
statement[sat] = amount
|
||||
statement[label] = amount
|
||||
ret[movement_type] = statement
|
||||
ret[movement_type + "_sum"] = total_sum
|
||||
return ret
|
||||
@ -668,15 +666,12 @@ class JournalNatureStatementView(JournalTabsMixin, CanViewMixin, DetailView):
|
||||
self.statement(self.object.operations.filter(label=None).all(), "DEBIT")
|
||||
)
|
||||
statement[_("No label operations")] = no_label_statement
|
||||
for l in labels:
|
||||
for label in labels:
|
||||
l_stmt = collections.OrderedDict()
|
||||
l_stmt.update(
|
||||
self.statement(self.object.operations.filter(label=l).all(), "CREDIT")
|
||||
)
|
||||
l_stmt.update(
|
||||
self.statement(self.object.operations.filter(label=l).all(), "DEBIT")
|
||||
)
|
||||
statement[l] = l_stmt
|
||||
journals = self.object.operations.filter(label=label).all()
|
||||
l_stmt.update(self.statement(journals, "CREDIT"))
|
||||
l_stmt.update(self.statement(journals, "DEBIT"))
|
||||
statement[label] = l_stmt
|
||||
return statement
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
@ -798,7 +793,7 @@ class LabelCreateView(
|
||||
|
||||
def get_initial(self):
|
||||
ret = super().get_initial()
|
||||
if "parent" in self.request.GET.keys():
|
||||
if "parent" in self.request.GET:
|
||||
obj = ClubAccount.objects.filter(id=int(self.request.GET["parent"])).first()
|
||||
if obj is not None:
|
||||
ret["club_account"] = obj.id
|
||||
|
@ -111,8 +111,8 @@ class MailingForm(forms.Form):
|
||||
"""Convert given users into real users and check their validity."""
|
||||
cleaned_data = super().clean()
|
||||
users = []
|
||||
for user in cleaned_data["subscription_users"]:
|
||||
user = User.objects.filter(id=user).first()
|
||||
for user_id in cleaned_data["subscription_users"]:
|
||||
user = User.objects.filter(id=user_id).first()
|
||||
if not user:
|
||||
raise forms.ValidationError(
|
||||
_("One of the selected users doesn't exist"), code="invalid"
|
||||
@ -128,7 +128,7 @@ class MailingForm(forms.Form):
|
||||
def clean(self):
|
||||
cleaned_data = super().clean()
|
||||
|
||||
if not "action" in cleaned_data:
|
||||
if "action" not in cleaned_data:
|
||||
# If there is no action provided, we can stop here
|
||||
raise forms.ValidationError(_("An action is required"), code="invalid")
|
||||
|
||||
|
@ -389,9 +389,7 @@ class Membership(models.Model):
|
||||
if user.is_root or user.is_board_member:
|
||||
return True
|
||||
membership = self.club.get_membership_for(user)
|
||||
if membership is not None and membership.role >= self.role:
|
||||
return True
|
||||
return False
|
||||
return membership is not None and membership.role >= self.role
|
||||
|
||||
def delete(self, *args, **kwargs):
|
||||
super().delete(*args, **kwargs)
|
||||
|
51
club/urls.py
51
club/urls.py
@ -24,7 +24,32 @@
|
||||
|
||||
from django.urls import path
|
||||
|
||||
from club.views import *
|
||||
from club.views import (
|
||||
ClubCreateView,
|
||||
ClubEditPropView,
|
||||
ClubEditView,
|
||||
ClubListView,
|
||||
ClubMailingView,
|
||||
ClubMembersView,
|
||||
ClubOldMembersView,
|
||||
ClubPageEditView,
|
||||
ClubPageHistView,
|
||||
ClubRevView,
|
||||
ClubSellingCSVView,
|
||||
ClubSellingView,
|
||||
ClubStatView,
|
||||
ClubToolsView,
|
||||
ClubView,
|
||||
MailingAutoGenerationView,
|
||||
MailingDeleteView,
|
||||
MailingSubscriptionDeleteView,
|
||||
MembershipDeleteView,
|
||||
MembershipSetOldView,
|
||||
PosterCreateView,
|
||||
PosterDeleteView,
|
||||
PosterEditView,
|
||||
PosterListView,
|
||||
)
|
||||
|
||||
urlpatterns = [
|
||||
path("", ClubListView.as_view(), name="club_list"),
|
||||
@ -32,32 +57,20 @@ urlpatterns = [
|
||||
path("stats/", ClubStatView.as_view(), name="club_stats"),
|
||||
path("<int:club_id>/", ClubView.as_view(), name="club_view"),
|
||||
path(
|
||||
"<int:club_id>/rev/<int:rev_id>/",
|
||||
ClubRevView.as_view(),
|
||||
name="club_view_rev",
|
||||
"<int:club_id>/rev/<int:rev_id>/", ClubRevView.as_view(), name="club_view_rev"
|
||||
),
|
||||
path("<int:club_id>/hist/", ClubPageHistView.as_view(), name="club_hist"),
|
||||
path("<int:club_id>/edit/", ClubEditView.as_view(), name="club_edit"),
|
||||
path(
|
||||
"<int:club_id>/edit/page/",
|
||||
ClubPageEditView.as_view(),
|
||||
name="club_edit_page",
|
||||
),
|
||||
path("<int:club_id>/edit/page/", ClubPageEditView.as_view(), name="club_edit_page"),
|
||||
path("<int:club_id>/members/", ClubMembersView.as_view(), name="club_members"),
|
||||
path(
|
||||
"<int:club_id>/elderlies/",
|
||||
ClubOldMembersView.as_view(),
|
||||
name="club_old_members",
|
||||
),
|
||||
path("<int:club_id>/sellings/", ClubSellingView.as_view(), name="club_sellings"),
|
||||
path(
|
||||
"<int:club_id>/sellings/",
|
||||
ClubSellingView.as_view(),
|
||||
name="club_sellings",
|
||||
),
|
||||
path(
|
||||
"<int:club_id>/sellings/csv/",
|
||||
ClubSellingCSVView.as_view(),
|
||||
name="sellings_csv",
|
||||
"<int:club_id>/sellings/csv/", ClubSellingCSVView.as_view(), name="sellings_csv"
|
||||
),
|
||||
path("<int:club_id>/prop/", ClubEditPropView.as_view(), name="club_prop"),
|
||||
path("<int:club_id>/tools/", ClubToolsView.as_view(), name="tools"),
|
||||
@ -89,9 +102,7 @@ urlpatterns = [
|
||||
),
|
||||
path("<int:club_id>/poster/", PosterListView.as_view(), name="poster_list"),
|
||||
path(
|
||||
"<int:club_id>/poster/create/",
|
||||
PosterCreateView.as_view(),
|
||||
name="poster_create",
|
||||
"<int:club_id>/poster/create/", PosterCreateView.as_view(), name="poster_create"
|
||||
),
|
||||
path(
|
||||
"<int:club_id>/poster/<int:poster_id>/edit/",
|
||||
|
@ -397,7 +397,8 @@ class ClubSellingCSVView(ClubSellingView):
|
||||
row.append(selling.customer.user.get_display_name())
|
||||
else:
|
||||
row.append("")
|
||||
row = row + [
|
||||
row = [
|
||||
*row,
|
||||
selling.label,
|
||||
selling.quantity,
|
||||
selling.quantity * selling.unit_price,
|
||||
@ -408,7 +409,7 @@ class ClubSellingCSVView(ClubSellingView):
|
||||
row.append(selling.product.purchase_price)
|
||||
row.append(selling.product.selling_price - selling.product.purchase_price)
|
||||
else:
|
||||
row = row + ["", "", ""]
|
||||
row = [*row, "", "", ""]
|
||||
return row
|
||||
|
||||
def get(self, request, *args, **kwargs):
|
||||
@ -622,9 +623,7 @@ class ClubMailingView(ClubTabsMixin, CanEditMixin, DetailFormView):
|
||||
def remove_subscription(self, cleaned_data):
|
||||
"""Remove specified users from a mailing list."""
|
||||
fields = [
|
||||
cleaned_data[key]
|
||||
for key in cleaned_data.keys()
|
||||
if key.startswith("removal_")
|
||||
val for key, val in cleaned_data.items() if key.startswith("removal_")
|
||||
]
|
||||
for field in fields:
|
||||
for sub in field:
|
||||
|
@ -15,7 +15,7 @@
|
||||
from django.contrib import admin
|
||||
from haystack.admin import SearchModelAdmin
|
||||
|
||||
from com.models import *
|
||||
from com.models import News, Poster, Screen, Sith, Weekmail
|
||||
|
||||
|
||||
@admin.register(News)
|
||||
|
53
com/urls.py
53
com/urls.py
@ -16,7 +16,36 @@
|
||||
from django.urls import path
|
||||
|
||||
from club.views import MailingDeleteView
|
||||
from com.views import *
|
||||
from com.views import (
|
||||
AlertMsgEditView,
|
||||
InfoMsgEditView,
|
||||
MailingListAdminView,
|
||||
MailingModerateView,
|
||||
NewsAdminListView,
|
||||
NewsCreateView,
|
||||
NewsDeleteView,
|
||||
NewsDetailView,
|
||||
NewsEditView,
|
||||
NewsListView,
|
||||
NewsModerateView,
|
||||
PosterCreateView,
|
||||
PosterDeleteView,
|
||||
PosterEditView,
|
||||
PosterListView,
|
||||
PosterModerateListView,
|
||||
PosterModerateView,
|
||||
ScreenCreateView,
|
||||
ScreenDeleteView,
|
||||
ScreenEditView,
|
||||
ScreenListView,
|
||||
ScreenSlideshowView,
|
||||
WeekmailArticleCreateView,
|
||||
WeekmailArticleDeleteView,
|
||||
WeekmailArticleEditView,
|
||||
WeekmailDestinationEditView,
|
||||
WeekmailEditView,
|
||||
WeekmailPreviewView,
|
||||
)
|
||||
|
||||
urlpatterns = [
|
||||
path("sith/edit/alert/", AlertMsgEditView.as_view(), name="alert_edit"),
|
||||
@ -46,15 +75,9 @@ urlpatterns = [
|
||||
path("news/", NewsListView.as_view(), name="news_list"),
|
||||
path("news/admin/", NewsAdminListView.as_view(), name="news_admin_list"),
|
||||
path("news/create/", NewsCreateView.as_view(), name="news_new"),
|
||||
path("news/<int:news_id>/delete/", NewsDeleteView.as_view(), name="news_delete"),
|
||||
path(
|
||||
"news/<int:news_id>/delete/",
|
||||
NewsDeleteView.as_view(),
|
||||
name="news_delete",
|
||||
),
|
||||
path(
|
||||
"news/<int:news_id>/moderate/",
|
||||
NewsModerateView.as_view(),
|
||||
name="news_moderate",
|
||||
"news/<int:news_id>/moderate/", NewsModerateView.as_view(), name="news_moderate"
|
||||
),
|
||||
path("news/<int:news_id>/edit/", NewsEditView.as_view(), name="news_edit"),
|
||||
path("news/<int:news_id>/", NewsDetailView.as_view(), name="news_detail"),
|
||||
@ -71,11 +94,7 @@ urlpatterns = [
|
||||
),
|
||||
path("poster/", PosterListView.as_view(), name="poster_list"),
|
||||
path("poster/create/", PosterCreateView.as_view(), name="poster_create"),
|
||||
path(
|
||||
"poster/<int:poster_id>/edit/",
|
||||
PosterEditView.as_view(),
|
||||
name="poster_edit",
|
||||
),
|
||||
path("poster/<int:poster_id>/edit/", PosterEditView.as_view(), name="poster_edit"),
|
||||
path(
|
||||
"poster/<int:poster_id>/delete/",
|
||||
PosterDeleteView.as_view(),
|
||||
@ -98,11 +117,7 @@ urlpatterns = [
|
||||
ScreenSlideshowView.as_view(),
|
||||
name="screen_slideshow",
|
||||
),
|
||||
path(
|
||||
"screen/<int:screen_id>/edit/",
|
||||
ScreenEditView.as_view(),
|
||||
name="screen_edit",
|
||||
),
|
||||
path("screen/<int:screen_id>/edit/", ScreenEditView.as_view(), name="screen_edit"),
|
||||
path(
|
||||
"screen/<int:screen_id>/delete/",
|
||||
ScreenDeleteView.as_view(),
|
||||
|
39
com/views.py
39
com/views.py
@ -86,8 +86,7 @@ class PosterForm(forms.ModelForm):
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.user = kwargs.pop("user", None)
|
||||
super().__init__(*args, **kwargs)
|
||||
if self.user:
|
||||
if not self.user.is_com_admin:
|
||||
if self.user and not self.user.is_com_admin:
|
||||
self.fields["club"].queryset = Club.objects.filter(
|
||||
id__in=self.user.clubs_with_rights
|
||||
)
|
||||
@ -312,7 +311,7 @@ class NewsCreateView(CanCreateMixin, CreateView):
|
||||
|
||||
def post(self, request, *args, **kwargs):
|
||||
form = self.get_form()
|
||||
if form.is_valid() and "preview" not in request.POST.keys():
|
||||
if form.is_valid() and "preview" not in request.POST:
|
||||
return self.form_valid(form)
|
||||
else:
|
||||
self.object = form.instance
|
||||
@ -354,13 +353,13 @@ class NewsModerateView(CanEditMixin, SingleObjectMixin):
|
||||
|
||||
def get(self, request, *args, **kwargs):
|
||||
self.object = self.get_object()
|
||||
if "remove" in request.GET.keys():
|
||||
if "remove" in request.GET:
|
||||
self.object.is_moderated = False
|
||||
else:
|
||||
self.object.is_moderated = True
|
||||
self.object.moderator = request.user
|
||||
self.object.save()
|
||||
if "next" in self.request.GET.keys():
|
||||
if "next" in self.request.GET:
|
||||
return redirect(self.request.GET["next"])
|
||||
return redirect("com:news_admin_list")
|
||||
|
||||
@ -424,7 +423,7 @@ class WeekmailPreviewView(ComTabsMixin, QuickNotifMixin, CanEditPropMixin, Detai
|
||||
try:
|
||||
self.object.send() # This should fail
|
||||
except SMTPRecipientsRefused as e:
|
||||
users = User.objects.filter(email__in=e.recipients.keys())
|
||||
users = User.objects.filter(email__in=e.recipients)
|
||||
for u in users:
|
||||
u.preferences.receive_weekmail = False
|
||||
u.preferences.save()
|
||||
@ -471,7 +470,7 @@ class WeekmailEditView(ComTabsMixin, QuickNotifMixin, CanEditPropMixin, UpdateVi
|
||||
|
||||
def get(self, request, *args, **kwargs):
|
||||
self.object = self.get_object()
|
||||
if "up_article" in request.GET.keys():
|
||||
if "up_article" in request.GET:
|
||||
art = get_object_or_404(
|
||||
WeekmailArticle, id=request.GET["up_article"], weekmail=self.object
|
||||
)
|
||||
@ -483,7 +482,7 @@ class WeekmailEditView(ComTabsMixin, QuickNotifMixin, CanEditPropMixin, UpdateVi
|
||||
art.save()
|
||||
prev_art.save()
|
||||
self.quick_notif_list += ["qn_success"]
|
||||
if "down_article" in request.GET.keys():
|
||||
if "down_article" in request.GET:
|
||||
art = get_object_or_404(
|
||||
WeekmailArticle, id=request.GET["down_article"], weekmail=self.object
|
||||
)
|
||||
@ -495,7 +494,7 @@ class WeekmailEditView(ComTabsMixin, QuickNotifMixin, CanEditPropMixin, UpdateVi
|
||||
art.save()
|
||||
next_art.save()
|
||||
self.quick_notif_list += ["qn_success"]
|
||||
if "add_article" in request.GET.keys():
|
||||
if "add_article" in request.GET:
|
||||
art = get_object_or_404(
|
||||
WeekmailArticle, id=request.GET["add_article"], weekmail=None
|
||||
)
|
||||
@ -504,7 +503,7 @@ class WeekmailEditView(ComTabsMixin, QuickNotifMixin, CanEditPropMixin, UpdateVi
|
||||
art.rank += 1
|
||||
art.save()
|
||||
self.quick_notif_list += ["qn_success"]
|
||||
if "del_article" in request.GET.keys():
|
||||
if "del_article" in request.GET:
|
||||
art = get_object_or_404(
|
||||
WeekmailArticle, id=request.GET["del_article"], weekmail=self.object
|
||||
)
|
||||
@ -571,7 +570,7 @@ class WeekmailArticleCreateView(QuickNotifMixin, CreateView):
|
||||
)
|
||||
),
|
||||
)
|
||||
if form.is_valid() and not "preview" in request.POST.keys():
|
||||
if form.is_valid() and "preview" not in request.POST:
|
||||
return self.form_valid(form)
|
||||
else:
|
||||
return self.form_invalid(form)
|
||||
@ -689,19 +688,13 @@ class PosterEditBaseView(UpdateView):
|
||||
template_name = "com/poster_edit.jinja"
|
||||
|
||||
def get_initial(self):
|
||||
init = {}
|
||||
try:
|
||||
init["date_begin"] = self.object.date_begin.strftime("%Y-%m-%d %H:%M:%S")
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
init["date_end"] = self.object.date_end.strftime("%Y-%m-%d %H:%M:%S")
|
||||
except Exception:
|
||||
pass
|
||||
return init
|
||||
return {
|
||||
"date_begin": self.object.date_begin.strftime("%Y-%m-%d %H:%M:%S"),
|
||||
"date_end": self.object.date_end.strftime("%Y-%m-%d %H:%M:%S"),
|
||||
}
|
||||
|
||||
def dispatch(self, request, *args, **kwargs):
|
||||
if "club_id" in kwargs and kwargs["club_id"]:
|
||||
if kwargs.get("club_id"):
|
||||
try:
|
||||
self.club = Club.objects.get(pk=kwargs["club_id"])
|
||||
except Club.DoesNotExist as e:
|
||||
@ -737,7 +730,7 @@ class PosterDeleteBaseView(DeleteView):
|
||||
template_name = "core/delete_confirm.jinja"
|
||||
|
||||
def dispatch(self, request, *args, **kwargs):
|
||||
if "club_id" in kwargs and kwargs["club_id"]:
|
||||
if kwargs.get("club_id"):
|
||||
try:
|
||||
self.club = Club.objects.get(pk=kwargs["club_id"])
|
||||
except Club.DoesNotExist as e:
|
||||
|
@ -67,5 +67,6 @@ class Command(BaseCommand):
|
||||
subprocess.run(
|
||||
[str(Path(__file__).parent / "install_xapian.sh"), desired],
|
||||
env=dict(os.environ),
|
||||
).check_returncode()
|
||||
check=True,
|
||||
)
|
||||
self.stdout.write("Installation success")
|
||||
|
@ -934,7 +934,7 @@ Welcome to the wiki page!
|
||||
# Adding subscription for sli
|
||||
s = Subscription(
|
||||
member=User.objects.filter(pk=sli.pk).first(),
|
||||
subscription_type=list(settings.SITH_SUBSCRIPTIONS.keys())[0],
|
||||
subscription_type=next(iter(settings.SITH_SUBSCRIPTIONS.keys())),
|
||||
payment_method=settings.SITH_SUBSCRIPTION_PAYMENT_METHOD[0][0],
|
||||
)
|
||||
s.subscription_start = s.compute_start()
|
||||
@ -947,7 +947,7 @@ Welcome to the wiki page!
|
||||
# Adding subscription for Krophil
|
||||
s = Subscription(
|
||||
member=User.objects.filter(pk=krophil.pk).first(),
|
||||
subscription_type=list(settings.SITH_SUBSCRIPTIONS.keys())[0],
|
||||
subscription_type=next(iter(settings.SITH_SUBSCRIPTIONS.keys())),
|
||||
payment_method=settings.SITH_SUBSCRIPTION_PAYMENT_METHOD[0][0],
|
||||
)
|
||||
s.subscription_start = s.compute_start()
|
||||
|
@ -217,9 +217,9 @@ class Command(BaseCommand):
|
||||
UV.objects.bulk_create(uvs, ignore_conflicts=True)
|
||||
|
||||
def create_products(self):
|
||||
categories = []
|
||||
for _ in range(10):
|
||||
categories.append(ProductType(name=self.faker.text(max_nb_chars=30)))
|
||||
categories = [
|
||||
ProductType(name=self.faker.text(max_nb_chars=30)) for _ in range(10)
|
||||
]
|
||||
ProductType.objects.bulk_create(categories)
|
||||
categories = list(
|
||||
ProductType.objects.filter(name__in=[c.name for c in categories])
|
||||
@ -254,15 +254,15 @@ class Command(BaseCommand):
|
||||
archived=bool(random.random() > 0.7),
|
||||
)
|
||||
products.append(product)
|
||||
for group in random.sample(groups, k=random.randint(0, 3)):
|
||||
# there will be products without buying groups
|
||||
# but there are also such products in the real database
|
||||
buying_groups.append(
|
||||
buying_groups.extend(
|
||||
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.append(
|
||||
selling_places.extend(
|
||||
Counter.products.through(counter=counter, product=product)
|
||||
for counter in random.sample(counters, random.randint(0, 4))
|
||||
)
|
||||
Product.objects.bulk_create(products)
|
||||
Product.buying_groups.through.objects.bulk_create(buying_groups)
|
||||
|
@ -174,7 +174,7 @@ def validate_promo(value: int) -> None:
|
||||
)
|
||||
|
||||
|
||||
def get_group(*, pk: int = None, name: str = None) -> Group | None:
|
||||
def get_group(*, pk: int | None = None, name: str | None = None) -> Group | None:
|
||||
"""Search for a group by its primary key or its name.
|
||||
Either one of the two must be set.
|
||||
|
||||
@ -445,7 +445,7 @@ class User(AbstractBaseUser):
|
||||
else:
|
||||
return 0
|
||||
|
||||
def is_in_group(self, *, pk: int = None, name: str = None) -> bool:
|
||||
def is_in_group(self, *, pk: int | None = None, name: str | None = None) -> bool:
|
||||
"""Check if this user is in the given group.
|
||||
Either a group id or a group name must be provided.
|
||||
If both are passed, only the id will be considered.
|
||||
@ -649,7 +649,7 @@ class User(AbstractBaseUser):
|
||||
continue
|
||||
links = list(User.godfathers.through.objects.filter(**{key: self.id}))
|
||||
res.extend(links)
|
||||
for _ in range(1, depth):
|
||||
for _ in range(1, depth): # noqa: F402 we don't care about gettext here
|
||||
ids = [getattr(c, reverse_key) for c in links]
|
||||
links = list(
|
||||
User.godfathers.through.objects.filter(
|
||||
@ -703,9 +703,7 @@ class User(AbstractBaseUser):
|
||||
return True
|
||||
if hasattr(obj, "owner_group") and self.is_in_group(pk=obj.owner_group.id):
|
||||
return True
|
||||
if self.is_root:
|
||||
return True
|
||||
return False
|
||||
return self.is_root
|
||||
|
||||
def can_edit(self, obj):
|
||||
"""Determine if the object can be edited by the user."""
|
||||
@ -717,9 +715,7 @@ class User(AbstractBaseUser):
|
||||
return True
|
||||
if isinstance(obj, User) and obj == self:
|
||||
return True
|
||||
if self.is_owner(obj):
|
||||
return True
|
||||
return False
|
||||
return self.is_owner(obj)
|
||||
|
||||
def can_view(self, obj):
|
||||
"""Determine if the object can be viewed by the user."""
|
||||
@ -729,9 +725,7 @@ class User(AbstractBaseUser):
|
||||
for pk in obj.view_groups.values_list("pk", flat=True):
|
||||
if self.is_in_group(pk=pk):
|
||||
return True
|
||||
if self.can_edit(obj):
|
||||
return True
|
||||
return False
|
||||
return self.can_edit(obj)
|
||||
|
||||
def can_be_edited_by(self, user):
|
||||
return user.is_root or user.is_board_member
|
||||
@ -759,23 +753,17 @@ class User(AbstractBaseUser):
|
||||
|
||||
@cached_property
|
||||
def preferences(self):
|
||||
try:
|
||||
if hasattr(self, "_preferences"):
|
||||
return self._preferences
|
||||
except:
|
||||
prefs = Preferences(user=self)
|
||||
prefs.save()
|
||||
return prefs
|
||||
return Preferences.objects.create(user=self)
|
||||
|
||||
@cached_property
|
||||
def forum_infos(self):
|
||||
try:
|
||||
if hasattr(self, "_forum_infos"):
|
||||
return self._forum_infos
|
||||
except:
|
||||
from forum.models import ForumUserInfo
|
||||
|
||||
infos = ForumUserInfo(user=self)
|
||||
infos.save()
|
||||
return infos
|
||||
return ForumUserInfo.objects.create(user=self)
|
||||
|
||||
@cached_property
|
||||
def clubs_with_rights(self) -> list[Club]:
|
||||
@ -840,7 +828,7 @@ class AnonymousUser(AuthAnonymousUser):
|
||||
def favorite_topics(self):
|
||||
raise PermissionDenied
|
||||
|
||||
def is_in_group(self, *, pk: int = None, name: str = None) -> bool:
|
||||
def is_in_group(self, *, pk: int | None = None, name: str | None = None) -> bool:
|
||||
"""The anonymous user is only in the public group."""
|
||||
allowed_id = settings.SITH_GROUP_PUBLIC_ID
|
||||
if pk is not None:
|
||||
@ -867,9 +855,7 @@ class AnonymousUser(AuthAnonymousUser):
|
||||
and obj.view_groups.filter(id=settings.SITH_GROUP_PUBLIC_ID).exists()
|
||||
):
|
||||
return True
|
||||
if hasattr(obj, "can_be_viewed_by") and obj.can_be_viewed_by(self):
|
||||
return True
|
||||
return False
|
||||
return hasattr(obj, "can_be_viewed_by") and obj.can_be_viewed_by(self)
|
||||
|
||||
def get_display_name(self):
|
||||
return _("Visitor")
|
||||
@ -1070,7 +1056,7 @@ class SithFile(models.Model):
|
||||
]:
|
||||
self.file.delete()
|
||||
self.file = None
|
||||
except:
|
||||
except: # noqa E722 I don't know the exception that can be raised
|
||||
self.file = None
|
||||
self.mime_type = "inode/directory"
|
||||
if self.is_file and (self.file is None or self.file == ""):
|
||||
@ -1196,12 +1182,12 @@ class SithFile(models.Model):
|
||||
return Album.objects.filter(id=self.id).first()
|
||||
|
||||
def get_parent_list(self):
|
||||
l = []
|
||||
p = self.parent
|
||||
while p is not None:
|
||||
l.append(p)
|
||||
p = p.parent
|
||||
return l
|
||||
parents = []
|
||||
current = self.parent
|
||||
while current is not None:
|
||||
parents.append(current)
|
||||
current = current.parent
|
||||
return parents
|
||||
|
||||
def get_parent_path(self):
|
||||
return "/" + "/".join([p.name for p in self.get_parent_list()[::-1]])
|
||||
@ -1359,22 +1345,18 @@ class Page(models.Model):
|
||||
if hasattr(self, "club") and self.club.can_be_edited_by(user):
|
||||
# Override normal behavior for clubs
|
||||
return True
|
||||
if self.name == settings.SITH_CLUB_ROOT_PAGE and user.is_board_member:
|
||||
return True
|
||||
return False
|
||||
return self.name == settings.SITH_CLUB_ROOT_PAGE and user.is_board_member
|
||||
|
||||
def can_be_viewed_by(self, user):
|
||||
if self.is_club_page:
|
||||
return True
|
||||
return False
|
||||
return self.is_club_page
|
||||
|
||||
def get_parent_list(self):
|
||||
l = []
|
||||
p = self.parent
|
||||
while p is not None:
|
||||
l.append(p)
|
||||
p = p.parent
|
||||
return l
|
||||
parents = []
|
||||
current = self.parent
|
||||
while current is not None:
|
||||
parents.append(current)
|
||||
current = current.parent
|
||||
return parents
|
||||
|
||||
def is_locked(self):
|
||||
"""Is True if the page is locked, False otherwise.
|
||||
@ -1386,7 +1368,6 @@ class Page(models.Model):
|
||||
if self.lock_timeout and (
|
||||
timezone.now() - self.lock_timeout > timedelta(minutes=5)
|
||||
):
|
||||
# print("Lock timed out")
|
||||
self.unset_lock()
|
||||
return (
|
||||
self.lock_user
|
||||
@ -1401,7 +1382,6 @@ class Page(models.Model):
|
||||
self.lock_user = user
|
||||
self.lock_timeout = timezone.now()
|
||||
super().save()
|
||||
# print("Locking page")
|
||||
|
||||
def set_lock_recursive(self, user):
|
||||
"""Locks recursively all the child pages for editing properties."""
|
||||
@ -1420,7 +1400,6 @@ class Page(models.Model):
|
||||
self.lock_user = None
|
||||
self.lock_timeout = None
|
||||
super().save()
|
||||
# print("Unlocking page")
|
||||
|
||||
def get_lock(self):
|
||||
"""Returns the page's mutex containing the time and the user in a dict."""
|
||||
@ -1435,13 +1414,11 @@ class Page(models.Model):
|
||||
"""
|
||||
if self.parent is None:
|
||||
return self.name
|
||||
return "/".join([self.parent.get_full_name(), self.name])
|
||||
return f"{self.parent.get_full_name()}/{self.name}"
|
||||
|
||||
def get_display_name(self):
|
||||
try:
|
||||
return self.revisions.last().title
|
||||
except:
|
||||
return self.name
|
||||
rev = self.revisions.last()
|
||||
return rev.title if rev is not None else self.name
|
||||
|
||||
@cached_property
|
||||
def is_club_page(self):
|
||||
|
@ -28,6 +28,7 @@ input[type="file"] {
|
||||
font-size: 1.2em;
|
||||
border-radius: 5px;
|
||||
color: black;
|
||||
|
||||
&:hover {
|
||||
background: hsl(0, 0%, 83%);
|
||||
}
|
||||
@ -63,6 +64,7 @@ textarea[type="text"],
|
||||
border-radius: 5px;
|
||||
max-width: 95%;
|
||||
}
|
||||
|
||||
textarea {
|
||||
border: none;
|
||||
text-decoration: none;
|
||||
@ -72,6 +74,7 @@ textarea {
|
||||
border-radius: 5px;
|
||||
font-family: sans-serif;
|
||||
}
|
||||
|
||||
select {
|
||||
border: none;
|
||||
text-decoration: none;
|
||||
@ -85,9 +88,11 @@ select {
|
||||
a:not(.button) {
|
||||
text-decoration: none;
|
||||
color: $primary-dark-color;
|
||||
|
||||
&:hover {
|
||||
color: $primary-light-color;
|
||||
}
|
||||
|
||||
&:active {
|
||||
color: $primary-color;
|
||||
}
|
||||
@ -116,7 +121,9 @@ a:not(.button) {
|
||||
}
|
||||
|
||||
@keyframes rotate {
|
||||
100% { transform: rotate(360deg); }
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.ib {
|
||||
@ -143,11 +150,13 @@ a:not(.button) {
|
||||
|
||||
.collapse-header-icon {
|
||||
transition: all ease-in-out 150ms;
|
||||
|
||||
&.reverse {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.collapse-body {
|
||||
padding: 10px;
|
||||
}
|
||||
@ -202,9 +211,11 @@ a:not(.button) {
|
||||
font-size: 0.9em;
|
||||
margin: 0.2em;
|
||||
border-radius: 0.6em;
|
||||
|
||||
.markdown {
|
||||
margin: 0.5em;
|
||||
}
|
||||
|
||||
&:before {
|
||||
font-family: FontAwesome;
|
||||
font-size: 4em;
|
||||
@ -212,15 +223,19 @@ a:not(.button) {
|
||||
margin: 0.2em;
|
||||
}
|
||||
}
|
||||
|
||||
#info_box {
|
||||
background: $primary-neutral-light-color;
|
||||
|
||||
&:before {
|
||||
content: "\f05a";
|
||||
color: hsl(210, 100%, 56%);
|
||||
}
|
||||
}
|
||||
|
||||
#alert_box {
|
||||
background: $second-color;
|
||||
|
||||
&:before {
|
||||
content: "\f06a";
|
||||
color: $white-color;
|
||||
@ -240,6 +255,7 @@ a:not(.button) {
|
||||
#page {
|
||||
width: 90%;
|
||||
margin: 20px auto 0;
|
||||
|
||||
/*---------------------------------NAV---------------------------------*/
|
||||
.btn {
|
||||
font-size: 15px;
|
||||
@ -252,9 +268,11 @@ a:not(.button) {
|
||||
|
||||
&.btn-blue {
|
||||
background-color: $deepblue;
|
||||
|
||||
&:not(:disabled):hover {
|
||||
background-color: darken($deepblue, 10%);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
background-color: rgba(70, 90, 126, 0.4);
|
||||
}
|
||||
@ -262,9 +280,11 @@ a:not(.button) {
|
||||
|
||||
&.btn-grey {
|
||||
background-color: grey;
|
||||
|
||||
&:not(:disabled):hover {
|
||||
background-color: darken(gray, 15%);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
background-color: lighten(gray, 15%);
|
||||
}
|
||||
@ -273,9 +293,11 @@ a:not(.button) {
|
||||
&.btn-red {
|
||||
background-color: #fc8181;
|
||||
color: black;
|
||||
|
||||
&:not(:disabled):hover {
|
||||
background-color: darken(#fc8181, 15%);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
background-color: lighten(#fc8181, 15%);
|
||||
color: grey;
|
||||
@ -293,6 +315,7 @@ a:not(.button) {
|
||||
margin: 0 auto;
|
||||
list-style-type: none;
|
||||
background: $second-color;
|
||||
|
||||
li {
|
||||
padding: 10px;
|
||||
}
|
||||
@ -333,14 +356,24 @@ a:not(.button) {
|
||||
border: #fc8181 1px solid;
|
||||
}
|
||||
|
||||
.alert-title {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.alert-main {
|
||||
flex: 2;
|
||||
}
|
||||
|
||||
.alert-aside {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
.tool_bar {
|
||||
overflow: auto;
|
||||
padding: 4px;
|
||||
|
||||
.tools {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
@ -349,6 +382,7 @@ a:not(.button) {
|
||||
padding: 5px;
|
||||
border-radius: 6px;
|
||||
text-align: center;
|
||||
|
||||
a {
|
||||
padding: 7px;
|
||||
display: inline-block;
|
||||
@ -358,11 +392,13 @@ a:not(.button) {
|
||||
flex: 1;
|
||||
flex-wrap: nowrap;
|
||||
white-space: nowrap;
|
||||
|
||||
&.selected_tab {
|
||||
background: $primary-color;
|
||||
color: $white-color;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: $primary-color;
|
||||
color: $white-color;
|
||||
@ -385,17 +421,21 @@ a:not(.button) {
|
||||
margin: 0;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
#news_admin {
|
||||
margin-bottom: 1em;
|
||||
}
|
||||
|
||||
#right_column {
|
||||
flex: 20%;
|
||||
float: right;
|
||||
margin: 0.2em;
|
||||
}
|
||||
|
||||
#left_column {
|
||||
flex: 79%;
|
||||
margin: 0.2em;
|
||||
|
||||
h3 {
|
||||
background: $second-color;
|
||||
box-shadow: $shadow-color 1px 1px 1px;
|
||||
@ -403,12 +443,15 @@ a:not(.button) {
|
||||
margin: 0 0 0.5em 0;
|
||||
text-transform: uppercase;
|
||||
font-size: 1.1em;
|
||||
|
||||
&:not(:first-of-type) {
|
||||
margin: 2em 0 1em 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: $small-devices) {
|
||||
|
||||
#left_column,
|
||||
#right_column {
|
||||
flex: 100%;
|
||||
@ -423,6 +466,7 @@ a:not(.button) {
|
||||
background: white;
|
||||
font-size: 70%;
|
||||
margin-bottom: 1em;
|
||||
|
||||
#agenda_title,
|
||||
#birthdays_title {
|
||||
margin: 0;
|
||||
@ -435,39 +479,48 @@ a:not(.button) {
|
||||
text-transform: uppercase;
|
||||
background: $second-color;
|
||||
}
|
||||
|
||||
#agenda_content {
|
||||
overflow: auto;
|
||||
box-shadow: $shadow-color 1px 1px 1px;
|
||||
height: 20em;
|
||||
}
|
||||
|
||||
#agenda_content,
|
||||
#birthdays_content {
|
||||
.agenda_item {
|
||||
padding: 0.5em;
|
||||
margin-bottom: 0.5em;
|
||||
|
||||
&:nth-of-type(even) {
|
||||
background: $secondary-neutral-light-color;
|
||||
}
|
||||
|
||||
.agenda_time {
|
||||
font-size: 90%;
|
||||
color: grey;
|
||||
}
|
||||
|
||||
.agenda_item_content {
|
||||
p {
|
||||
margin-top: 0.2em;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ul.birthdays_year {
|
||||
margin: 0;
|
||||
list-style-type: none;
|
||||
font-weight: bold;
|
||||
|
||||
>li {
|
||||
padding: 0.5em;
|
||||
|
||||
&:nth-child(even) {
|
||||
background: $secondary-neutral-light-color;
|
||||
}
|
||||
}
|
||||
|
||||
ul {
|
||||
margin: 0;
|
||||
margin-left: 1em;
|
||||
@ -478,6 +531,7 @@ a:not(.button) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* END AGENDA/BIRTHDAYS */
|
||||
|
||||
/* EVENTS TODAY AND NEXT FEW DAYS */
|
||||
@ -485,6 +539,7 @@ a:not(.button) {
|
||||
box-shadow: $shadow-color 1px 1px 1px;
|
||||
margin-left: 1em;
|
||||
margin-bottom: 0.5em;
|
||||
|
||||
.news_events_group_date {
|
||||
display: table-cell;
|
||||
padding: 0.6em;
|
||||
@ -500,33 +555,42 @@ a:not(.button) {
|
||||
|
||||
div {
|
||||
margin: 0 auto;
|
||||
|
||||
.day {
|
||||
font-size: 1.5em;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.news_events_group_items {
|
||||
display: table-cell;
|
||||
width: 100%;
|
||||
|
||||
.news_event:nth-of-type(odd) {
|
||||
background: white;
|
||||
}
|
||||
|
||||
.news_event:nth-of-type(even) {
|
||||
background: $primary-neutral-light-color;
|
||||
}
|
||||
|
||||
.news_event {
|
||||
display: block;
|
||||
padding: 0.4em;
|
||||
|
||||
&:not(:last-child) {
|
||||
border-bottom: 1px solid grey;
|
||||
}
|
||||
|
||||
div {
|
||||
margin: 0.2em;
|
||||
}
|
||||
|
||||
h4 {
|
||||
margin-top: 1em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.club_logo {
|
||||
float: left;
|
||||
min-width: 7em;
|
||||
@ -534,6 +598,7 @@ a:not(.button) {
|
||||
margin: 0;
|
||||
margin-right: 1em;
|
||||
margin-top: 0.8em;
|
||||
|
||||
img {
|
||||
max-height: 6em;
|
||||
max-width: 8em;
|
||||
@ -541,16 +606,21 @@ a:not(.button) {
|
||||
margin: 0 auto;
|
||||
}
|
||||
}
|
||||
|
||||
.news_date {
|
||||
font-size: 100%;
|
||||
}
|
||||
|
||||
.news_content {
|
||||
clear: left;
|
||||
|
||||
.button_bar {
|
||||
text-align: right;
|
||||
|
||||
.fb {
|
||||
color: $faceblue;
|
||||
}
|
||||
|
||||
.twitter {
|
||||
color: $twitblue;
|
||||
}
|
||||
@ -559,6 +629,7 @@ a:not(.button) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* END EVENTS TODAY AND NEXT FEW DAYS */
|
||||
|
||||
/* COMING SOON */
|
||||
@ -568,14 +639,17 @@ a:not(.button) {
|
||||
list-style-position: inside;
|
||||
margin-left: 1em;
|
||||
padding-left: 0;
|
||||
|
||||
a {
|
||||
font-weight: bold;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.news_date {
|
||||
font-size: 0.9em;
|
||||
}
|
||||
}
|
||||
|
||||
/* END COMING SOON */
|
||||
|
||||
/* NOTICES */
|
||||
@ -586,13 +660,16 @@ a:not(.button) {
|
||||
background: $secondary-neutral-light-color;
|
||||
box-shadow: $shadow-color 0 0 2px;
|
||||
border-radius: 18px 5px 18px 5px;
|
||||
|
||||
h4 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.news_content {
|
||||
margin-left: 1em;
|
||||
}
|
||||
}
|
||||
|
||||
/* END NOTICES */
|
||||
|
||||
/* CALLS */
|
||||
@ -603,21 +680,26 @@ a:not(.button) {
|
||||
background: $secondary-neutral-light-color;
|
||||
border: 1px solid grey;
|
||||
box-shadow: $shadow-color 1px 1px 1px;
|
||||
|
||||
h4 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.news_date {
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.news_content {
|
||||
margin-left: 1em;
|
||||
}
|
||||
}
|
||||
|
||||
/* END CALLS */
|
||||
|
||||
.news_empty {
|
||||
margin-left: 1em;
|
||||
}
|
||||
|
||||
.news_date {
|
||||
color: grey;
|
||||
}
|
||||
@ -631,7 +713,7 @@ a:not(.button) {
|
||||
}
|
||||
|
||||
|
||||
.select2 {
|
||||
.tomselected {
|
||||
margin: 10px 0 !important;
|
||||
max-width: 100%;
|
||||
min-width: 100%;
|
||||
@ -648,7 +730,9 @@ a:not(.button) {
|
||||
color: black;
|
||||
}
|
||||
}
|
||||
.select2-results {
|
||||
|
||||
.ts-dropdown {
|
||||
|
||||
.select-item {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
@ -664,16 +748,39 @@ a:not(.button) {
|
||||
}
|
||||
}
|
||||
|
||||
.ts-control {
|
||||
|
||||
.item {
|
||||
.fa-times {
|
||||
margin-left: 5px;
|
||||
margin-right: 5px;
|
||||
}
|
||||
|
||||
cursor: pointer;
|
||||
background-color: #e4e4e4;
|
||||
border: 1px solid #aaa;
|
||||
border-radius: 4px;
|
||||
display: inline-block;
|
||||
margin-left: 5px;
|
||||
margin-top: 5px;
|
||||
margin-bottom: 5px;
|
||||
padding-right: 10px;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
#news_details {
|
||||
display: inline-block;
|
||||
margin-top: 20px;
|
||||
padding: 0.4em;
|
||||
width: 80%;
|
||||
background: $white-color;
|
||||
|
||||
h4 {
|
||||
margin-top: 1em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.club_logo {
|
||||
display: inline-block;
|
||||
text-align: center;
|
||||
@ -681,6 +788,7 @@ a:not(.button) {
|
||||
float: left;
|
||||
min-width: 15em;
|
||||
margin: 0;
|
||||
|
||||
img {
|
||||
max-height: 15em;
|
||||
max-width: 12em;
|
||||
@ -689,6 +797,7 @@ a:not(.button) {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
.share_button {
|
||||
border: none;
|
||||
color: white;
|
||||
@ -700,6 +809,7 @@ a:not(.button) {
|
||||
float: right;
|
||||
display: block;
|
||||
margin-left: 0.3em;
|
||||
|
||||
&:hover {
|
||||
color: lightgrey;
|
||||
}
|
||||
@ -731,26 +841,32 @@ a:not(.button) {
|
||||
#poster_edit,
|
||||
#screen_edit {
|
||||
position: relative;
|
||||
|
||||
#title {
|
||||
position: relative;
|
||||
padding: 10px;
|
||||
margin: 10px;
|
||||
border-bottom: 2px solid black;
|
||||
|
||||
h3 {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
#links {
|
||||
position: absolute;
|
||||
display: flex;
|
||||
bottom: 5px;
|
||||
|
||||
&.left {
|
||||
left: 0;
|
||||
}
|
||||
|
||||
&.right {
|
||||
right: 0;
|
||||
}
|
||||
|
||||
.link {
|
||||
padding: 5px;
|
||||
padding-left: 20px;
|
||||
@ -759,27 +875,32 @@ a:not(.button) {
|
||||
border-radius: 20px;
|
||||
background-color: hsl(40, 100%, 50%);
|
||||
color: black;
|
||||
|
||||
&:hover {
|
||||
color: black;
|
||||
background-color: hsl(40, 58%, 50%);
|
||||
}
|
||||
|
||||
&.delete {
|
||||
background-color: hsl(0, 100%, 40%);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#posters,
|
||||
#screens {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
|
||||
#no-posters,
|
||||
#no-screens {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.poster,
|
||||
.screen {
|
||||
min-width: 10%;
|
||||
@ -791,26 +912,31 @@ a:not(.button) {
|
||||
border-radius: 4px;
|
||||
padding: 10px;
|
||||
background-color: lightgrey;
|
||||
|
||||
* {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.name {
|
||||
padding-bottom: 5px;
|
||||
margin-bottom: 5px;
|
||||
border-bottom: 1px solid whitesmoke;
|
||||
}
|
||||
|
||||
.image {
|
||||
flex-grow: 1;
|
||||
position: relative;
|
||||
padding-bottom: 5px;
|
||||
margin-bottom: 5px;
|
||||
border-bottom: 1px solid whitesmoke;
|
||||
|
||||
img {
|
||||
max-height: 20vw;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
&::before {
|
||||
position: absolute;
|
||||
@ -829,10 +955,12 @@ a:not(.button) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.dates {
|
||||
padding-bottom: 5px;
|
||||
margin-bottom: 5px;
|
||||
border-bottom: 1px solid whitesmoke;
|
||||
|
||||
* {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
@ -841,15 +969,18 @@ a:not(.button) {
|
||||
margin-left: 5px;
|
||||
margin-right: 5px;
|
||||
}
|
||||
|
||||
.begin,
|
||||
.end {
|
||||
width: 48%;
|
||||
}
|
||||
|
||||
.begin {
|
||||
border-right: 1px solid whitesmoke;
|
||||
padding-right: 2%;
|
||||
}
|
||||
}
|
||||
|
||||
.edit,
|
||||
.moderate,
|
||||
.slideshow {
|
||||
@ -857,15 +988,18 @@ a:not(.button) {
|
||||
border-radius: 20px;
|
||||
background-color: hsl(40, 100%, 50%);
|
||||
color: black;
|
||||
|
||||
&:hover {
|
||||
color: black;
|
||||
background-color: hsl(40, 58%, 50%);
|
||||
}
|
||||
|
||||
&:nth-child(2n) {
|
||||
margin-top: 5px;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
}
|
||||
|
||||
.tooltip {
|
||||
visibility: hidden;
|
||||
width: 120px;
|
||||
@ -876,23 +1010,28 @@ a:not(.button) {
|
||||
border-radius: 6px;
|
||||
position: absolute;
|
||||
z-index: 10;
|
||||
|
||||
ul {
|
||||
margin-left: 0;
|
||||
display: inline-block;
|
||||
|
||||
li {
|
||||
display: list-item;
|
||||
list-style-type: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.not_moderated {
|
||||
border: 1px solid red;
|
||||
}
|
||||
|
||||
&:hover .tooltip {
|
||||
visibility: visible;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#view {
|
||||
position: fixed;
|
||||
width: 100vw;
|
||||
@ -906,9 +1045,11 @@ a:not(.button) {
|
||||
visibility: hidden;
|
||||
background-color: rgba(10, 10, 10, 0.9);
|
||||
overflow: hidden;
|
||||
|
||||
&.active {
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
#placeholder {
|
||||
width: 80vw;
|
||||
height: 80vh;
|
||||
@ -917,6 +1058,7 @@ a:not(.button) {
|
||||
align-items: center;
|
||||
top: 0;
|
||||
left: 0;
|
||||
|
||||
img {
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
@ -931,14 +1073,17 @@ a:not(.button) {
|
||||
tbody {
|
||||
.neg-amount {
|
||||
color: red;
|
||||
|
||||
&:before {
|
||||
font-family: FontAwesome;
|
||||
font-size: 1em;
|
||||
content: "\f063";
|
||||
}
|
||||
}
|
||||
|
||||
.pos-amount {
|
||||
color: green;
|
||||
|
||||
&:before {
|
||||
font-family: FontAwesome;
|
||||
font-size: 1em;
|
||||
@ -1005,6 +1150,7 @@ dt {
|
||||
.edit-bar {
|
||||
display: block;
|
||||
margin: 4px;
|
||||
|
||||
a {
|
||||
display: inline-block;
|
||||
margin: 4px;
|
||||
@ -1044,6 +1190,7 @@ th {
|
||||
vertical-align: middle;
|
||||
text-align: center;
|
||||
padding: 5px 10px;
|
||||
|
||||
>ul {
|
||||
margin-top: 0;
|
||||
}
|
||||
@ -1055,6 +1202,7 @@ td {
|
||||
vertical-align: top;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
|
||||
>ul {
|
||||
margin-top: 0;
|
||||
}
|
||||
@ -1075,11 +1223,13 @@ tbody > tr {
|
||||
&:nth-child(even):not(.highlight) {
|
||||
background: $primary-neutral-light-color;
|
||||
}
|
||||
|
||||
&.clickable:hover {
|
||||
cursor: pointer;
|
||||
background: $secondary-neutral-light-color;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
&.highlight {
|
||||
color: $primary-dark-color;
|
||||
font-style: italic;
|
||||
@ -1139,9 +1289,11 @@ u,
|
||||
margin: 0.2em;
|
||||
height: 100%;
|
||||
background: $secondary-neutral-light-color;
|
||||
|
||||
img {
|
||||
max-width: 70%;
|
||||
}
|
||||
|
||||
input {
|
||||
background: white;
|
||||
}
|
||||
@ -1153,10 +1305,12 @@ u,
|
||||
.user_mini_profile {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
|
||||
img {
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
}
|
||||
|
||||
.user_mini_profile_infos {
|
||||
padding: 0.2em;
|
||||
height: 20%;
|
||||
@ -1164,16 +1318,20 @@ u,
|
||||
flex-wrap: nowrap;
|
||||
justify-content: space-around;
|
||||
font-size: 0.9em;
|
||||
|
||||
div {
|
||||
max-height: 100%;
|
||||
}
|
||||
|
||||
.user_mini_profile_infos_text {
|
||||
text-align: center;
|
||||
|
||||
.user_mini_profile_nick {
|
||||
font-style: italic;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.user_mini_profile_picture {
|
||||
height: 80%;
|
||||
display: flex;
|
||||
@ -1185,14 +1343,17 @@ u,
|
||||
.mini_profile_link {
|
||||
display: block;
|
||||
text-decoration: none;
|
||||
|
||||
span {
|
||||
display: inline-block;
|
||||
width: 50px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
em {
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
img {
|
||||
max-width: 40px;
|
||||
max-height: 60px;
|
||||
@ -1214,6 +1375,7 @@ u,
|
||||
border: solid 1px red;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
img {
|
||||
width: 500px;
|
||||
}
|
||||
@ -1223,6 +1385,7 @@ u,
|
||||
.matmat_results {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
|
||||
.matmat_user {
|
||||
flex-basis: 14em;
|
||||
align-self: flex-start;
|
||||
@ -1231,10 +1394,12 @@ u,
|
||||
overflow: hidden;
|
||||
border: 1px solid black;
|
||||
box-shadow: $shadow-color 1px 1px 1px;
|
||||
|
||||
&:hover {
|
||||
box-shadow: 1px 1px 5px $second-color;
|
||||
}
|
||||
}
|
||||
|
||||
.matmat_user a {
|
||||
color: $primary-neutral-dark-color;
|
||||
height: 100%;
|
||||
@ -1274,6 +1439,7 @@ footer {
|
||||
font-size: 90%;
|
||||
text-align: center;
|
||||
vertical-align: middle;
|
||||
|
||||
div {
|
||||
margin: 0.6em 0;
|
||||
color: $white-color;
|
||||
@ -1283,11 +1449,13 @@ footer {
|
||||
align-items: center;
|
||||
background-color: $primary-neutral-dark-color;
|
||||
box-shadow: $shadow-color 0 0 15px;
|
||||
|
||||
a {
|
||||
padding: 0.8em;
|
||||
flex: 1;
|
||||
font-weight: bold;
|
||||
color: $white-color !important;
|
||||
|
||||
&:hover {
|
||||
color: $primary-dark-color;
|
||||
}
|
||||
@ -1326,6 +1494,7 @@ label {
|
||||
* {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
img {
|
||||
width: 100px;
|
||||
}
|
||||
@ -1342,19 +1511,23 @@ label {
|
||||
padding: 2px;
|
||||
display: inline-block;
|
||||
font-size: 0.8em;
|
||||
|
||||
span {
|
||||
width: 70px;
|
||||
float: right;
|
||||
}
|
||||
|
||||
img {
|
||||
max-width: 50px;
|
||||
max-height: 50px;
|
||||
float: left;
|
||||
}
|
||||
|
||||
strong {
|
||||
font-weight: bold;
|
||||
font-size: 1.2em;
|
||||
}
|
||||
|
||||
button {
|
||||
vertical-align: middle;
|
||||
}
|
||||
@ -1371,6 +1544,7 @@ a.ui-button:active,
|
||||
background: $primary-color;
|
||||
border-color: $primary-color;
|
||||
}
|
||||
|
||||
.ui-corner-all,
|
||||
.ui-corner-bottom,
|
||||
.ui-corner-right,
|
||||
@ -1382,6 +1556,7 @@ a.ui-button:active,
|
||||
#club_detail {
|
||||
.club_logo {
|
||||
float: right;
|
||||
|
||||
img {
|
||||
display: block;
|
||||
max-height: 10em;
|
||||
|
93
core/static/webpack/ajax-select-index.ts
Normal file
93
core/static/webpack/ajax-select-index.ts
Normal 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("");
|
||||
});
|
||||
}
|
||||
}
|
@ -1,53 +1,57 @@
|
||||
// biome-ignore lint/correctness/noUndeclaredDependencies: shipped by easymde
|
||||
import "codemirror/lib/codemirror.css";
|
||||
import "easymde/src/css/easymde.css";
|
||||
import easyMde from "easymde";
|
||||
import { inheritHtmlElement, registerComponent } from "#core:utils/web-components";
|
||||
// biome-ignore lint/correctness/noUndeclaredDependencies: Imported by EasyMDE
|
||||
import type CodeMirror from "codemirror";
|
||||
// biome-ignore lint/style/useNamingConvention: This is how they called their namespace
|
||||
import EasyMDE from "easymde";
|
||||
import { markdownRenderMarkdown } from "#openapi";
|
||||
|
||||
/**
|
||||
* Create a new easymde based textarea
|
||||
* @param {HTMLTextAreaElement} textarea to use
|
||||
**/
|
||||
window.easymdeFactory = (textarea) => {
|
||||
const easymde = new easyMde({
|
||||
const loadEasyMde = (textarea: HTMLTextAreaElement) => {
|
||||
new EasyMDE({
|
||||
element: textarea,
|
||||
spellChecker: false,
|
||||
autoDownloadFontAwesome: false,
|
||||
previewRender: Alpine.debounce(async (plainText, preview) => {
|
||||
previewRender: Alpine.debounce((plainText: string, preview: MarkdownInput) => {
|
||||
const func = async (plainText: string, preview: MarkdownInput): Promise<null> => {
|
||||
preview.innerHTML = (
|
||||
await markdownRenderMarkdown({ body: { text: plainText } })
|
||||
).data;
|
||||
).data as string;
|
||||
return null;
|
||||
};
|
||||
func(plainText, preview);
|
||||
return null;
|
||||
}, 300),
|
||||
forceSync: true, // Avoid validation error on generic create view
|
||||
toolbar: [
|
||||
{
|
||||
name: "heading-smaller",
|
||||
action: easyMde.toggleHeadingSmaller,
|
||||
action: EasyMDE.toggleHeadingSmaller,
|
||||
className: "fa fa-header",
|
||||
title: gettext("Heading"),
|
||||
},
|
||||
{
|
||||
name: "italic",
|
||||
action: easyMde.toggleItalic,
|
||||
action: EasyMDE.toggleItalic,
|
||||
className: "fa fa-italic",
|
||||
title: gettext("Italic"),
|
||||
},
|
||||
{
|
||||
name: "bold",
|
||||
action: easyMde.toggleBold,
|
||||
action: EasyMDE.toggleBold,
|
||||
className: "fa fa-bold",
|
||||
title: gettext("Bold"),
|
||||
},
|
||||
{
|
||||
name: "strikethrough",
|
||||
action: easyMde.toggleStrikethrough,
|
||||
action: EasyMDE.toggleStrikethrough,
|
||||
className: "fa fa-strikethrough",
|
||||
title: gettext("Strikethrough"),
|
||||
},
|
||||
{
|
||||
name: "underline",
|
||||
action: function customFunction(editor) {
|
||||
action: function customFunction(editor: { codemirror: CodeMirror.Editor }) {
|
||||
const cm = editor.codemirror;
|
||||
cm.replaceSelection(`__${cm.getSelection()}__`);
|
||||
},
|
||||
@ -56,7 +60,7 @@ window.easymdeFactory = (textarea) => {
|
||||
},
|
||||
{
|
||||
name: "superscript",
|
||||
action: function customFunction(editor) {
|
||||
action: function customFunction(editor: { codemirror: CodeMirror.Editor }) {
|
||||
const cm = editor.codemirror;
|
||||
cm.replaceSelection(`^${cm.getSelection()}^`);
|
||||
},
|
||||
@ -65,7 +69,7 @@ window.easymdeFactory = (textarea) => {
|
||||
},
|
||||
{
|
||||
name: "subscript",
|
||||
action: function customFunction(editor) {
|
||||
action: function customFunction(editor: { codemirror: CodeMirror.Editor }) {
|
||||
const cm = editor.codemirror;
|
||||
cm.replaceSelection(`~${cm.getSelection()}~`);
|
||||
},
|
||||
@ -74,71 +78,71 @@ window.easymdeFactory = (textarea) => {
|
||||
},
|
||||
{
|
||||
name: "code",
|
||||
action: easyMde.toggleCodeBlock,
|
||||
action: EasyMDE.toggleCodeBlock,
|
||||
className: "fa fa-code",
|
||||
title: gettext("Code"),
|
||||
},
|
||||
"|",
|
||||
{
|
||||
name: "quote",
|
||||
action: easyMde.toggleBlockquote,
|
||||
action: EasyMDE.toggleBlockquote,
|
||||
className: "fa fa-quote-left",
|
||||
title: gettext("Quote"),
|
||||
},
|
||||
{
|
||||
name: "unordered-list",
|
||||
action: easyMde.toggleUnorderedList,
|
||||
action: EasyMDE.toggleUnorderedList,
|
||||
className: "fa fa-list-ul",
|
||||
title: gettext("Unordered list"),
|
||||
},
|
||||
{
|
||||
name: "ordered-list",
|
||||
action: easyMde.toggleOrderedList,
|
||||
action: EasyMDE.toggleOrderedList,
|
||||
className: "fa fa-list-ol",
|
||||
title: gettext("Ordered list"),
|
||||
},
|
||||
"|",
|
||||
{
|
||||
name: "link",
|
||||
action: easyMde.drawLink,
|
||||
action: EasyMDE.drawLink,
|
||||
className: "fa fa-link",
|
||||
title: gettext("Insert link"),
|
||||
},
|
||||
{
|
||||
name: "image",
|
||||
action: easyMde.drawImage,
|
||||
action: EasyMDE.drawImage,
|
||||
className: "fa-regular fa-image",
|
||||
title: gettext("Insert image"),
|
||||
},
|
||||
{
|
||||
name: "table",
|
||||
action: easyMde.drawTable,
|
||||
action: EasyMDE.drawTable,
|
||||
className: "fa fa-table",
|
||||
title: gettext("Insert table"),
|
||||
},
|
||||
"|",
|
||||
{
|
||||
name: "clean-block",
|
||||
action: easyMde.cleanBlock,
|
||||
action: EasyMDE.cleanBlock,
|
||||
className: "fa fa-eraser fa-clean-block",
|
||||
title: gettext("Clean block"),
|
||||
},
|
||||
"|",
|
||||
{
|
||||
name: "preview",
|
||||
action: easyMde.togglePreview,
|
||||
action: EasyMDE.togglePreview,
|
||||
className: "fa fa-eye no-disable",
|
||||
title: gettext("Toggle preview"),
|
||||
},
|
||||
{
|
||||
name: "side-by-side",
|
||||
action: easyMde.toggleSideBySide,
|
||||
action: EasyMDE.toggleSideBySide,
|
||||
className: "fa fa-columns no-disable no-mobile",
|
||||
title: gettext("Toggle side by side"),
|
||||
},
|
||||
{
|
||||
name: "fullscreen",
|
||||
action: easyMde.toggleFullScreen,
|
||||
action: EasyMDE.toggleFullScreen,
|
||||
className: "fa fa-expand no-mobile",
|
||||
title: gettext("Toggle fullscreen"),
|
||||
},
|
||||
@ -152,27 +156,25 @@ window.easymdeFactory = (textarea) => {
|
||||
],
|
||||
});
|
||||
|
||||
const submits = textarea.closest("form").querySelectorAll('input[type="submit"]');
|
||||
const parentDiv = textarea.parentElement;
|
||||
let submitPressed = false;
|
||||
const submits: HTMLInputElement[] = Array.from(
|
||||
textarea.closest("form").querySelectorAll('input[type="submit"]'),
|
||||
);
|
||||
const parentDiv = textarea.parentElement.parentElement;
|
||||
|
||||
function checkMarkdownInput() {
|
||||
function checkMarkdownInput(event: Event) {
|
||||
// an attribute is null if it does not exist, else a string
|
||||
const required = textarea.getAttribute("required") != null;
|
||||
const length = textarea.value.trim().length;
|
||||
|
||||
if (required && length === 0) {
|
||||
parentDiv.style.boxShadow = "red 0px 0px 1.5px 1px";
|
||||
event.preventDefault();
|
||||
} else {
|
||||
parentDiv.style.boxShadow = "";
|
||||
}
|
||||
}
|
||||
|
||||
function onSubmitClick(e) {
|
||||
if (!submitPressed) {
|
||||
easymde.codemirror.on("change", checkMarkdownInput);
|
||||
}
|
||||
submitPressed = true;
|
||||
function onSubmitClick(e: Event) {
|
||||
checkMarkdownInput(e);
|
||||
}
|
||||
|
||||
@ -180,3 +182,11 @@ window.easymdeFactory = (textarea) => {
|
||||
submit.addEventListener("click", onSubmitClick);
|
||||
}
|
||||
};
|
||||
|
||||
@registerComponent("markdown-input")
|
||||
class MarkdownInput extends inheritHtmlElement("textarea") {
|
||||
constructor() {
|
||||
super();
|
||||
window.addEventListener("DOMContentLoaded", () => loadEasyMde(this.node));
|
||||
}
|
||||
}
|
3
core/static/webpack/jquery-index.js
vendored
3
core/static/webpack/jquery-index.js
vendored
@ -13,9 +13,6 @@ require("jquery-ui/ui/widgets/tabs.js");
|
||||
|
||||
require("jquery-ui/themes/base/all.css");
|
||||
|
||||
// We ship select2 here, otherwise it will duplicate jquery everywhere we load it
|
||||
import "select2";
|
||||
|
||||
/**
|
||||
* Simple wrapper to solve shorten not being able on legacy pages
|
||||
* @param {string} selector to be passed to jQuery
|
||||
|
@ -3,6 +3,7 @@ import type { Alpine as AlpineType } from "alpinejs";
|
||||
declare global {
|
||||
const Alpine: AlpineType;
|
||||
const gettext: (text: string) => string;
|
||||
const interpolate: <T>(fmt: string, args: string[] | T, isNamed?: boolean) => string;
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -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>`);
|
||||
};
|
||||
}
|
50
core/static/webpack/utils/web-components.ts
Normal file
50
core/static/webpack/utils/web-components.ts
Normal 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);
|
||||
}
|
||||
};
|
||||
}
|
@ -5,6 +5,7 @@
|
||||
<title>{% block title %}{% trans %}Welcome!{% endtrans %}{% endblock %} - Association des Étudiants UTBM</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<link rel="shortcut icon" href="{{ static('core/img/favicon.ico') }}">
|
||||
<link rel="stylesheet" href="{{ static('user/user_stats.scss') }}">
|
||||
<link rel="stylesheet" href="{{ static('core/base.css') }}">
|
||||
<link rel="stylesheet" href="{{ static('ajax_select/css/ajax_select.css') }}">
|
||||
<link rel="stylesheet" href="{{ static('core/style.scss') }}">
|
||||
|
@ -1,13 +1,7 @@
|
||||
<div>
|
||||
<textarea name="{{ widget.name }}"{% include "django/forms/widgets/attrs.html" %}>{% if widget.value %}{{ widget.value }}{% endif %}</textarea>
|
||||
<markdown-input name="{{ widget.name }}"{% include "django/forms/widgets/attrs.html" %}>{% if widget.value %}{{ widget.value }}{% endif %}</markdown-input>
|
||||
|
||||
{# The easymde script can be included twice, it's safe in the code #}
|
||||
<script src="{{ statics.js }}" defer> </script>
|
||||
<link rel="stylesheet" type="text/css" href="{{ statics.css }}" defer>
|
||||
<script type="text/javascript">
|
||||
addEventListener("DOMContentLoaded", (event) => {
|
||||
easymdeFactory(
|
||||
document.getElementById("{{ widget.attrs.id }}"));
|
||||
})
|
||||
</script>
|
||||
</div>
|
||||
|
@ -50,7 +50,7 @@ def phonenumber(
|
||||
try:
|
||||
parsed = phonenumbers.parse(value, country)
|
||||
return phonenumbers.format_number(parsed, number_format)
|
||||
except phonenumbers.NumberParseException as e:
|
||||
except phonenumbers.NumberParseException:
|
||||
return value
|
||||
|
||||
|
||||
|
@ -343,7 +343,7 @@ class TestUserTools:
|
||||
response = client.get(reverse("core:user_tools"))
|
||||
assertRedirects(
|
||||
response,
|
||||
expected_url=f"/login?next=%2Fuser%2Ftools%2F",
|
||||
expected_url="/login?next=%2Fuser%2Ftools%2F",
|
||||
target_status_code=301,
|
||||
)
|
||||
|
||||
|
@ -73,7 +73,7 @@ class TestFetchFamilyApi(TestCase):
|
||||
self.client.force_login(self.main_user)
|
||||
response = self.client.get(
|
||||
reverse("api:family_graph", args=[self.main_user.id])
|
||||
+ f"?godfathers_depth=0&godchildren_depth=0"
|
||||
+ "?godfathers_depth=0&godchildren_depth=0"
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert [u["id"] for u in response.json()["users"]] == [self.main_user.id]
|
||||
@ -91,7 +91,7 @@ class TestFetchFamilyApi(TestCase):
|
||||
self.client.force_login(self.main_user)
|
||||
response = self.client.get(
|
||||
reverse("api:family_graph", args=[self.main_user.id])
|
||||
+ f"?godfathers_depth=10&godchildren_depth=10"
|
||||
+ "?godfathers_depth=10&godchildren_depth=10"
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert [u["id"] for u in response.json()["users"]] == [
|
||||
@ -126,7 +126,7 @@ class TestFetchFamilyApi(TestCase):
|
||||
self.client.force_login(self.main_user)
|
||||
response = self.client.get(
|
||||
reverse("api:family_graph", args=[self.main_user.id])
|
||||
+ f"?godfathers_depth=1&godchildren_depth=1"
|
||||
+ "?godfathers_depth=1&godchildren_depth=1"
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert [u["id"] for u in response.json()["users"]] == [
|
||||
@ -150,7 +150,7 @@ class TestFetchFamilyApi(TestCase):
|
||||
self.client.force_login(self.main_user)
|
||||
response = self.client.get(
|
||||
reverse("api:family_graph", args=[self.main_user.id])
|
||||
+ f"?godfathers_depth=10&godchildren_depth=0"
|
||||
+ "?godfathers_depth=10&godchildren_depth=0"
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert [u["id"] for u in response.json()["users"]] == [
|
||||
|
136
core/urls.py
136
core/urls.py
@ -29,13 +29,67 @@ from core.converters import (
|
||||
FourDigitYearConverter,
|
||||
TwoDigitMonthConverter,
|
||||
)
|
||||
from core.views import *
|
||||
from core.views import (
|
||||
FileDeleteView,
|
||||
FileEditPropView,
|
||||
FileEditView,
|
||||
FileListView,
|
||||
FileModerateView,
|
||||
FileModerationView,
|
||||
FileView,
|
||||
GiftCreateView,
|
||||
GiftDeleteView,
|
||||
GroupCreateView,
|
||||
GroupDeleteView,
|
||||
GroupEditView,
|
||||
GroupListView,
|
||||
GroupTemplateView,
|
||||
NotificationList,
|
||||
PageCreateView,
|
||||
PageDeleteView,
|
||||
PageEditView,
|
||||
PageHistView,
|
||||
PageListView,
|
||||
PagePropView,
|
||||
PageRevView,
|
||||
PageView,
|
||||
SithLoginView,
|
||||
SithPasswordChangeDoneView,
|
||||
SithPasswordChangeView,
|
||||
SithPasswordResetCompleteView,
|
||||
SithPasswordResetConfirmView,
|
||||
SithPasswordResetDoneView,
|
||||
SithPasswordResetView,
|
||||
UserAccountDetailView,
|
||||
UserAccountView,
|
||||
UserClubView,
|
||||
UserCreationView,
|
||||
UserGodfathersTreeView,
|
||||
UserGodfathersView,
|
||||
UserListView,
|
||||
UserMiniView,
|
||||
UserPicturesView,
|
||||
UserPreferencesView,
|
||||
UserStatsView,
|
||||
UserToolsView,
|
||||
UserUpdateGroupView,
|
||||
UserUpdateProfileView,
|
||||
UserView,
|
||||
delete_user_godfather,
|
||||
index,
|
||||
logout,
|
||||
notification,
|
||||
password_root_change,
|
||||
search_json,
|
||||
search_user_json,
|
||||
search_view,
|
||||
send_file,
|
||||
)
|
||||
|
||||
register_converter(FourDigitYearConverter, "yyyy")
|
||||
register_converter(TwoDigitMonthConverter, "mm")
|
||||
register_converter(BooleanStringConverter, "bool")
|
||||
|
||||
|
||||
urlpatterns = [
|
||||
path("", index, name="index"),
|
||||
path("notifications/", NotificationList.as_view(), name="notification_list"),
|
||||
@ -80,27 +134,17 @@ urlpatterns = [
|
||||
path("group/new/", GroupCreateView.as_view(), name="group_new"),
|
||||
path("group/<int:group_id>/", GroupEditView.as_view(), name="group_edit"),
|
||||
path(
|
||||
"group/<int:group_id>/delete/",
|
||||
GroupDeleteView.as_view(),
|
||||
name="group_delete",
|
||||
"group/<int:group_id>/delete/", GroupDeleteView.as_view(), name="group_delete"
|
||||
),
|
||||
path(
|
||||
"group/<int:group_id>/detail/",
|
||||
GroupTemplateView.as_view(),
|
||||
name="group_detail",
|
||||
"group/<int:group_id>/detail/", GroupTemplateView.as_view(), name="group_detail"
|
||||
),
|
||||
# User views
|
||||
path("user/", UserListView.as_view(), name="user_list"),
|
||||
path(
|
||||
"user/<int:user_id>/mini/",
|
||||
UserMiniView.as_view(),
|
||||
name="user_profile_mini",
|
||||
),
|
||||
path("user/<int:user_id>/mini/", UserMiniView.as_view(), name="user_profile_mini"),
|
||||
path("user/<int:user_id>/", UserView.as_view(), name="user_profile"),
|
||||
path(
|
||||
"user/<int:user_id>/pictures/",
|
||||
UserPicturesView.as_view(),
|
||||
name="user_pictures",
|
||||
"user/<int:user_id>/pictures/", UserPicturesView.as_view(), name="user_pictures"
|
||||
),
|
||||
path(
|
||||
"user/<int:user_id>/godfathers/",
|
||||
@ -117,28 +161,14 @@ urlpatterns = [
|
||||
delete_user_godfather,
|
||||
name="user_godfathers_delete",
|
||||
),
|
||||
path(
|
||||
"user/<int:user_id>/edit/",
|
||||
UserUpdateProfileView.as_view(),
|
||||
name="user_edit",
|
||||
),
|
||||
path("user/<int:user_id>/edit/", UserUpdateProfileView.as_view(), name="user_edit"),
|
||||
path("user/<int:user_id>/clubs/", UserClubView.as_view(), name="user_clubs"),
|
||||
path("user/<int:user_id>/prefs/", UserPreferencesView.as_view(), name="user_prefs"),
|
||||
path(
|
||||
"user/<int:user_id>/prefs/",
|
||||
UserPreferencesView.as_view(),
|
||||
name="user_prefs",
|
||||
),
|
||||
path(
|
||||
"user/<int:user_id>/groups/",
|
||||
UserUpdateGroupView.as_view(),
|
||||
name="user_groups",
|
||||
"user/<int:user_id>/groups/", UserUpdateGroupView.as_view(), name="user_groups"
|
||||
),
|
||||
path("user/tools/", UserToolsView.as_view(), name="user_tools"),
|
||||
path(
|
||||
"user/<int:user_id>/account/",
|
||||
UserAccountView.as_view(),
|
||||
name="user_account",
|
||||
),
|
||||
path("user/<int:user_id>/account/", UserAccountView.as_view(), name="user_account"),
|
||||
path(
|
||||
"user/<int:user_id>/account/<yyyy:year>/<mm:month>/",
|
||||
UserAccountDetailView.as_view(),
|
||||
@ -179,42 +209,18 @@ urlpatterns = [
|
||||
),
|
||||
path("file/moderation/", FileModerationView.as_view(), name="file_moderation"),
|
||||
path(
|
||||
"file/<int:file_id>/moderate/",
|
||||
FileModerateView.as_view(),
|
||||
name="file_moderate",
|
||||
"file/<int:file_id>/moderate/", FileModerateView.as_view(), name="file_moderate"
|
||||
),
|
||||
path("file/<int:file_id>/download/", send_file, name="download"),
|
||||
# Page views
|
||||
path("page/", PageListView.as_view(), name="page_list"),
|
||||
path("page/create/", PageCreateView.as_view(), name="page_new"),
|
||||
path("page/<int:page_id>/delete/", PageDeleteView.as_view(), name="page_delete"),
|
||||
path("page/<path:page_name>/edit/", PageEditView.as_view(), name="page_edit"),
|
||||
path("page/<path:page_name>/prop/", PagePropView.as_view(), name="page_prop"),
|
||||
path("page/<path:page_name>/hist/", PageHistView.as_view(), name="page_hist"),
|
||||
path(
|
||||
"page/<int:page_id>/delete/",
|
||||
PageDeleteView.as_view(),
|
||||
name="page_delete",
|
||||
),
|
||||
path(
|
||||
"page/<path:page_name>/edit/",
|
||||
PageEditView.as_view(),
|
||||
name="page_edit",
|
||||
),
|
||||
path(
|
||||
"page/<path:page_name>/prop/",
|
||||
PagePropView.as_view(),
|
||||
name="page_prop",
|
||||
),
|
||||
path(
|
||||
"page/<path:page_name>/hist/",
|
||||
PageHistView.as_view(),
|
||||
name="page_hist",
|
||||
),
|
||||
path(
|
||||
"page/<path:page_name>/rev/<int:rev>/",
|
||||
PageRevView.as_view(),
|
||||
name="page_rev",
|
||||
),
|
||||
path(
|
||||
"page/<path:page_name>/",
|
||||
PageView.as_view(),
|
||||
name="page",
|
||||
"page/<path:page_name>/rev/<int:rev>/", PageRevView.as_view(), name="page_rev"
|
||||
),
|
||||
path("page/<path:page_name>/", PageView.as_view(), name="page"),
|
||||
]
|
||||
|
@ -127,7 +127,7 @@ def resize_image_explicit(
|
||||
|
||||
|
||||
def exif_auto_rotate(image):
|
||||
for orientation in ExifTags.TAGS.keys():
|
||||
for orientation in ExifTags.TAGS:
|
||||
if ExifTags.TAGS[orientation] == "Orientation":
|
||||
break
|
||||
exif = dict(image._getexif().items())
|
||||
|
@ -25,6 +25,7 @@
|
||||
import types
|
||||
from typing import Any
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.mixins import AccessMixin
|
||||
from django.core.exceptions import (
|
||||
ImproperlyConfigured,
|
||||
@ -35,6 +36,7 @@ from django.http import (
|
||||
HttpResponseNotFound,
|
||||
HttpResponseServerError,
|
||||
)
|
||||
from django.shortcuts import render
|
||||
from django.utils.functional import cached_property
|
||||
from django.views.generic.base import View
|
||||
from django.views.generic.detail import SingleObjectMixin
|
||||
@ -79,9 +81,7 @@ def can_edit_prop(obj: Any, user: User) -> bool:
|
||||
raise PermissionDenied
|
||||
```
|
||||
"""
|
||||
if obj is None or user.is_owner(obj):
|
||||
return True
|
||||
return False
|
||||
return obj is None or user.is_owner(obj)
|
||||
|
||||
|
||||
def can_edit(obj: Any, user: User) -> bool:
|
||||
@ -232,7 +232,9 @@ class UserIsRootMixin(GenericContentPermissionMixinBuilder):
|
||||
PermissionDenied: if the user isn't root
|
||||
"""
|
||||
|
||||
permission_function = lambda obj, user: user.is_root
|
||||
@staticmethod
|
||||
def permission_function(obj: Any, user: User):
|
||||
return user.is_root
|
||||
|
||||
|
||||
class FormerSubscriberMixin(AccessMixin):
|
||||
@ -304,10 +306,10 @@ class QuickNotifMixin:
|
||||
kwargs["quick_notifs"] = []
|
||||
for n in self.quick_notif_list:
|
||||
kwargs["quick_notifs"].append(settings.SITH_QUICK_NOTIF[n])
|
||||
for k, v in settings.SITH_QUICK_NOTIF.items():
|
||||
for gk in self.request.GET.keys():
|
||||
if k == gk:
|
||||
kwargs["quick_notifs"].append(v)
|
||||
for key, val in settings.SITH_QUICK_NOTIF.items():
|
||||
for gk in self.request.GET:
|
||||
if key == gk:
|
||||
kwargs["quick_notifs"].append(val)
|
||||
return kwargs
|
||||
|
||||
|
||||
@ -324,8 +326,10 @@ class DetailFormView(SingleObjectMixin, FormView):
|
||||
return super().get_object()
|
||||
|
||||
|
||||
from .files import *
|
||||
from .group import *
|
||||
from .page import *
|
||||
from .site import *
|
||||
from .user import *
|
||||
# F403: those star-imports would be hellish to refactor
|
||||
# E402: putting those import at the top of the file would also be difficult
|
||||
from .files import * # noqa: F403 E402
|
||||
from .group import * # noqa: F403 E402
|
||||
from .page import * # noqa: F403 E402
|
||||
from .site import * # noqa: F403 E402
|
||||
from .user import * # noqa: F403 E402
|
||||
|
@ -193,7 +193,7 @@ class FileEditView(CanEditMixin, UpdateView):
|
||||
def get_form_class(self):
|
||||
fields = ["name", "is_moderated"]
|
||||
if self.object.is_file:
|
||||
fields = ["file"] + fields
|
||||
fields = ["file", *fields]
|
||||
return modelform_factory(SithFile, fields=fields)
|
||||
|
||||
def get_success_url(self):
|
||||
@ -283,38 +283,38 @@ class FileView(CanViewMixin, DetailView, FormMixin):
|
||||
`obj` is the SithFile object you want to put in the clipboard, or
|
||||
where you want to paste the clipboard
|
||||
"""
|
||||
if "delete" in request.POST.keys():
|
||||
if "delete" in request.POST:
|
||||
for f_id in request.POST.getlist("file_list"):
|
||||
sf = SithFile.objects.filter(id=f_id).first()
|
||||
if sf:
|
||||
sf.delete()
|
||||
if "clear" in request.POST.keys():
|
||||
file = SithFile.objects.filter(id=f_id).first()
|
||||
if file:
|
||||
file.delete()
|
||||
if "clear" in request.POST:
|
||||
request.session["clipboard"] = []
|
||||
if "cut" in request.POST.keys():
|
||||
for f_id in request.POST.getlist("file_list"):
|
||||
f_id = int(f_id)
|
||||
if "cut" in request.POST:
|
||||
for f_id_str in request.POST.getlist("file_list"):
|
||||
f_id = int(f_id_str)
|
||||
if (
|
||||
f_id in [c.id for c in obj.children.all()]
|
||||
and f_id not in request.session["clipboard"]
|
||||
):
|
||||
request.session["clipboard"].append(f_id)
|
||||
if "paste" in request.POST.keys():
|
||||
if "paste" in request.POST:
|
||||
for f_id in request.session["clipboard"]:
|
||||
sf = SithFile.objects.filter(id=f_id).first()
|
||||
if sf:
|
||||
sf.move_to(obj)
|
||||
file = SithFile.objects.filter(id=f_id).first()
|
||||
if file:
|
||||
file.move_to(obj)
|
||||
request.session["clipboard"] = []
|
||||
request.session.modified = True
|
||||
|
||||
def get(self, request, *args, **kwargs):
|
||||
self.form = self.get_form()
|
||||
if "clipboard" not in request.session.keys():
|
||||
if "clipboard" not in request.session:
|
||||
request.session["clipboard"] = []
|
||||
return super().get(request, *args, **kwargs)
|
||||
|
||||
def post(self, request, *args, **kwargs):
|
||||
self.object = self.get_object()
|
||||
if "clipboard" not in request.session.keys():
|
||||
if "clipboard" not in request.session:
|
||||
request.session["clipboard"] = []
|
||||
if request.user.can_edit(self.object):
|
||||
# XXX this call can fail!
|
||||
@ -398,6 +398,6 @@ class FileModerateView(CanEditPropMixin, SingleObjectMixin):
|
||||
self.object.is_moderated = True
|
||||
self.object.moderator = request.user
|
||||
self.object.save()
|
||||
if "next" in self.request.GET.keys():
|
||||
if "next" in self.request.GET:
|
||||
return redirect(self.request.GET["next"])
|
||||
return redirect("core:file_moderation")
|
||||
|
@ -29,6 +29,7 @@ from captcha.fields import CaptchaField
|
||||
from django import forms
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.forms import AuthenticationForm, UserCreationForm
|
||||
from django.contrib.staticfiles.storage import staticfiles_storage
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db import transaction
|
||||
from django.forms import (
|
||||
@ -38,7 +39,6 @@ from django.forms import (
|
||||
Textarea,
|
||||
TextInput,
|
||||
)
|
||||
from django.templatetags.static import static
|
||||
from django.utils.translation import gettext
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from phonenumber_field.widgets import RegionalPhoneNumberWidget
|
||||
@ -72,8 +72,8 @@ class MarkdownInput(Textarea):
|
||||
context = super().get_context(name, value, attrs)
|
||||
|
||||
context["statics"] = {
|
||||
"js": static("webpack/easymde-index.js"),
|
||||
"css": static("webpack/easymde-index.css"),
|
||||
"js": staticfiles_storage.url("webpack/easymde-index.ts"),
|
||||
"css": staticfiles_storage.url("webpack/easymde-index.css"),
|
||||
}
|
||||
return context
|
||||
|
||||
@ -140,7 +140,7 @@ class SelectUser(TextInput):
|
||||
|
||||
class LoginForm(AuthenticationForm):
|
||||
def __init__(self, *arg, **kwargs):
|
||||
if "data" in kwargs.keys():
|
||||
if "data" in kwargs:
|
||||
from counter.models import Customer
|
||||
|
||||
data = kwargs["data"].copy()
|
||||
@ -157,7 +157,7 @@ class LoginForm(AuthenticationForm):
|
||||
else:
|
||||
user = User.objects.filter(username=data["username"]).first()
|
||||
data["username"] = user.username
|
||||
except:
|
||||
except: # noqa E722 I don't know what error is supposed to be raised here
|
||||
pass
|
||||
kwargs["data"] = data
|
||||
super().__init__(*arg, **kwargs)
|
||||
|
@ -55,7 +55,7 @@ class PageView(CanViewMixin, DetailView):
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
if "page" not in context.keys():
|
||||
if "page" not in context:
|
||||
context["new_page"] = self.kwargs["page_name"]
|
||||
return context
|
||||
|
||||
@ -92,22 +92,16 @@ class PageRevView(CanViewMixin, DetailView):
|
||||
)
|
||||
return res
|
||||
|
||||
def get_object(self):
|
||||
def get_object(self, *args, **kwargs):
|
||||
self.page = Page.get_page_by_full_name(self.kwargs["page_name"])
|
||||
return self.page
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
if self.page is not None:
|
||||
if not self.page:
|
||||
return context | {"new_page": self.kwargs["page_name"]}
|
||||
context["page"] = self.page
|
||||
try:
|
||||
rev = self.page.revisions.get(id=self.kwargs["rev"])
|
||||
context["rev"] = rev
|
||||
except:
|
||||
# By passing, the template will just display the normal page without taking revision into account
|
||||
pass
|
||||
else:
|
||||
context["new_page"] = self.kwargs["page_name"]
|
||||
context["rev"] = self.page.revisions.filter(id=self.kwargs["rev"]).first()
|
||||
return context
|
||||
|
||||
|
||||
@ -118,7 +112,7 @@ class PageCreateView(CanCreateMixin, CreateView):
|
||||
|
||||
def get_initial(self):
|
||||
init = {}
|
||||
if "page" in self.request.GET.keys():
|
||||
if "page" in self.request.GET:
|
||||
page_name = self.request.GET["page"]
|
||||
parent_name = "/".join(page_name.split("/")[:-1])
|
||||
parent = Page.get_page_by_full_name(parent_name)
|
||||
@ -145,18 +139,8 @@ class PagePropView(CanEditPagePropMixin, UpdateView):
|
||||
slug_field = "_full_name"
|
||||
slug_url_kwarg = "page_name"
|
||||
|
||||
def get_object(self):
|
||||
o = super().get_object()
|
||||
# Create the page if it does not exists
|
||||
# if p == None:
|
||||
# parent_name = '/'.join(page_name.split('/')[:-1])
|
||||
# name = page_name.split('/')[-1]
|
||||
# if parent_name == "":
|
||||
# p = Page(name=name)
|
||||
# else:
|
||||
# parent = Page.get_page_by_full_name(parent_name)
|
||||
# p = Page(name=name, parent=parent)
|
||||
self.page = o
|
||||
def get_object(self, queryset=None):
|
||||
self.page = super().get_object()
|
||||
try:
|
||||
self.page.set_lock_recursive(self.request.user)
|
||||
except LockError as e:
|
||||
|
@ -53,11 +53,8 @@ class NotificationList(ListView):
|
||||
if self.request.user.is_anonymous:
|
||||
return Notification.objects.none()
|
||||
# TODO: Bulk update in django 2.2
|
||||
if "see_all" in self.request.GET.keys():
|
||||
for n in self.request.user.notifications.filter(viewed=False):
|
||||
n.viewed = True
|
||||
n.save()
|
||||
|
||||
if "see_all" in self.request.GET:
|
||||
self.request.user.notifications.filter(viewed=False).update(viewed=True)
|
||||
return self.request.user.notifications.order_by("-date")[:20]
|
||||
|
||||
|
||||
|
@ -255,8 +255,10 @@ class UserTabsMixin(TabedViewMixin):
|
||||
"name": _("Groups"),
|
||||
}
|
||||
)
|
||||
try:
|
||||
if user.customer and (
|
||||
if (
|
||||
hasattr(user, "customer")
|
||||
and user.customer
|
||||
and (
|
||||
user == self.request.user
|
||||
or self.request.user.is_in_group(
|
||||
pk=settings.SITH_GROUP_ACCOUNTING_ADMIN_ID
|
||||
@ -266,6 +268,7 @@ class UserTabsMixin(TabedViewMixin):
|
||||
+ settings.SITH_BOARD_SUFFIX
|
||||
)
|
||||
or self.request.user.is_root
|
||||
)
|
||||
):
|
||||
tab_list.append(
|
||||
{
|
||||
@ -276,15 +279,11 @@ class UserTabsMixin(TabedViewMixin):
|
||||
)
|
||||
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",
|
||||
"name": _("Account") + " (%s €)" % user.customer.amount,
|
||||
}
|
||||
)
|
||||
except:
|
||||
pass
|
||||
return tab_list
|
||||
|
||||
|
||||
|
@ -15,7 +15,19 @@
|
||||
from django.contrib import admin
|
||||
from haystack.admin import SearchModelAdmin
|
||||
|
||||
from counter.models import *
|
||||
from counter.models import (
|
||||
AccountDump,
|
||||
BillingInfo,
|
||||
CashRegisterSummary,
|
||||
Counter,
|
||||
Customer,
|
||||
Eticket,
|
||||
Permanency,
|
||||
Product,
|
||||
ProductType,
|
||||
Refilling,
|
||||
Selling,
|
||||
)
|
||||
|
||||
|
||||
@admin.register(Product)
|
||||
|
@ -1,4 +1,5 @@
|
||||
import logging
|
||||
import time
|
||||
from smtplib import SMTPException
|
||||
|
||||
from django.conf import settings
|
||||
@ -25,9 +26,34 @@ class Command(BaseCommand):
|
||||
self.logger.setLevel(logging.INFO)
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument(
|
||||
"--dry-run",
|
||||
action="store_true",
|
||||
help="Do not send the mails, just display the number of users concerned",
|
||||
)
|
||||
parser.add_argument(
|
||||
"-d",
|
||||
"--delay",
|
||||
type=float,
|
||||
default=0,
|
||||
help="Delay in seconds between each mail sent",
|
||||
)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
users = list(self._get_users())
|
||||
users: list[User] = list(self._get_users())
|
||||
self.stdout.write(f"{len(users)} users will be warned of their account dump")
|
||||
if options["verbosity"] > 1:
|
||||
self.stdout.write("Users concerned:\n")
|
||||
self.stdout.write(
|
||||
"\n".join(
|
||||
f" - {user.get_display_name()} ({user.email}) : "
|
||||
f"{user.customer.amount} €"
|
||||
for user in users
|
||||
)
|
||||
)
|
||||
if options["dry_run"]:
|
||||
return
|
||||
dumps = []
|
||||
for user in users:
|
||||
is_success = self._send_mail(user)
|
||||
@ -38,6 +64,10 @@ class Command(BaseCommand):
|
||||
warning_mail_error=not is_success,
|
||||
)
|
||||
)
|
||||
if options["delay"]:
|
||||
# avoid spamming the mail server too much
|
||||
time.sleep(options["delay"])
|
||||
|
||||
AccountDump.objects.bulk_create(dumps)
|
||||
self.stdout.write("Finished !")
|
||||
|
||||
|
@ -154,7 +154,7 @@ class Customer(models.Model):
|
||||
self.save()
|
||||
|
||||
def get_full_url(self):
|
||||
return "".join(["https://", settings.SITH_URL, self.get_absolute_url()])
|
||||
return f"https://{settings.SITH_URL}{self.get_absolute_url()}"
|
||||
|
||||
|
||||
class BillingInfo(models.Model):
|
||||
@ -287,9 +287,7 @@ class ProductType(models.Model):
|
||||
"""Method to see if that object can be edited by the given user."""
|
||||
if user.is_anonymous:
|
||||
return False
|
||||
if user.is_in_group(pk=settings.SITH_GROUP_ACCOUNTING_ADMIN_ID):
|
||||
return True
|
||||
return False
|
||||
return user.is_in_group(pk=settings.SITH_GROUP_ACCOUNTING_ADMIN_ID)
|
||||
|
||||
|
||||
class Product(models.Model):
|
||||
@ -346,21 +344,19 @@ class Product(models.Model):
|
||||
|
||||
@property
|
||||
def is_record_product(self):
|
||||
return settings.SITH_ECOCUP_CONS == self.id
|
||||
return self.id == settings.SITH_ECOCUP_CONS
|
||||
|
||||
@property
|
||||
def is_unrecord_product(self):
|
||||
return settings.SITH_ECOCUP_DECO == self.id
|
||||
return self.id == settings.SITH_ECOCUP_DECO
|
||||
|
||||
def is_owned_by(self, user):
|
||||
"""Method to see if that object can be edited by the given user."""
|
||||
if user.is_anonymous:
|
||||
return False
|
||||
if user.is_in_group(
|
||||
return user.is_in_group(
|
||||
pk=settings.SITH_GROUP_ACCOUNTING_ADMIN_ID
|
||||
) or user.is_in_group(pk=settings.SITH_GROUP_COUNTER_ADMIN_ID):
|
||||
return True
|
||||
return False
|
||||
) or user.is_in_group(pk=settings.SITH_GROUP_COUNTER_ADMIN_ID)
|
||||
|
||||
def can_be_sold_to(self, user: User) -> bool:
|
||||
"""Check if whether the user given in parameter has the right to buy
|
||||
@ -392,10 +388,7 @@ class Product(models.Model):
|
||||
buying_groups = list(self.buying_groups.all())
|
||||
if not buying_groups:
|
||||
return True
|
||||
for group in buying_groups:
|
||||
if user.is_in_group(pk=group.id):
|
||||
return True
|
||||
return False
|
||||
return any(user.is_in_group(pk=group.id) for group in buying_groups)
|
||||
|
||||
@property
|
||||
def profit(self):
|
||||
@ -887,27 +880,19 @@ class Selling(models.Model):
|
||||
"You bought an eticket for the event %(event)s.\nYou can download it directly from this link %(eticket)s.\nYou can also retrieve all your e-tickets on your account page %(url)s."
|
||||
) % {
|
||||
"event": event,
|
||||
"url": "".join(
|
||||
(
|
||||
'<a href="',
|
||||
self.customer.get_full_url(),
|
||||
'">',
|
||||
self.customer.get_full_url(),
|
||||
"</a>",
|
||||
)
|
||||
"url": (
|
||||
f'<a href="{self.customer.get_full_url()}">'
|
||||
f"{self.customer.get_full_url()}</a>"
|
||||
),
|
||||
"eticket": "".join(
|
||||
(
|
||||
'<a href="',
|
||||
self.get_eticket_full_url(),
|
||||
'">',
|
||||
self.get_eticket_full_url(),
|
||||
"</a>",
|
||||
)
|
||||
"eticket": (
|
||||
f'<a href="{self.get_eticket_full_url()}">'
|
||||
f"{self.get_eticket_full_url()}</a>"
|
||||
),
|
||||
}
|
||||
message_txt = _(
|
||||
"You bought an eticket for the event %(event)s.\nYou can download it directly from this link %(eticket)s.\nYou can also retrieve all your e-tickets on your account page %(url)s."
|
||||
"You bought an eticket for the event %(event)s.\n"
|
||||
"You can download it directly from this link %(eticket)s.\n"
|
||||
"You can also retrieve all your e-tickets on your account page %(url)s."
|
||||
) % {
|
||||
"event": event,
|
||||
"url": self.customer.get_full_url(),
|
||||
@ -919,7 +904,7 @@ class Selling(models.Model):
|
||||
|
||||
def get_eticket_full_url(self):
|
||||
eticket_url = reverse("counter:eticket_pdf", kwargs={"selling_id": self.id})
|
||||
return "".join(["https://", settings.SITH_URL, eticket_url])
|
||||
return f"https://{settings.SITH_URL}{eticket_url}"
|
||||
|
||||
|
||||
class Permanency(models.Model):
|
||||
@ -1019,15 +1004,15 @@ class CashRegisterSummary(models.Model):
|
||||
elif name == "hundred_euros":
|
||||
return self.items.filter(value=100, is_check=False).first()
|
||||
elif name == "check_1":
|
||||
return checks[0] if 0 < len(checks) else None
|
||||
return checks[0] if len(checks) > 0 else None
|
||||
elif name == "check_2":
|
||||
return checks[1] if 1 < len(checks) else None
|
||||
return checks[1] if len(checks) > 1 else None
|
||||
elif name == "check_3":
|
||||
return checks[2] if 2 < len(checks) else None
|
||||
return checks[2] if len(checks) > 2 else None
|
||||
elif name == "check_4":
|
||||
return checks[3] if 3 < len(checks) else None
|
||||
return checks[3] if len(checks) > 3 else None
|
||||
elif name == "check_5":
|
||||
return checks[4] if 4 < len(checks) else None
|
||||
return checks[4] if len(checks) > 4 else None
|
||||
else:
|
||||
return object.__getattribute__(self, name)
|
||||
|
||||
@ -1035,9 +1020,7 @@ class CashRegisterSummary(models.Model):
|
||||
"""Method to see if that object can be edited by the given user."""
|
||||
if user.is_anonymous:
|
||||
return False
|
||||
if user.is_in_group(pk=settings.SITH_GROUP_COUNTER_ADMIN_ID):
|
||||
return True
|
||||
return False
|
||||
return user.is_in_group(pk=settings.SITH_GROUP_COUNTER_ADMIN_ID)
|
||||
|
||||
def get_total(self):
|
||||
t = 0
|
||||
|
@ -51,7 +51,7 @@ def write_log(instance, operation_type):
|
||||
# Return None by default
|
||||
return None
|
||||
|
||||
log = OperationLog(
|
||||
OperationLog(
|
||||
label=str(instance),
|
||||
operator=get_user(),
|
||||
operation_type=operation_type,
|
||||
|
@ -1,43 +1,40 @@
|
||||
<p>
|
||||
Bonjour,
|
||||
</p>
|
||||
{% trans %}Hello{% endtrans %},
|
||||
|
||||
<p>
|
||||
{%- trans date=last_subscription_date|date(DATETIME_FORMAT) -%}
|
||||
{% trans trimmed date=last_subscription_date|date(DATETIME_FORMAT) -%}
|
||||
You received this email because your last subscription to the
|
||||
Students' association ended on {{ date }}.
|
||||
{%- endtrans -%}
|
||||
</p>
|
||||
{%- endtrans %}
|
||||
|
||||
<p>
|
||||
{%- trans date=dump_date|date(DATETIME_FORMAT), amount=balance -%}
|
||||
{% trans trimmed date=dump_date|date(DATETIME_FORMAT), amount=balance -%}
|
||||
In accordance with the Internal Regulations, the balance of any
|
||||
inactive AE account for more than 2 years automatically goes back
|
||||
to the AE.
|
||||
The money present on your account will therefore be recovered in full
|
||||
on {{ date }}, for a total of {{ amount }} €.
|
||||
{%- endtrans -%}
|
||||
</p>
|
||||
{%- endtrans %}
|
||||
|
||||
<p>
|
||||
{%- trans -%}However, if your subscription is renewed by this date,
|
||||
your right to keep the money in your AE account will be renewed.{%- endtrans -%}
|
||||
</p>
|
||||
{% trans trimmed -%}
|
||||
However, if your subscription is renewed by this date,
|
||||
your right to keep the money in your AE account will be renewed.
|
||||
{%- endtrans %}
|
||||
|
||||
{% if balance >= 10 %}
|
||||
<p>
|
||||
{%- trans -%}You can also request a refund by sending an email to
|
||||
<a href="mailto:ae@utbm.fr">ae@utbm.fr</a>
|
||||
before the aforementioned date.{%- endtrans -%}
|
||||
</p>
|
||||
{% endif %}
|
||||
{% if balance >= 10 -%}
|
||||
{% trans trimmed -%}
|
||||
You can also request a refund by sending an email to ae@utbm.fr
|
||||
before the aforementioned date.
|
||||
{%- endtrans %}
|
||||
{%- endif %}
|
||||
|
||||
{% trans trimmed -%}
|
||||
Whatever you decide, you won't be expelled from the association,
|
||||
and you won't lose your rights.
|
||||
You will always be able to renew your subscription later.
|
||||
If you don't renew your subscription, there will be no consequences
|
||||
other than the loss of the money currently in your AE account.
|
||||
{%- endtrans %}
|
||||
|
||||
<p>
|
||||
{% trans %}Sincerely{% endtrans %},
|
||||
</p>
|
||||
|
||||
<p>
|
||||
L'association des étudiants de l'UTBM <br>
|
||||
6, Boulevard Anatole France <br>
|
||||
L'association des étudiants de l'UTBM
|
||||
6, Boulevard Anatole France
|
||||
90000 Belfort
|
||||
</p>
|
||||
|
@ -13,7 +13,7 @@
|
||||
<h4>{{ product_type or _("Uncategorized") }}</h4>
|
||||
<ul>
|
||||
{%- for product in products -%}
|
||||
<li><a href="{{ url('counter:product_edit', product_id=product.id) }}">{{ product }} ({{ product.code }})</a></li>
|
||||
<li><a href="{{ url('counter:product_edit', product_id=product.id) }}">{{ product.name }} ({{ product.code }})</a></li>
|
||||
{%- endfor -%}
|
||||
</ul>
|
||||
{%- else -%}
|
||||
|
@ -503,7 +503,7 @@ class TestBarmanConnection(TestCase):
|
||||
)
|
||||
response = self.client.get(reverse("counter:activity", args=[self.counter.id]))
|
||||
|
||||
assert not '<li><a href="/user/1/">S' Kia</a></li>' in str(response.content)
|
||||
assert '<li><a href="/user/1/">S' Kia</a></li>' not in str(response.content)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@ -853,7 +853,7 @@ class TestCustomerAccountId(TestCase):
|
||||
number = account_id[:-1]
|
||||
assert created is True
|
||||
assert number == "12346"
|
||||
assert 6 == len(account_id)
|
||||
assert len(account_id) == 6
|
||||
assert account_id[-1] in string.ascii_lowercase
|
||||
assert customer.amount == 0
|
||||
|
||||
|
@ -15,7 +15,40 @@
|
||||
|
||||
from django.urls import path
|
||||
|
||||
from counter.views import *
|
||||
from counter.views import (
|
||||
ActiveProductListView,
|
||||
ArchivedProductListView,
|
||||
CashSummaryEditView,
|
||||
CashSummaryListView,
|
||||
CounterActivityView,
|
||||
CounterCashSummaryView,
|
||||
CounterClick,
|
||||
CounterCreateView,
|
||||
CounterDeleteView,
|
||||
CounterEditPropView,
|
||||
CounterEditView,
|
||||
CounterLastOperationsView,
|
||||
CounterListView,
|
||||
CounterMain,
|
||||
CounterRefillingListView,
|
||||
CounterStatView,
|
||||
EticketCreateView,
|
||||
EticketEditView,
|
||||
EticketListView,
|
||||
EticketPDFView,
|
||||
InvoiceCallView,
|
||||
ProductCreateView,
|
||||
ProductEditView,
|
||||
ProductTypeCreateView,
|
||||
ProductTypeEditView,
|
||||
ProductTypeListView,
|
||||
RefillingDeleteView,
|
||||
SellingDeleteView,
|
||||
StudentCardDeleteView,
|
||||
StudentCardFormView,
|
||||
counter_login,
|
||||
counter_logout,
|
||||
)
|
||||
|
||||
urlpatterns = [
|
||||
path("<int:counter_id>/", CounterMain.as_view(), name="details"),
|
||||
|
@ -17,7 +17,7 @@ import re
|
||||
from datetime import datetime, timedelta
|
||||
from datetime import timezone as tz
|
||||
from http import HTTPStatus
|
||||
from operator import attrgetter
|
||||
from operator import itemgetter
|
||||
from typing import TYPE_CHECKING
|
||||
from urllib.parse import parse_qs
|
||||
|
||||
@ -91,16 +91,10 @@ class CounterAdminMixin(View):
|
||||
edit_club = []
|
||||
|
||||
def _test_group(self, user):
|
||||
for grp_id in self.edit_group:
|
||||
if user.is_in_group(pk=grp_id):
|
||||
return True
|
||||
return False
|
||||
return any(user.is_in_group(pk=grp_id) for grp_id in self.edit_group)
|
||||
|
||||
def _test_club(self, user):
|
||||
for c in self.edit_club:
|
||||
if c.can_be_edited_by(user):
|
||||
return True
|
||||
return False
|
||||
return any(c.can_be_edited_by(user) for c in self.edit_club)
|
||||
|
||||
def dispatch(self, request, *args, **kwargs):
|
||||
if not (
|
||||
@ -181,7 +175,7 @@ class CounterMain(
|
||||
def post(self, request, *args, **kwargs):
|
||||
self.object = self.get_object()
|
||||
if self.object.type == "BAR" and not (
|
||||
"counter_token" in self.request.session.keys()
|
||||
"counter_token" in self.request.session
|
||||
and self.request.session["counter_token"] == self.object.token
|
||||
): # Check the token to avoid the bar to be stolen
|
||||
return HttpResponseRedirect(
|
||||
@ -219,7 +213,7 @@ class CounterMain(
|
||||
kwargs["barmen"] = self.object.barmen_list
|
||||
elif self.request.user.is_authenticated:
|
||||
kwargs["barmen"] = [self.request.user]
|
||||
if "last_basket" in self.request.session.keys():
|
||||
if "last_basket" in self.request.session:
|
||||
kwargs["last_basket"] = self.request.session.pop("last_basket")
|
||||
kwargs["last_customer"] = self.request.session.pop("last_customer")
|
||||
kwargs["last_total"] = self.request.session.pop("last_total")
|
||||
@ -294,7 +288,7 @@ class CounterClick(CounterTabsMixin, CanViewMixin, DetailView):
|
||||
|
||||
def get(self, request, *args, **kwargs):
|
||||
"""Simple get view."""
|
||||
if "basket" not in request.session.keys(): # Init the basket session entry
|
||||
if "basket" not in request.session: # Init the basket session entry
|
||||
request.session["basket"] = {}
|
||||
request.session["basket_total"] = 0
|
||||
request.session["not_enough"] = False # Reset every variable
|
||||
@ -318,7 +312,7 @@ class CounterClick(CounterTabsMixin, CanViewMixin, DetailView):
|
||||
): # Check that at least one barman is logged in
|
||||
return self.cancel(request)
|
||||
if self.object.type == "BAR" and not (
|
||||
"counter_token" in self.request.session.keys()
|
||||
"counter_token" in self.request.session
|
||||
and self.request.session["counter_token"] == self.object.token
|
||||
): # Also check the token to avoid the bar to be stolen
|
||||
return HttpResponseRedirect(
|
||||
@ -329,7 +323,7 @@ class CounterClick(CounterTabsMixin, CanViewMixin, DetailView):
|
||||
)
|
||||
+ "?bad_location"
|
||||
)
|
||||
if "basket" not in request.session.keys():
|
||||
if "basket" not in request.session:
|
||||
request.session["basket"] = {}
|
||||
request.session["basket_total"] = 0
|
||||
request.session["not_enough"] = False # Reset every variable
|
||||
@ -386,13 +380,12 @@ class CounterClick(CounterTabsMixin, CanViewMixin, DetailView):
|
||||
|
||||
def get_total_quantity_for_pid(self, request, pid):
|
||||
pid = str(pid)
|
||||
try:
|
||||
if pid not in request.session["basket"]:
|
||||
return 0
|
||||
return (
|
||||
request.session["basket"][pid]["qty"]
|
||||
+ request.session["basket"][pid]["bonus_qty"]
|
||||
)
|
||||
except:
|
||||
return 0
|
||||
|
||||
def compute_record_product(self, request, product=None):
|
||||
recorded = 0
|
||||
@ -808,7 +801,7 @@ class ProductTypeEditView(CounterAdminTabsMixin, CounterAdminMixin, UpdateView):
|
||||
|
||||
class ProductListView(CounterAdminTabsMixin, CounterAdminMixin, ListView):
|
||||
model = Product
|
||||
queryset = Product.objects.annotate(type_name=F("product_type__name"))
|
||||
queryset = Product.objects.values("id", "name", "code", "product_type__name")
|
||||
template_name = "counter/product_list.jinja"
|
||||
ordering = [
|
||||
F("product_type__priority").desc(nulls_last=True),
|
||||
@ -819,7 +812,7 @@ class ProductListView(CounterAdminTabsMixin, CounterAdminMixin, ListView):
|
||||
def get_context_data(self, **kwargs):
|
||||
res = super().get_context_data(**kwargs)
|
||||
res["object_list"] = itertools.groupby(
|
||||
res["object_list"], key=attrgetter("type_name")
|
||||
res["object_list"], key=itemgetter("product_type__name")
|
||||
)
|
||||
return res
|
||||
|
||||
|
@ -24,6 +24,12 @@ Si le mot apparaît dans le template Jinja :
|
||||
{% trans %}Hello{% endtrans %}
|
||||
```
|
||||
|
||||
Si on est dans un fichier javascript ou typescript :
|
||||
|
||||
```js
|
||||
gettext("Hello");
|
||||
```
|
||||
|
||||
## Générer le fichier django.po
|
||||
|
||||
La traduction se fait en trois étapes.
|
||||
@ -32,7 +38,7 @@ l'éditer et enfin le compiler au format binaire pour qu'il soit lu par le serve
|
||||
|
||||
```bash
|
||||
./manage.py makemessages --locale=fr -e py,jinja --ignore=node_modules # Pour le backend
|
||||
./manage.py makemessages --locale=fr -d djangojs --ignore=node_modules # Pour le frontend
|
||||
./manage.py makemessages --locale=fr -d djangojs -e js,ts --ignore=node_modules # Pour le frontend
|
||||
```
|
||||
|
||||
## Éditer le fichier django.po
|
||||
|
@ -13,8 +13,9 @@
|
||||
#
|
||||
#
|
||||
from django.contrib import admin
|
||||
from django.db.models import F, Sum
|
||||
|
||||
from eboutic.models import *
|
||||
from eboutic.models import Basket, BasketItem, Invoice, InvoiceItem
|
||||
|
||||
|
||||
@admin.register(Basket)
|
||||
|
@ -117,9 +117,7 @@ class BasketForm:
|
||||
"""
|
||||
if not self.error_messages and not self.correct_items:
|
||||
self.clean()
|
||||
if self.error_messages:
|
||||
return False
|
||||
return True
|
||||
return not self.error_messages
|
||||
|
||||
@cached_property
|
||||
def errors(self) -> list[str]:
|
||||
|
@ -2,8 +2,6 @@ from typing import Annotated
|
||||
|
||||
from ninja import ModelSchema, Schema
|
||||
from pydantic import Field, NonNegativeInt, PositiveInt, TypeAdapter
|
||||
|
||||
# from phonenumber_field.phonenumber import PhoneNumber
|
||||
from pydantic_extra_types.phone_numbers import PhoneNumber, PhoneNumberValidator
|
||||
|
||||
from counter.models import BillingInfo
|
||||
|
@ -7,11 +7,11 @@
|
||||
|
||||
import base64
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import pytest
|
||||
from cryptography.exceptions import InvalidSignature
|
||||
from cryptography.hazmat.primitives.asymmetric.padding import PKCS1v15
|
||||
from cryptography.hazmat.primitives.asymmetric.rsa import RSAPrivateKey, RSAPublicKey
|
||||
from cryptography.hazmat.primitives.hashes import SHA1
|
||||
from cryptography.hazmat.primitives.serialization import (
|
||||
load_pem_private_key,
|
||||
@ -19,6 +19,12 @@ from cryptography.hazmat.primitives.serialization import (
|
||||
)
|
||||
from django.conf import settings
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from cryptography.hazmat.primitives.asymmetric.rsa import (
|
||||
RSAPrivateKey,
|
||||
RSAPublicKey,
|
||||
)
|
||||
|
||||
|
||||
def test_signature_valid():
|
||||
"""Test that data sent to the bank is correctly signed."""
|
||||
|
@ -24,9 +24,9 @@
|
||||
import base64
|
||||
import json
|
||||
import urllib
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from cryptography.hazmat.primitives.asymmetric.padding import PKCS1v15
|
||||
from cryptography.hazmat.primitives.asymmetric.rsa import RSAPrivateKey
|
||||
from cryptography.hazmat.primitives.hashes import SHA1
|
||||
from cryptography.hazmat.primitives.serialization import load_pem_private_key
|
||||
from django.conf import settings
|
||||
@ -38,6 +38,9 @@ from core.models import User
|
||||
from counter.models import Counter, Customer, Product, Selling
|
||||
from eboutic.models import Basket, BasketItem
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from cryptography.hazmat.primitives.asymmetric.rsa import RSAPrivateKey
|
||||
|
||||
|
||||
class TestEboutic(TestCase):
|
||||
@classmethod
|
||||
|
@ -25,7 +25,14 @@
|
||||
from django.urls import path, register_converter
|
||||
|
||||
from eboutic.converters import PaymentResultConverter
|
||||
from eboutic.views import *
|
||||
from eboutic.views import (
|
||||
EbouticCommand,
|
||||
EtransactionAutoAnswer,
|
||||
e_transaction_data,
|
||||
eboutic_main,
|
||||
pay_with_sith,
|
||||
payment_result,
|
||||
)
|
||||
|
||||
register_converter(PaymentResultConverter, "res")
|
||||
|
||||
|
@ -17,11 +17,11 @@ import base64
|
||||
import json
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import sentry_sdk
|
||||
from cryptography.exceptions import InvalidSignature
|
||||
from cryptography.hazmat.primitives.asymmetric.padding import PKCS1v15
|
||||
from cryptography.hazmat.primitives.asymmetric.rsa import RSAPublicKey
|
||||
from cryptography.hazmat.primitives.hashes import SHA1
|
||||
from cryptography.hazmat.primitives.serialization import load_pem_public_key
|
||||
from django.conf import settings
|
||||
@ -47,6 +47,9 @@ from eboutic.models import (
|
||||
)
|
||||
from eboutic.schemas import PurchaseItemList, PurchaseItemSchema
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from cryptography.hazmat.primitives.asymmetric.rsa import RSAPublicKey
|
||||
|
||||
|
||||
@login_required
|
||||
@require_GET
|
||||
@ -221,7 +224,7 @@ class EtransactionAutoAnswer(View):
|
||||
# Payment authorized:
|
||||
# * 'Error' is '00000'
|
||||
# * 'Auto' is in the request
|
||||
if request.GET["Error"] == "00000" and "Auto" in request.GET.keys():
|
||||
if request.GET["Error"] == "00000" and "Auto" in request.GET:
|
||||
try:
|
||||
with transaction.atomic():
|
||||
b = (
|
||||
|
@ -1,6 +1,22 @@
|
||||
from django.urls import path
|
||||
|
||||
from election.views import *
|
||||
from election.views import (
|
||||
CandidatureCreateView,
|
||||
CandidatureDeleteView,
|
||||
CandidatureUpdateView,
|
||||
ElectionCreateView,
|
||||
ElectionDeleteView,
|
||||
ElectionDetailView,
|
||||
ElectionListArchivedView,
|
||||
ElectionListCreateView,
|
||||
ElectionListDeleteView,
|
||||
ElectionsListView,
|
||||
ElectionUpdateView,
|
||||
RoleCreateView,
|
||||
RoleDeleteView,
|
||||
RoleUpdateView,
|
||||
VoteFormView,
|
||||
)
|
||||
|
||||
urlpatterns = [
|
||||
path("", ElectionsListView.as_view(), name="list"),
|
||||
@ -19,16 +35,10 @@ urlpatterns = [
|
||||
name="delete_list",
|
||||
),
|
||||
path(
|
||||
"<int:election_id>/role/create/",
|
||||
RoleCreateView.as_view(),
|
||||
name="create_role",
|
||||
"<int:election_id>/role/create/", RoleCreateView.as_view(), name="create_role"
|
||||
),
|
||||
path("<int:role_id>/role/edit/", RoleUpdateView.as_view(), name="update_role"),
|
||||
path(
|
||||
"<int:role_id>/role/delete/",
|
||||
RoleDeleteView.as_view(),
|
||||
name="delete_role",
|
||||
),
|
||||
path("<int:role_id>/role/delete/", RoleDeleteView.as_view(), name="delete_role"),
|
||||
path(
|
||||
"<int:election_id>/candidate/add/",
|
||||
CandidatureCreateView.as_view(),
|
||||
|
@ -1,3 +1,5 @@
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from ajax_select import make_ajax_field
|
||||
from ajax_select.fields import AutoCompleteSelectField
|
||||
from django import forms
|
||||
@ -10,11 +12,14 @@ from django.utils.translation import gettext_lazy as _
|
||||
from django.views.generic import DetailView, ListView
|
||||
from django.views.generic.edit import CreateView, DeleteView, FormView, UpdateView
|
||||
|
||||
from core.models import User
|
||||
from core.views import CanCreateMixin, CanEditMixin, CanViewMixin
|
||||
from core.views.forms import MarkdownInput, SelectDateTime
|
||||
from election.models import Candidature, Election, ElectionList, Role, Vote
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from core.models import User
|
||||
|
||||
|
||||
# Custom form field
|
||||
|
||||
|
||||
@ -23,7 +28,6 @@ class LimitedCheckboxField(forms.ModelMultipleChoiceField):
|
||||
|
||||
def __init__(self, queryset, max_choice, **kwargs):
|
||||
self.max_choice = max_choice
|
||||
widget = forms.CheckboxSelectMultiple()
|
||||
super().__init__(queryset, **kwargs)
|
||||
|
||||
def clean(self, value):
|
||||
@ -251,7 +255,7 @@ class VoteFormView(CanCreateMixin, FormView):
|
||||
|
||||
def vote(self, election_data):
|
||||
with transaction.atomic():
|
||||
for role_title in election_data.keys():
|
||||
for role_title in election_data:
|
||||
# If we have a multiple choice field
|
||||
if isinstance(election_data[role_title], QuerySet):
|
||||
if election_data[role_title].count() > 0:
|
||||
@ -444,28 +448,16 @@ class ElectionUpdateView(CanEditMixin, UpdateView):
|
||||
pk_url_kwarg = "election_id"
|
||||
|
||||
def get_initial(self):
|
||||
init = {}
|
||||
try:
|
||||
init["start_date"] = self.object.start_date.strftime("%Y-%m-%d %H:%M:%S")
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
init["end_date"] = self.object.end_date.strftime("%Y-%m-%d %H:%M:%S")
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
init["start_candidature"] = self.object.start_candidature.strftime(
|
||||
return {
|
||||
"start_date": self.object.start_date.strftime("%Y-%m-%d %H:%M:%S"),
|
||||
"end_date": self.object.end_date.strftime("%Y-%m-%d %H:%M:%S"),
|
||||
"start_candidature": self.object.start_candidature.strftime(
|
||||
"%Y-%m-%d %H:%M:%S"
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
init["end_candidature"] = self.object.end_candidature.strftime(
|
||||
),
|
||||
"end_candidature": self.object.end_candidature.strftime(
|
||||
"%Y-%m-%d %H:%M:%S"
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
return init
|
||||
),
|
||||
}
|
||||
|
||||
def get_success_url(self, **kwargs):
|
||||
return reverse_lazy("election:detail", kwargs={"election_id": self.object.id})
|
||||
|
@ -16,7 +16,7 @@
|
||||
from django.contrib import admin
|
||||
from haystack.admin import SearchModelAdmin
|
||||
|
||||
from forum.models import *
|
||||
from forum.models import Forum, ForumMessage, ForumTopic
|
||||
|
||||
|
||||
@admin.register(Forum)
|
||||
|
@ -25,6 +25,7 @@ from __future__ import annotations
|
||||
from datetime import datetime
|
||||
from datetime import timezone as tz
|
||||
from itertools import chain
|
||||
from typing import Self
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import ValidationError
|
||||
@ -207,12 +208,12 @@ class Forum(models.Model):
|
||||
return self.get_parent_list()
|
||||
|
||||
def get_parent_list(self):
|
||||
l = []
|
||||
p = self.parent
|
||||
while p is not None:
|
||||
l.append(p)
|
||||
p = p.parent
|
||||
return l
|
||||
parents = []
|
||||
current = self.parent
|
||||
while current is not None:
|
||||
parents.append(current)
|
||||
current = current.parent
|
||||
return parents
|
||||
|
||||
@property
|
||||
def topic_number(self):
|
||||
@ -228,12 +229,12 @@ class Forum(models.Model):
|
||||
def last_message(self):
|
||||
return self._last_message
|
||||
|
||||
def get_children_list(self):
|
||||
l = [self.id]
|
||||
def get_children_list(self) -> list[Self]:
|
||||
children = [self.id]
|
||||
for c in self.children.all():
|
||||
l.append(c.id)
|
||||
l += c.get_children_list()
|
||||
return l
|
||||
children.append(c.id)
|
||||
children.extend(c.get_children_list())
|
||||
return children
|
||||
|
||||
|
||||
class ForumTopic(models.Model):
|
||||
|
@ -23,7 +23,26 @@
|
||||
|
||||
from django.urls import path
|
||||
|
||||
from forum.views import *
|
||||
from forum.views import (
|
||||
ForumCreateView,
|
||||
ForumDeleteView,
|
||||
ForumDetailView,
|
||||
ForumEditView,
|
||||
ForumFavoriteTopics,
|
||||
ForumLastUnread,
|
||||
ForumMainView,
|
||||
ForumMarkAllAsRead,
|
||||
ForumMessageCreateView,
|
||||
ForumMessageDeleteView,
|
||||
ForumMessageEditView,
|
||||
ForumMessageUndeleteView,
|
||||
ForumMessageView,
|
||||
ForumSearchView,
|
||||
ForumTopicCreateView,
|
||||
ForumTopicDetailView,
|
||||
ForumTopicEditView,
|
||||
ForumTopicSubscribeView,
|
||||
)
|
||||
|
||||
urlpatterns = [
|
||||
path("", ForumMainView.as_view(), name="main"),
|
||||
@ -35,21 +54,9 @@ urlpatterns = [
|
||||
path("<int:forum_id>/", ForumDetailView.as_view(), name="view_forum"),
|
||||
path("<int:forum_id>/edit/", ForumEditView.as_view(), name="edit_forum"),
|
||||
path("<int:forum_id>/delete/", ForumDeleteView.as_view(), name="delete_forum"),
|
||||
path(
|
||||
"<int:forum_id>/new_topic/",
|
||||
ForumTopicCreateView.as_view(),
|
||||
name="new_topic",
|
||||
),
|
||||
path(
|
||||
"topic/<int:topic_id>/",
|
||||
ForumTopicDetailView.as_view(),
|
||||
name="view_topic",
|
||||
),
|
||||
path(
|
||||
"topic/<int:topic_id>/edit/",
|
||||
ForumTopicEditView.as_view(),
|
||||
name="edit_topic",
|
||||
),
|
||||
path("<int:forum_id>/new_topic/", ForumTopicCreateView.as_view(), name="new_topic"),
|
||||
path("topic/<int:topic_id>/", ForumTopicDetailView.as_view(), name="view_topic"),
|
||||
path("topic/<int:topic_id>/edit/", ForumTopicEditView.as_view(), name="edit_topic"),
|
||||
path(
|
||||
"topic/<int:topic_id>/new_message/",
|
||||
ForumMessageCreateView.as_view(),
|
||||
@ -60,11 +67,7 @@ urlpatterns = [
|
||||
ForumTopicSubscribeView.as_view(),
|
||||
name="toggle_subscribe_topic",
|
||||
),
|
||||
path(
|
||||
"message/<int:message_id>/",
|
||||
ForumMessageView.as_view(),
|
||||
name="view_message",
|
||||
),
|
||||
path("message/<int:message_id>/", ForumMessageView.as_view(), name="view_message"),
|
||||
path(
|
||||
"message/<int:message_id>/edit/",
|
||||
ForumMessageEditView.as_view(),
|
||||
|
@ -71,7 +71,7 @@ class Command(BaseCommand):
|
||||
|
||||
def handle(self, *args, **options):
|
||||
self.logger = logging.getLogger("main")
|
||||
if options["verbosity"] < 0 or 2 < options["verbosity"]:
|
||||
if not 0 <= options["verbosity"] <= 2:
|
||||
warnings.warn(
|
||||
"verbosity level should be between 0 and 2 included", stacklevel=2
|
||||
)
|
||||
|
@ -40,7 +40,7 @@ class Command(BaseCommand):
|
||||
|
||||
def handle(self, *args, **options):
|
||||
logger = logging.getLogger("main")
|
||||
if options["verbosity"] < 0 or 2 < options["verbosity"]:
|
||||
if not 0 <= options["verbosity"] <= 2:
|
||||
warnings.warn(
|
||||
"verbosity level should be between 0 and 2 included", stacklevel=2
|
||||
)
|
||||
|
@ -23,17 +23,9 @@
|
||||
|
||||
from django.urls import path
|
||||
|
||||
from galaxy.views import *
|
||||
from galaxy.views import GalaxyDataView, GalaxyUserView
|
||||
|
||||
urlpatterns = [
|
||||
path(
|
||||
"<int:user_id>/",
|
||||
GalaxyUserView.as_view(),
|
||||
name="user",
|
||||
),
|
||||
path(
|
||||
"data.json",
|
||||
GalaxyDataView.as_view(),
|
||||
name="data",
|
||||
),
|
||||
path("<int:user_id>/", GalaxyUserView.as_view(), name="user"),
|
||||
path("data.json", GalaxyDataView.as_view(), name="data"),
|
||||
]
|
||||
|
@ -14,7 +14,7 @@
|
||||
#
|
||||
from django.contrib import admin
|
||||
|
||||
from launderette.models import *
|
||||
from launderette.models import Launderette, Machine, Slot, Token
|
||||
|
||||
|
||||
@admin.register(Launderette)
|
||||
|
@ -51,18 +51,14 @@ class Launderette(models.Model):
|
||||
unix_name=settings.SITH_LAUNDERETTE_MANAGER["unix_name"]
|
||||
).first()
|
||||
m = launderette_club.get_membership_for(user)
|
||||
if m and m.role >= 9:
|
||||
return True
|
||||
return False
|
||||
return bool(m and m.role >= 9)
|
||||
|
||||
def can_be_edited_by(self, user):
|
||||
launderette_club = Club.objects.filter(
|
||||
unix_name=settings.SITH_LAUNDERETTE_MANAGER["unix_name"]
|
||||
).first()
|
||||
m = launderette_club.get_membership_for(user)
|
||||
if m and m.role >= 2:
|
||||
return True
|
||||
return False
|
||||
return bool(m and m.role >= 2)
|
||||
|
||||
def can_be_viewed_by(self, user):
|
||||
return user.is_subscribed
|
||||
@ -113,9 +109,7 @@ class Machine(models.Model):
|
||||
unix_name=settings.SITH_LAUNDERETTE_MANAGER["unix_name"]
|
||||
).first()
|
||||
m = launderette_club.get_membership_for(user)
|
||||
if m and m.role >= 9:
|
||||
return True
|
||||
return False
|
||||
return bool(m and m.role >= 9)
|
||||
|
||||
|
||||
class Token(models.Model):
|
||||
@ -164,15 +158,7 @@ class Token(models.Model):
|
||||
unix_name=settings.SITH_LAUNDERETTE_MANAGER["unix_name"]
|
||||
).first()
|
||||
m = launderette_club.get_membership_for(user)
|
||||
if m and m.role >= 9:
|
||||
return True
|
||||
return False
|
||||
|
||||
def is_avaliable(self):
|
||||
if not self.borrow_date and not self.user:
|
||||
return True
|
||||
else:
|
||||
return False
|
||||
return bool(m and m.role >= 9)
|
||||
|
||||
|
||||
class Slot(models.Model):
|
||||
|
@ -15,22 +15,28 @@
|
||||
|
||||
from django.urls import path
|
||||
|
||||
from launderette.views import *
|
||||
from launderette.views import (
|
||||
LaunderetteAdminView,
|
||||
LaunderetteBookMainView,
|
||||
LaunderetteBookView,
|
||||
LaunderetteClickView,
|
||||
LaunderetteCreateView,
|
||||
LaunderetteEditView,
|
||||
LaunderetteListView,
|
||||
LaunderetteMainClickView,
|
||||
LaunderetteMainView,
|
||||
MachineCreateView,
|
||||
MachineDeleteView,
|
||||
MachineEditView,
|
||||
SlotDeleteView,
|
||||
)
|
||||
|
||||
urlpatterns = [
|
||||
# views
|
||||
path("", LaunderetteMainView.as_view(), name="launderette_main"),
|
||||
path(
|
||||
"slot/<int:slot_id>/delete/",
|
||||
SlotDeleteView.as_view(),
|
||||
name="delete_slot",
|
||||
),
|
||||
path("slot/<int:slot_id>/delete/", SlotDeleteView.as_view(), name="delete_slot"),
|
||||
path("book/", LaunderetteBookMainView.as_view(), name="book_main"),
|
||||
path(
|
||||
"book/<int:launderette_id>/",
|
||||
LaunderetteBookView.as_view(),
|
||||
name="book_slot",
|
||||
),
|
||||
path("book/<int:launderette_id>/", LaunderetteBookView.as_view(), name="book_slot"),
|
||||
path(
|
||||
"<int:launderette_id>/click/",
|
||||
LaunderetteMainClickView.as_view(),
|
||||
|
@ -19,7 +19,7 @@ from datetime import timezone as tz
|
||||
|
||||
from django import forms
|
||||
from django.conf import settings
|
||||
from django.db import DataError, transaction
|
||||
from django.db import transaction
|
||||
from django.template import defaultfilters
|
||||
from django.urls import reverse_lazy
|
||||
from django.utils import dateparse, timezone
|
||||
@ -73,15 +73,15 @@ class LaunderetteBookView(CanViewMixin, DetailView):
|
||||
self.machines = {}
|
||||
with transaction.atomic():
|
||||
self.object = self.get_object()
|
||||
if "slot_type" in request.POST.keys():
|
||||
if "slot_type" in request.POST:
|
||||
self.slot_type = request.POST["slot_type"]
|
||||
if "slot" in request.POST.keys() and request.user.is_authenticated:
|
||||
if "slot" in request.POST and request.user.is_authenticated:
|
||||
self.subscriber = request.user
|
||||
if self.subscriber.is_subscribed:
|
||||
self.date = dateparse.parse_datetime(request.POST["slot"]).replace(
|
||||
tzinfo=tz.utc
|
||||
)
|
||||
if self.slot_type == "WASHING":
|
||||
if self.slot_type in ["WASHING", "DRYING"]:
|
||||
if self.check_slot(self.slot_type):
|
||||
Slot(
|
||||
user=self.subscriber,
|
||||
@ -89,16 +89,7 @@ class LaunderetteBookView(CanViewMixin, DetailView):
|
||||
machine=self.machines[self.slot_type],
|
||||
type=self.slot_type,
|
||||
).save()
|
||||
elif self.slot_type == "DRYING":
|
||||
if self.check_slot(self.slot_type):
|
||||
Slot(
|
||||
user=self.subscriber,
|
||||
start_date=self.date,
|
||||
machine=self.machines[self.slot_type],
|
||||
type=self.slot_type,
|
||||
).save()
|
||||
else:
|
||||
if self.check_slot("WASHING") and self.check_slot(
|
||||
elif self.check_slot("WASHING") and self.check_slot(
|
||||
"DRYING", self.date + timedelta(hours=1)
|
||||
):
|
||||
Slot(
|
||||
@ -149,15 +140,17 @@ class LaunderetteBookView(CanViewMixin, DetailView):
|
||||
):
|
||||
free = False
|
||||
if (
|
||||
(
|
||||
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)
|
||||
or self.slot_type == "DRYING"
|
||||
and self.check_slot("DRYING", h)
|
||||
):
|
||||
free = True
|
||||
elif self.slot_type == "WASHING" and self.check_slot("WASHING", h):
|
||||
free = True
|
||||
elif self.slot_type == "DRYING" and self.check_slot("DRYING", h):
|
||||
free = True
|
||||
if free and datetime.now().replace(tzinfo=tz.utc) < h:
|
||||
kwargs["planning"][date].append(h)
|
||||
else:
|
||||
@ -236,42 +229,39 @@ class ManageTokenForm(forms.Form):
|
||||
token_list = cleaned_data["tokens"].strip(" \n\r").split(" ")
|
||||
token_type = cleaned_data["token_type"]
|
||||
self.data = {}
|
||||
if cleaned_data["action"] == "BACK":
|
||||
for t in token_list:
|
||||
try:
|
||||
tok = Token.objects.filter(
|
||||
launderette=launderette, type=token_type, name=t
|
||||
).first()
|
||||
tok.borrow_date = None
|
||||
tok.user = None
|
||||
tok.save()
|
||||
except:
|
||||
|
||||
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},
|
||||
)
|
||||
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},
|
||||
if cleaned_data["action"] == "BACK":
|
||||
Token.objects.filter(id__in=[t.id for t in tokens]).update(
|
||||
borrow_date=None, user=None
|
||||
)
|
||||
elif cleaned_data["action"] == "DEL":
|
||||
for t in token_list:
|
||||
try:
|
||||
Token.objects.filter(
|
||||
launderette=launderette, type=token_type, name=t
|
||||
).delete()
|
||||
except:
|
||||
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 does not exists") % {"token_name": t},
|
||||
_("Token %(token_name)s already exists") % {"token_name": name},
|
||||
)
|
||||
for t in token_list:
|
||||
if t == "":
|
||||
self.add_error(None, _("Token name can not be blank"))
|
||||
else:
|
||||
Token(launderette=launderette, type=token_type, name=t).save()
|
||||
|
||||
|
||||
class LaunderetteAdminView(CanEditPropMixin, BaseFormView, DetailView):
|
||||
@ -288,13 +278,7 @@ class LaunderetteAdminView(CanEditPropMixin, BaseFormView, DetailView):
|
||||
|
||||
def post(self, request, *args, **kwargs):
|
||||
self.object = self.get_object()
|
||||
form = self.get_form()
|
||||
return super().post(request, *args, **kwargs)
|
||||
form.launderette = self.object
|
||||
if form.is_valid():
|
||||
return self.form_valid(form)
|
||||
else:
|
||||
return self.form_invalid(form)
|
||||
|
||||
def form_valid(self, form):
|
||||
"""We handle here the redirection, passing the user id of the asked customer."""
|
||||
@ -353,7 +337,7 @@ class LaunderetteMainClickView(CanEditMixin, BaseFormView, DetailView):
|
||||
kwargs["counter"] = self.object.counter
|
||||
kwargs["form"] = self.get_form()
|
||||
kwargs["barmen"] = [self.request.user]
|
||||
if "last_basket" in self.request.session.keys():
|
||||
if "last_basket" in self.request.session:
|
||||
kwargs["last_basket"] = self.request.session.pop("last_basket", None)
|
||||
kwargs["last_customer"] = self.request.session.pop("last_customer", None)
|
||||
kwargs["last_total"] = self.request.session.pop("last_total", None)
|
||||
@ -479,7 +463,7 @@ class LaunderetteClickView(CanEditMixin, DetailView, BaseFormView):
|
||||
def get_context_data(self, **kwargs):
|
||||
"""We handle here the login form for the barman."""
|
||||
kwargs = super().get_context_data(**kwargs)
|
||||
if "form" not in kwargs.keys():
|
||||
if "form" not in kwargs:
|
||||
kwargs["form"] = self.get_form()
|
||||
kwargs["counter"] = self.object.counter
|
||||
kwargs["customer"] = self.customer
|
||||
@ -519,7 +503,7 @@ class MachineCreateView(CanCreateMixin, CreateView):
|
||||
|
||||
def get_initial(self):
|
||||
ret = super().get_initial()
|
||||
if "launderette" in self.request.GET.keys():
|
||||
if "launderette" in self.request.GET:
|
||||
obj = Launderette.objects.filter(
|
||||
id=int(self.request.GET["launderette"])
|
||||
).first()
|
||||
|
File diff suppressed because it is too large
Load Diff
@ -7,7 +7,7 @@
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2024-10-09 11:50+0200\n"
|
||||
"POT-Creation-Date: 2024-10-16 02:19+0200\n"
|
||||
"PO-Revision-Date: 2024-09-17 11:54+0200\n"
|
||||
"Last-Translator: Sli <antoine@bartuccio.fr>\n"
|
||||
"Language-Team: AE info <ae.info@utbm.fr>\n"
|
||||
@ -22,87 +22,95 @@ msgstr ""
|
||||
msgid "captured.%s"
|
||||
msgstr "capture.%s"
|
||||
|
||||
#: core/static/webpack/easymde-index.js:32
|
||||
#: core/static/webpack/ajax-select-index.ts:73
|
||||
msgid "You need to type %(number)s more characters"
|
||||
msgstr "Vous devez taper %(number)s caractères de plus"
|
||||
|
||||
#: core/static/webpack/ajax-select-index.ts:76
|
||||
msgid "No results found"
|
||||
msgstr "Aucun résultat trouvé"
|
||||
|
||||
#: core/static/webpack/easymde-index.ts:31
|
||||
msgid "Heading"
|
||||
msgstr "Titre"
|
||||
|
||||
#: core/static/webpack/easymde-index.js:38
|
||||
#: core/static/webpack/easymde-index.ts:37
|
||||
msgid "Italic"
|
||||
msgstr "Italique"
|
||||
|
||||
#: core/static/webpack/easymde-index.js:44
|
||||
#: core/static/webpack/easymde-index.ts:43
|
||||
msgid "Bold"
|
||||
msgstr "Gras"
|
||||
|
||||
#: core/static/webpack/easymde-index.js:50
|
||||
#: core/static/webpack/easymde-index.ts:49
|
||||
msgid "Strikethrough"
|
||||
msgstr "Barré"
|
||||
|
||||
#: core/static/webpack/easymde-index.js:59
|
||||
#: core/static/webpack/easymde-index.ts:58
|
||||
msgid "Underline"
|
||||
msgstr "Souligné"
|
||||
|
||||
#: core/static/webpack/easymde-index.js:68
|
||||
#: core/static/webpack/easymde-index.ts:67
|
||||
msgid "Superscript"
|
||||
msgstr "Exposant"
|
||||
|
||||
#: core/static/webpack/easymde-index.js:77
|
||||
#: core/static/webpack/easymde-index.ts:76
|
||||
msgid "Subscript"
|
||||
msgstr "Indice"
|
||||
|
||||
#: core/static/webpack/easymde-index.js:83
|
||||
#: core/static/webpack/easymde-index.ts:82
|
||||
msgid "Code"
|
||||
msgstr "Code"
|
||||
|
||||
#: core/static/webpack/easymde-index.js:90
|
||||
#: core/static/webpack/easymde-index.ts:89
|
||||
msgid "Quote"
|
||||
msgstr "Citation"
|
||||
|
||||
#: core/static/webpack/easymde-index.js:96
|
||||
#: core/static/webpack/easymde-index.ts:95
|
||||
msgid "Unordered list"
|
||||
msgstr "Liste non ordonnée"
|
||||
|
||||
#: core/static/webpack/easymde-index.js:102
|
||||
#: core/static/webpack/easymde-index.ts:101
|
||||
msgid "Ordered list"
|
||||
msgstr "Liste ordonnée"
|
||||
|
||||
#: core/static/webpack/easymde-index.js:109
|
||||
#: core/static/webpack/easymde-index.ts:108
|
||||
msgid "Insert link"
|
||||
msgstr "Insérer lien"
|
||||
|
||||
#: core/static/webpack/easymde-index.js:115
|
||||
#: core/static/webpack/easymde-index.ts:114
|
||||
msgid "Insert image"
|
||||
msgstr "Insérer image"
|
||||
|
||||
#: core/static/webpack/easymde-index.js:121
|
||||
#: core/static/webpack/easymde-index.ts:120
|
||||
msgid "Insert table"
|
||||
msgstr "Insérer tableau"
|
||||
|
||||
#: core/static/webpack/easymde-index.js:128
|
||||
#: core/static/webpack/easymde-index.ts:127
|
||||
msgid "Clean block"
|
||||
msgstr "Nettoyer bloc"
|
||||
|
||||
#: core/static/webpack/easymde-index.js:135
|
||||
#: core/static/webpack/easymde-index.ts:134
|
||||
msgid "Toggle preview"
|
||||
msgstr "Activer la prévisualisation"
|
||||
|
||||
#: core/static/webpack/easymde-index.js:141
|
||||
#: core/static/webpack/easymde-index.ts:140
|
||||
msgid "Toggle side by side"
|
||||
msgstr "Activer la vue côte à côte"
|
||||
|
||||
#: core/static/webpack/easymde-index.js:147
|
||||
#: core/static/webpack/easymde-index.ts:146
|
||||
msgid "Toggle fullscreen"
|
||||
msgstr "Activer le plein écran"
|
||||
|
||||
#: core/static/webpack/easymde-index.js:154
|
||||
#: core/static/webpack/easymde-index.ts:153
|
||||
msgid "Markdown guide"
|
||||
msgstr "Guide markdown"
|
||||
|
||||
#: core/static/webpack/user/family-graph-index.js:222
|
||||
#: core/static/webpack/user/family-graph-index.js:233
|
||||
msgid "family_tree.%(extension)s"
|
||||
msgstr "arbre_genealogique.%(extension)s"
|
||||
|
||||
#: core/static/webpack/user/pictures-index.js:67
|
||||
#: core/static/webpack/user/pictures-index.js:76
|
||||
msgid "pictures.%(extension)s"
|
||||
msgstr "photos.%(extension)s"
|
||||
|
||||
@ -110,10 +118,10 @@ msgstr "photos.%(extension)s"
|
||||
msgid "Incorrect value"
|
||||
msgstr "Valeur incorrecte"
|
||||
|
||||
#: sas/static/sas/js/viewer.js:205
|
||||
#: sas/static/webpack/sas/viewer-index.ts:271
|
||||
msgid "Couldn't moderate picture"
|
||||
msgstr "Il n'a pas été possible de modérer l'image"
|
||||
|
||||
#: sas/static/sas/js/viewer.js:217
|
||||
#: sas/static/webpack/sas/viewer-index.ts:284
|
||||
msgid "Couldn't delete picture"
|
||||
msgstr "Il n'a pas été possible de supprimer l'image"
|
||||
|
@ -23,7 +23,12 @@
|
||||
|
||||
from django.urls import path
|
||||
|
||||
from matmat.views import *
|
||||
from matmat.views import (
|
||||
SearchClearFormView,
|
||||
SearchNormalFormView,
|
||||
SearchQuickFormView,
|
||||
SearchReverseFormView,
|
||||
)
|
||||
|
||||
urlpatterns = [
|
||||
path("", SearchNormalFormView.as_view(), name="search"),
|
||||
|
@ -71,15 +71,15 @@ class SearchForm(forms.ModelForm):
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
for key in self.fields.keys():
|
||||
for key in self.fields:
|
||||
self.fields[key].required = False
|
||||
|
||||
@property
|
||||
def cleaned_data_json(self):
|
||||
data = self.cleaned_data
|
||||
for key in data.keys():
|
||||
if key in ("date_of_birth", "phone") and data[key] is not None:
|
||||
data[key] = str(data[key])
|
||||
for key, val in data.items():
|
||||
if key in ("date_of_birth", "phone") and val is not None:
|
||||
data[key] = str(val)
|
||||
return data
|
||||
|
||||
|
||||
@ -98,10 +98,7 @@ class SearchFormListView(FormerSubscriberMixin, SingleObjectMixin, ListView):
|
||||
self.session = request.session
|
||||
self.last_search = self.session.get("matmat_search_result", str([]))
|
||||
self.last_search = literal_eval(self.last_search)
|
||||
if "valid_form" in kwargs.keys():
|
||||
self.valid_form = kwargs["valid_form"]
|
||||
else:
|
||||
self.valid_form = None
|
||||
self.valid_form = kwargs.get("valid_form")
|
||||
|
||||
self.init_query = self.model.objects
|
||||
self.can_see_hidden = True
|
||||
@ -202,8 +199,8 @@ class SearchClearFormView(FormerSubscriberMixin, View):
|
||||
|
||||
def dispatch(self, request, *args, **kwargs):
|
||||
super().dispatch(request, *args, **kwargs)
|
||||
if "matmat_search_form" in request.session.keys():
|
||||
if "matmat_search_form" in request.session:
|
||||
request.session.pop("matmat_search_form")
|
||||
if "matmat_search_result" in request.session.keys():
|
||||
if "matmat_search_result" in request.session:
|
||||
request.session.pop("matmat_search_result")
|
||||
return HttpResponseRedirect(reverse("matmat:search"))
|
||||
|
35
package-lock.json
generated
35
package-lock.json
generated
@ -28,9 +28,9 @@
|
||||
"jquery-ui": "^1.14.0",
|
||||
"jquery.shorten": "^1.0.0",
|
||||
"native-file-system-adapter": "^3.0.1",
|
||||
"select2": "^4.1.0-rc.0",
|
||||
"three": "^0.169.0",
|
||||
"three-spritetext": "^1.9.0"
|
||||
"three-spritetext": "^1.9.0",
|
||||
"tom-select": "^2.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.25.2",
|
||||
@ -39,7 +39,6 @@
|
||||
"@hey-api/openapi-ts": "^0.53.8",
|
||||
"@types/alpinejs": "^3.13.10",
|
||||
"@types/jquery": "^3.5.31",
|
||||
"@types/select2": "^4.0.63",
|
||||
"babel-loader": "^9.2.1",
|
||||
"css-loader": "^7.1.2",
|
||||
"css-minimizer-webpack-plugin": "^7.0.0",
|
||||
@ -2112,6 +2111,19 @@
|
||||
"integrity": "sha512-fuscdXJ9G1qb7W8VdHi+IwRqij3lBkosAm4ydQtEmbY58OzHXqQhvlxqEkoz0yssNVn38bcpRWgA9PP+OGoisw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@orchidjs/sifter": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@orchidjs/sifter/-/sifter-1.0.3.tgz",
|
||||
"integrity": "sha512-zCZbwKegHytfsPm8Amcfh7v/4vHqTAaOu6xFswBYcn8nznBOuseu6COB2ON7ez0tFV0mKL0nRNnCiZZA+lU9/g==",
|
||||
"dependencies": {
|
||||
"@orchidjs/unicode-variants": "^1.0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@orchidjs/unicode-variants": {
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@orchidjs/unicode-variants/-/unicode-variants-1.0.4.tgz",
|
||||
"integrity": "sha512-NvVBRnZNE+dugiXERFsET1JlKZfM5lJDEpSMilKW4bToYJ7pxf0Zne78xyXB2ny2c2aHfJ6WLnz1AaTNHAmQeQ=="
|
||||
},
|
||||
"node_modules/@pkgjs/parseargs": {
|
||||
"version": "0.11.0",
|
||||
"resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
|
||||
@ -6134,6 +6146,7 @@
|
||||
"integrity": "sha512-Hr9TdhyHCZUtwznEH2CBf7967mEM0idtJ5nMtjvk3Up5tPukOLXbHUNmh10oRfeNIhj+3GD3niu+g6sVK+gK0A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
|
||||
"node_modules/semver": {
|
||||
"version": "7.6.3",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz",
|
||||
@ -6617,6 +6630,22 @@
|
||||
"node": ">=8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/tom-select": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/tom-select/-/tom-select-2.3.1.tgz",
|
||||
"integrity": "sha512-QS4vnOcB6StNGqX4sGboGXL2fkhBF2gIBB+8Hwv30FZXYPn0CyYO8kkdATRvwfCTThxiR4WcXwKJZ3cOmtI9eg==",
|
||||
"dependencies": {
|
||||
"@orchidjs/sifter": "^1.0.3",
|
||||
"@orchidjs/unicode-variants": "^1.0.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": "*"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/tom-select"
|
||||
}
|
||||
},
|
||||
"node_modules/totalist": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz",
|
||||
|
@ -27,7 +27,6 @@
|
||||
"@hey-api/openapi-ts": "^0.53.8",
|
||||
"@types/alpinejs": "^3.13.10",
|
||||
"@types/jquery": "^3.5.31",
|
||||
"@types/select2": "^4.0.63",
|
||||
"babel-loader": "^9.2.1",
|
||||
"css-loader": "^7.1.2",
|
||||
"css-minimizer-webpack-plugin": "^7.0.0",
|
||||
@ -61,8 +60,8 @@
|
||||
"jquery-ui": "^1.14.0",
|
||||
"jquery.shorten": "^1.0.0",
|
||||
"native-file-system-adapter": "^3.0.1",
|
||||
"select2": "^4.1.0-rc.0",
|
||||
"three": "^0.169.0",
|
||||
"three-spritetext": "^1.9.0"
|
||||
"three-spritetext": "^1.9.0",
|
||||
"tom-select": "^2.3.1"
|
||||
}
|
||||
}
|
||||
|
@ -32,7 +32,10 @@ class Migration(migrations.Migration):
|
||||
unique=True,
|
||||
validators=[
|
||||
django.core.validators.RegexValidator(
|
||||
message="The code of an UV must only contains uppercase characters without accent and numbers",
|
||||
message=(
|
||||
"The code of an UV must only contains "
|
||||
"uppercase characters without accent and numbers"
|
||||
),
|
||||
regex="([A-Z0-9]+)",
|
||||
)
|
||||
],
|
||||
|
@ -45,7 +45,8 @@ class UV(models.Model):
|
||||
validators.RegexValidator(
|
||||
regex="([A-Z0-9]+)",
|
||||
message=_(
|
||||
"The code of an UV must only contains uppercase characters without accent and numbers"
|
||||
"The code of an UV must only contains "
|
||||
"uppercase characters without accent and numbers"
|
||||
),
|
||||
)
|
||||
],
|
||||
|
@ -27,7 +27,10 @@ class TestUVSearch(TestCase):
|
||||
semester="AUTUMN",
|
||||
department="GI",
|
||||
manager="francky",
|
||||
title="Programmation Orientée Objet: Concepts fondamentaux et mise en pratique avec le langage C++",
|
||||
title=(
|
||||
"Programmation Orientée Objet: "
|
||||
"Concepts fondamentaux et mise en pratique avec le langage C++"
|
||||
),
|
||||
),
|
||||
uv_recipe.prepare(
|
||||
code="MT01",
|
||||
|
@ -381,7 +381,9 @@ class TestUVCommentCreationAndDisplay(TestCase):
|
||||
self.assertContains(
|
||||
response,
|
||||
_(
|
||||
"You already posted a comment on this UV. If you want to comment again, please modify or delete your previous comment."
|
||||
"You already posted a comment on this UV. "
|
||||
"If you want to comment again, "
|
||||
"please modify or delete your previous comment."
|
||||
),
|
||||
)
|
||||
|
||||
|
@ -23,7 +23,17 @@
|
||||
|
||||
from django.urls import path
|
||||
|
||||
from pedagogy.views import *
|
||||
from pedagogy.views import (
|
||||
UVCommentDeleteView,
|
||||
UVCommentReportCreateView,
|
||||
UVCommentUpdateView,
|
||||
UVCreateView,
|
||||
UVDeleteView,
|
||||
UVDetailFormView,
|
||||
UVGuideView,
|
||||
UVModerationFormView,
|
||||
UVUpdateView,
|
||||
)
|
||||
|
||||
urlpatterns = [
|
||||
# Urls displaying the actual application for visitors
|
||||
|
@ -23,7 +23,7 @@
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.mixins import LoginRequiredMixin
|
||||
from django.core.exceptions import ObjectDoesNotExist, PermissionDenied
|
||||
from django.core.exceptions import PermissionDenied
|
||||
from django.shortcuts import get_object_or_404
|
||||
from django.urls import reverse, reverse_lazy
|
||||
from django.views.generic import (
|
||||
@ -193,18 +193,12 @@ class UVModerationFormView(FormView):
|
||||
|
||||
def form_valid(self, form):
|
||||
form_clean = form.clean()
|
||||
for report in form_clean.get("accepted_reports", []):
|
||||
try:
|
||||
report.comment.delete() # Delete the related comment
|
||||
except ObjectDoesNotExist:
|
||||
# To avoid errors when two reports points the same comment
|
||||
pass
|
||||
for report in form_clean.get("denied_reports", []):
|
||||
try:
|
||||
report.delete() # Delete the report itself
|
||||
except ObjectDoesNotExist:
|
||||
# To avoid errors when two reports points the same comment
|
||||
pass
|
||||
accepted = form_clean.get("accepted_reports", [])
|
||||
if len(accepted) > 0: # delete the reported comments
|
||||
UVComment.objects.filter(reports__in=accepted).delete()
|
||||
denied = form_clean.get("denied_reports", [])
|
||||
if len(denied) > 0: # delete the comments themselves
|
||||
UVCommentReport.objects.filter(id__in={d.id for d in denied}).delete()
|
||||
return super().form_valid(form)
|
||||
|
||||
def get_success_url(self):
|
||||
|
284
poetry.lock
generated
284
poetry.lock
generated
@ -343,73 +343,73 @@ files = [
|
||||
|
||||
[[package]]
|
||||
name = "coverage"
|
||||
version = "7.6.2"
|
||||
version = "7.6.3"
|
||||
description = "Code coverage measurement for Python"
|
||||
optional = false
|
||||
python-versions = ">=3.9"
|
||||
files = [
|
||||
{file = "coverage-7.6.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:c9df1950fb92d49970cce38100d7e7293c84ed3606eaa16ea0b6bc27175bb667"},
|
||||
{file = "coverage-7.6.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:24500f4b0e03aab60ce575c85365beab64b44d4db837021e08339f61d1fbfe52"},
|
||||
{file = "coverage-7.6.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a663b180b6669c400b4630a24cc776f23a992d38ce7ae72ede2a397ce6b0f170"},
|
||||
{file = "coverage-7.6.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bfde025e2793a22efe8c21f807d276bd1d6a4bcc5ba6f19dbdfc4e7a12160909"},
|
||||
{file = "coverage-7.6.2-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:087932079c065d7b8ebadd3a0160656c55954144af6439886c8bcf78bbbcde7f"},
|
||||
{file = "coverage-7.6.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:9c6b0c1cafd96213a0327cf680acb39f70e452caf8e9a25aeb05316db9c07f89"},
|
||||
{file = "coverage-7.6.2-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:6e85830eed5b5263ffa0c62428e43cb844296f3b4461f09e4bdb0d44ec190bc2"},
|
||||
{file = "coverage-7.6.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:62ab4231c01e156ece1b3a187c87173f31cbeee83a5e1f6dff17f288dca93345"},
|
||||
{file = "coverage-7.6.2-cp310-cp310-win32.whl", hash = "sha256:7b80fbb0da3aebde102a37ef0138aeedff45997e22f8962e5f16ae1742852676"},
|
||||
{file = "coverage-7.6.2-cp310-cp310-win_amd64.whl", hash = "sha256:d20c3d1f31f14d6962a4e2f549c21d31e670b90f777ef4171be540fb7fb70f02"},
|
||||
{file = "coverage-7.6.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:bb21bac7783c1bf6f4bbe68b1e0ff0d20e7e7732cfb7995bc8d96e23aa90fc7b"},
|
||||
{file = "coverage-7.6.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a7b2e437fbd8fae5bc7716b9c7ff97aecc95f0b4d56e4ca08b3c8d8adcaadb84"},
|
||||
{file = "coverage-7.6.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:536f77f2bf5797983652d1d55f1a7272a29afcc89e3ae51caa99b2db4e89d658"},
|
||||
{file = "coverage-7.6.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f361296ca7054f0936b02525646b2731b32c8074ba6defab524b79b2b7eeac72"},
|
||||
{file = "coverage-7.6.2-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7926d8d034e06b479797c199747dd774d5e86179f2ce44294423327a88d66ca7"},
|
||||
{file = "coverage-7.6.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0bbae11c138585c89fb4e991faefb174a80112e1a7557d507aaa07675c62e66b"},
|
||||
{file = "coverage-7.6.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:fcad7d5d2bbfeae1026b395036a8aa5abf67e8038ae7e6a25c7d0f88b10a8e6a"},
|
||||
{file = "coverage-7.6.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f01e53575f27097d75d42de33b1b289c74b16891ce576d767ad8c48d17aeb5e0"},
|
||||
{file = "coverage-7.6.2-cp311-cp311-win32.whl", hash = "sha256:7781f4f70c9b0b39e1b129b10c7d43a4e0c91f90c60435e6da8288efc2b73438"},
|
||||
{file = "coverage-7.6.2-cp311-cp311-win_amd64.whl", hash = "sha256:9bcd51eeca35a80e76dc5794a9dd7cb04b97f0e8af620d54711793bfc1fbba4b"},
|
||||
{file = "coverage-7.6.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ebc94fadbd4a3f4215993326a6a00e47d79889391f5659bf310f55fe5d9f581c"},
|
||||
{file = "coverage-7.6.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:9681516288e3dcf0aa7c26231178cc0be6cac9705cac06709f2353c5b406cfea"},
|
||||
{file = "coverage-7.6.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8d9c5d13927d77af4fbe453953810db766f75401e764727e73a6ee4f82527b3e"},
|
||||
{file = "coverage-7.6.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b92f9ca04b3e719d69b02dc4a69debb795af84cb7afd09c5eb5d54b4a1ae2191"},
|
||||
{file = "coverage-7.6.2-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0ff2ef83d6d0b527b5c9dad73819b24a2f76fdddcfd6c4e7a4d7e73ecb0656b4"},
|
||||
{file = "coverage-7.6.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:47ccb6e99a3031ffbbd6e7cc041e70770b4fe405370c66a54dbf26a500ded80b"},
|
||||
{file = "coverage-7.6.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:a867d26f06bcd047ef716175b2696b315cb7571ccb951006d61ca80bbc356e9e"},
|
||||
{file = "coverage-7.6.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:cdfcf2e914e2ba653101157458afd0ad92a16731eeba9a611b5cbb3e7124e74b"},
|
||||
{file = "coverage-7.6.2-cp312-cp312-win32.whl", hash = "sha256:f9035695dadfb397bee9eeaf1dc7fbeda483bf7664a7397a629846800ce6e276"},
|
||||
{file = "coverage-7.6.2-cp312-cp312-win_amd64.whl", hash = "sha256:5ed69befa9a9fc796fe015a7040c9398722d6b97df73a6b608e9e275fa0932b0"},
|
||||
{file = "coverage-7.6.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4eea60c79d36a8f39475b1af887663bc3ae4f31289cd216f514ce18d5938df40"},
|
||||
{file = "coverage-7.6.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:aa68a6cdbe1bc6793a9dbfc38302c11599bbe1837392ae9b1d238b9ef3dafcf1"},
|
||||
{file = "coverage-7.6.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3ec528ae69f0a139690fad6deac8a7d33629fa61ccce693fdd07ddf7e9931fba"},
|
||||
{file = "coverage-7.6.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ed5ac02126f74d190fa2cc14a9eb2a5d9837d5863920fa472b02eb1595cdc925"},
|
||||
{file = "coverage-7.6.2-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:21c0ea0d4db8a36b275cb6fb2437a3715697a4ba3cb7b918d3525cc75f726304"},
|
||||
{file = "coverage-7.6.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:35a51598f29b2a19e26d0908bd196f771a9b1c5d9a07bf20be0adf28f1ad4f77"},
|
||||
{file = "coverage-7.6.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:c9192925acc33e146864b8cf037e2ed32a91fdf7644ae875f5d46cd2ef086a5f"},
|
||||
{file = "coverage-7.6.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:bf4eeecc9e10f5403ec06138978235af79c9a79af494eb6b1d60a50b49ed2869"},
|
||||
{file = "coverage-7.6.2-cp313-cp313-win32.whl", hash = "sha256:e4ee15b267d2dad3e8759ca441ad450c334f3733304c55210c2a44516e8d5530"},
|
||||
{file = "coverage-7.6.2-cp313-cp313-win_amd64.whl", hash = "sha256:c71965d1ced48bf97aab79fad56df82c566b4c498ffc09c2094605727c4b7e36"},
|
||||
{file = "coverage-7.6.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:7571e8bbecc6ac066256f9de40365ff833553e2e0c0c004f4482facb131820ef"},
|
||||
{file = "coverage-7.6.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:078a87519057dacb5d77e333f740708ec2a8f768655f1db07f8dfd28d7a005f0"},
|
||||
{file = "coverage-7.6.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1e5e92e3e84a8718d2de36cd8387459cba9a4508337b8c5f450ce42b87a9e760"},
|
||||
{file = "coverage-7.6.2-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ebabdf1c76593a09ee18c1a06cd3022919861365219ea3aca0247ededf6facd6"},
|
||||
{file = "coverage-7.6.2-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:12179eb0575b8900912711688e45474f04ab3934aaa7b624dea7b3c511ecc90f"},
|
||||
{file = "coverage-7.6.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:39d3b964abfe1519b9d313ab28abf1d02faea26cd14b27f5283849bf59479ff5"},
|
||||
{file = "coverage-7.6.2-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:84c4315577f7cd511d6250ffd0f695c825efe729f4205c0340f7004eda51191f"},
|
||||
{file = "coverage-7.6.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:ff797320dcbff57caa6b2301c3913784a010e13b1f6cf4ab3f563f3c5e7919db"},
|
||||
{file = "coverage-7.6.2-cp313-cp313t-win32.whl", hash = "sha256:2b636a301e53964550e2f3094484fa5a96e699db318d65398cfba438c5c92171"},
|
||||
{file = "coverage-7.6.2-cp313-cp313t-win_amd64.whl", hash = "sha256:d03a060ac1a08e10589c27d509bbdb35b65f2d7f3f8d81cf2fa199877c7bc58a"},
|
||||
{file = "coverage-7.6.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:c37faddc8acd826cfc5e2392531aba734b229741d3daec7f4c777a8f0d4993e5"},
|
||||
{file = "coverage-7.6.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:ab31fdd643f162c467cfe6a86e9cb5f1965b632e5e65c072d90854ff486d02cf"},
|
||||
{file = "coverage-7.6.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:97df87e1a20deb75ac7d920c812e9326096aa00a9a4b6d07679b4f1f14b06c90"},
|
||||
{file = "coverage-7.6.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:343056c5e0737487a5291f5691f4dfeb25b3e3c8699b4d36b92bb0e586219d14"},
|
||||
{file = "coverage-7.6.2-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ad4ef1c56b47b6b9024b939d503ab487231df1f722065a48f4fc61832130b90e"},
|
||||
{file = "coverage-7.6.2-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:7fca4a92c8a7a73dee6946471bce6d1443d94155694b893b79e19ca2a540d86e"},
|
||||
{file = "coverage-7.6.2-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:69f251804e052fc46d29d0e7348cdc5fcbfc4861dc4a1ebedef7e78d241ad39e"},
|
||||
{file = "coverage-7.6.2-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:e8ea055b3ea046c0f66217af65bc193bbbeca1c8661dc5fd42698db5795d2627"},
|
||||
{file = "coverage-7.6.2-cp39-cp39-win32.whl", hash = "sha256:6c2ba1e0c24d8fae8f2cf0aeb2fc0a2a7f69b6d20bd8d3749fd6b36ecef5edf0"},
|
||||
{file = "coverage-7.6.2-cp39-cp39-win_amd64.whl", hash = "sha256:2186369a654a15628e9c1c9921409a6b3eda833e4b91f3ca2a7d9f77abb4987c"},
|
||||
{file = "coverage-7.6.2-pp39.pp310-none-any.whl", hash = "sha256:667952739daafe9616db19fbedbdb87917eee253ac4f31d70c7587f7ab531b4e"},
|
||||
{file = "coverage-7.6.2.tar.gz", hash = "sha256:a5f81e68aa62bc0cfca04f7b19eaa8f9c826b53fc82ab9e2121976dc74f131f3"},
|
||||
{file = "coverage-7.6.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:6da42bbcec130b188169107ecb6ee7bd7b4c849d24c9370a0c884cf728d8e976"},
|
||||
{file = "coverage-7.6.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c222958f59b0ae091f4535851cbb24eb57fc0baea07ba675af718fb5302dddb2"},
|
||||
{file = "coverage-7.6.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ab84a8b698ad5a6c365b08061920138e7a7dd9a04b6feb09ba1bfae68346ce6d"},
|
||||
{file = "coverage-7.6.3-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:70a6756ce66cd6fe8486c775b30889f0dc4cb20c157aa8c35b45fd7868255c5c"},
|
||||
{file = "coverage-7.6.3-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3c2e6fa98032fec8282f6b27e3f3986c6e05702828380618776ad794e938f53a"},
|
||||
{file = "coverage-7.6.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:921fbe13492caf6a69528f09d5d7c7d518c8d0e7b9f6701b7719715f29a71e6e"},
|
||||
{file = "coverage-7.6.3-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:6d99198203f0b9cb0b5d1c0393859555bc26b548223a769baf7e321a627ed4fc"},
|
||||
{file = "coverage-7.6.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:87cd2e29067ea397a47e352efb13f976eb1b03e18c999270bb50589323294c6e"},
|
||||
{file = "coverage-7.6.3-cp310-cp310-win32.whl", hash = "sha256:a3328c3e64ea4ab12b85999eb0779e6139295bbf5485f69d42cf794309e3d007"},
|
||||
{file = "coverage-7.6.3-cp310-cp310-win_amd64.whl", hash = "sha256:bca4c8abc50d38f9773c1ec80d43f3768df2e8576807d1656016b9d3eeaa96fd"},
|
||||
{file = "coverage-7.6.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c51ef82302386d686feea1c44dbeef744585da16fcf97deea2a8d6c1556f519b"},
|
||||
{file = "coverage-7.6.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:0ca37993206402c6c35dc717f90d4c8f53568a8b80f0bf1a1b2b334f4d488fba"},
|
||||
{file = "coverage-7.6.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c77326300b839c44c3e5a8fe26c15b7e87b2f32dfd2fc9fee1d13604347c9b38"},
|
||||
{file = "coverage-7.6.3-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6e484e479860e00da1f005cd19d1c5d4a813324e5951319ac3f3eefb497cc549"},
|
||||
{file = "coverage-7.6.3-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0c6c0f4d53ef603397fc894a895b960ecd7d44c727df42a8d500031716d4e8d2"},
|
||||
{file = "coverage-7.6.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:37be7b5ea3ff5b7c4a9db16074dc94523b5f10dd1f3b362a827af66a55198175"},
|
||||
{file = "coverage-7.6.3-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:43b32a06c47539fe275106b376658638b418c7cfdfff0e0259fbf877e845f14b"},
|
||||
{file = "coverage-7.6.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ee77c7bef0724165e795b6b7bf9c4c22a9b8468a6bdb9c6b4281293c6b22a90f"},
|
||||
{file = "coverage-7.6.3-cp311-cp311-win32.whl", hash = "sha256:43517e1f6b19f610a93d8227e47790722c8bf7422e46b365e0469fc3d3563d97"},
|
||||
{file = "coverage-7.6.3-cp311-cp311-win_amd64.whl", hash = "sha256:04f2189716e85ec9192df307f7c255f90e78b6e9863a03223c3b998d24a3c6c6"},
|
||||
{file = "coverage-7.6.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:27bd5f18d8f2879e45724b0ce74f61811639a846ff0e5c0395b7818fae87aec6"},
|
||||
{file = "coverage-7.6.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d546cfa78844b8b9c1c0533de1851569a13f87449897bbc95d698d1d3cb2a30f"},
|
||||
{file = "coverage-7.6.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9975442f2e7a5cfcf87299c26b5a45266ab0696348420049b9b94b2ad3d40234"},
|
||||
{file = "coverage-7.6.3-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:583049c63106c0555e3ae3931edab5669668bbef84c15861421b94e121878d3f"},
|
||||
{file = "coverage-7.6.3-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2341a78ae3a5ed454d524206a3fcb3cec408c2a0c7c2752cd78b606a2ff15af4"},
|
||||
{file = "coverage-7.6.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a4fb91d5f72b7e06a14ff4ae5be625a81cd7e5f869d7a54578fc271d08d58ae3"},
|
||||
{file = "coverage-7.6.3-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:e279f3db904e3b55f520f11f983cc8dc8a4ce9b65f11692d4718ed021ec58b83"},
|
||||
{file = "coverage-7.6.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:aa23ce39661a3e90eea5f99ec59b763b7d655c2cada10729ed920a38bfc2b167"},
|
||||
{file = "coverage-7.6.3-cp312-cp312-win32.whl", hash = "sha256:52ac29cc72ee7e25ace7807249638f94c9b6a862c56b1df015d2b2e388e51dbd"},
|
||||
{file = "coverage-7.6.3-cp312-cp312-win_amd64.whl", hash = "sha256:40e8b1983080439d4802d80b951f4a93d991ef3261f69e81095a66f86cf3c3c6"},
|
||||
{file = "coverage-7.6.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:9134032f5aa445ae591c2ba6991d10136a1f533b1d2fa8f8c21126468c5025c6"},
|
||||
{file = "coverage-7.6.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:99670790f21a96665a35849990b1df447993880bb6463a0a1d757897f30da929"},
|
||||
{file = "coverage-7.6.3-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2dc7d6b380ca76f5e817ac9eef0c3686e7834c8346bef30b041a4ad286449990"},
|
||||
{file = "coverage-7.6.3-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f7b26757b22faf88fcf232f5f0e62f6e0fd9e22a8a5d0d5016888cdfe1f6c1c4"},
|
||||
{file = "coverage-7.6.3-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4c59d6a4a4633fad297f943c03d0d2569867bd5372eb5684befdff8df8522e39"},
|
||||
{file = "coverage-7.6.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f263b18692f8ed52c8de7f40a0751e79015983dbd77b16906e5b310a39d3ca21"},
|
||||
{file = "coverage-7.6.3-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:79644f68a6ff23b251cae1c82b01a0b51bc40c8468ca9585c6c4b1aeee570e0b"},
|
||||
{file = "coverage-7.6.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:71967c35828c9ff94e8c7d405469a1fb68257f686bca7c1ed85ed34e7c2529c4"},
|
||||
{file = "coverage-7.6.3-cp313-cp313-win32.whl", hash = "sha256:e266af4da2c1a4cbc6135a570c64577fd3e6eb204607eaff99d8e9b710003c6f"},
|
||||
{file = "coverage-7.6.3-cp313-cp313-win_amd64.whl", hash = "sha256:ea52bd218d4ba260399a8ae4bb6b577d82adfc4518b93566ce1fddd4a49d1dce"},
|
||||
{file = "coverage-7.6.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:8d4c6ea0f498c7c79111033a290d060c517853a7bcb2f46516f591dab628ddd3"},
|
||||
{file = "coverage-7.6.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:331b200ad03dbaa44151d74daeb7da2cf382db424ab923574f6ecca7d3b30de3"},
|
||||
{file = "coverage-7.6.3-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:54356a76b67cf8a3085818026bb556545ebb8353951923b88292556dfa9f812d"},
|
||||
{file = "coverage-7.6.3-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ebec65f5068e7df2d49466aab9128510c4867e532e07cb6960075b27658dca38"},
|
||||
{file = "coverage-7.6.3-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d33a785ea8354c480515e781554d3be582a86297e41ccbea627a5c632647f2cd"},
|
||||
{file = "coverage-7.6.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:f7ddb920106bbbbcaf2a274d56f46956bf56ecbde210d88061824a95bdd94e92"},
|
||||
{file = "coverage-7.6.3-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:70d24936ca6c15a3bbc91ee9c7fc661132c6f4c9d42a23b31b6686c05073bde5"},
|
||||
{file = "coverage-7.6.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:c30e42ea11badb147f0d2e387115b15e2bd8205a5ad70d6ad79cf37f6ac08c91"},
|
||||
{file = "coverage-7.6.3-cp313-cp313t-win32.whl", hash = "sha256:365defc257c687ce3e7d275f39738dcd230777424117a6c76043459db131dd43"},
|
||||
{file = "coverage-7.6.3-cp313-cp313t-win_amd64.whl", hash = "sha256:23bb63ae3f4c645d2d82fa22697364b0046fbafb6261b258a58587441c5f7bd0"},
|
||||
{file = "coverage-7.6.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:da29ceabe3025a1e5a5aeeb331c5b1af686daab4ff0fb4f83df18b1180ea83e2"},
|
||||
{file = "coverage-7.6.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:df8c05a0f574d480947cba11b947dc41b1265d721c3777881da2fb8d3a1ddfba"},
|
||||
{file = "coverage-7.6.3-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ec1e3b40b82236d100d259854840555469fad4db64f669ab817279eb95cd535c"},
|
||||
{file = "coverage-7.6.3-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b4adeb878a374126f1e5cf03b87f66279f479e01af0e9a654cf6d1509af46c40"},
|
||||
{file = "coverage-7.6.3-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:43d6a66e33b1455b98fc7312b124296dad97a2e191c80320587234a77b1b736e"},
|
||||
{file = "coverage-7.6.3-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:1990b1f4e2c402beb317840030bb9f1b6a363f86e14e21b4212e618acdfce7f6"},
|
||||
{file = "coverage-7.6.3-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:12f9515d875859faedb4144fd38694a761cd2a61ef9603bf887b13956d0bbfbb"},
|
||||
{file = "coverage-7.6.3-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:99ded130555c021d99729fabd4ddb91a6f4cc0707df4b1daf912c7850c373b13"},
|
||||
{file = "coverage-7.6.3-cp39-cp39-win32.whl", hash = "sha256:c3a79f56dee9136084cf84a6c7c4341427ef36e05ae6415bf7d787c96ff5eaa3"},
|
||||
{file = "coverage-7.6.3-cp39-cp39-win_amd64.whl", hash = "sha256:aac7501ae73d4a02f4b7ac8fcb9dc55342ca98ffb9ed9f2dfb8a25d53eda0e4d"},
|
||||
{file = "coverage-7.6.3-pp39.pp310-none-any.whl", hash = "sha256:b9853509b4bf57ba7b1f99b9d866c422c9c5248799ab20e652bbb8a184a38181"},
|
||||
{file = "coverage-7.6.3.tar.gz", hash = "sha256:bb7d5fe92bd0dc235f63ebe9f8c6e0884f7360f88f3411bfed1350c872ef2054"},
|
||||
]
|
||||
|
||||
[package.extras]
|
||||
@ -491,13 +491,13 @@ tests = ["noseofyeti[black] (==2.4.9)", "pytest (==8.3.2)"]
|
||||
|
||||
[[package]]
|
||||
name = "distlib"
|
||||
version = "0.3.8"
|
||||
version = "0.3.9"
|
||||
description = "Distribution utilities"
|
||||
optional = false
|
||||
python-versions = "*"
|
||||
files = [
|
||||
{file = "distlib-0.3.8-py2.py3-none-any.whl", hash = "sha256:034db59a0b96f8ca18035f36290806a9a6e6bd9d1ff91e45a7f172eb17e51784"},
|
||||
{file = "distlib-0.3.8.tar.gz", hash = "sha256:1530ea13e350031b6312d8580ddb6b27a104275a31106523b8f123787f494f64"},
|
||||
{file = "distlib-0.3.9-py2.py3-none-any.whl", hash = "sha256:47f8c22fd27c27e25a65601af709b38e4f0a45ea4fc2e710f65755fa8caaaf87"},
|
||||
{file = "distlib-0.3.9.tar.gz", hash = "sha256:a60f20dea646b8a33f3e7772f74dc0b2d0772d2837ee1342a00645c81edf9403"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@ -814,13 +814,13 @@ dev = ["flake8", "markdown", "twine", "wheel"]
|
||||
|
||||
[[package]]
|
||||
name = "griffe"
|
||||
version = "1.3.2"
|
||||
version = "1.4.1"
|
||||
description = "Signatures for entire Python programs. Extract the structure, the frame, the skeleton of your project, to generate API documentation or find breaking changes in your API."
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
python-versions = ">=3.9"
|
||||
files = [
|
||||
{file = "griffe-1.3.2-py3-none-any.whl", hash = "sha256:2e34b5e46507d615915c8e6288bb1a2234bd35dee44d01e40a2bc2f25bd4d10c"},
|
||||
{file = "griffe-1.3.2.tar.gz", hash = "sha256:1ec50335aa507ed2445f2dd45a15c9fa3a45f52c9527e880571dfc61912fd60c"},
|
||||
{file = "griffe-1.4.1-py3-none-any.whl", hash = "sha256:84295ee0b27743bd880aea75632830ef02ded65d16124025e4c263bb826ab645"},
|
||||
{file = "griffe-1.4.1.tar.gz", hash = "sha256:911a201b01dc92e08c0e84c38a301e9da5ec067f00e7d9f2e39bc24dbfa3c176"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
@ -1283,13 +1283,13 @@ cache = ["platformdirs"]
|
||||
|
||||
[[package]]
|
||||
name = "mkdocs-material"
|
||||
version = "9.5.39"
|
||||
version = "9.5.40"
|
||||
description = "Documentation that simply works"
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
files = [
|
||||
{file = "mkdocs_material-9.5.39-py3-none-any.whl", hash = "sha256:0f2f68c8db89523cb4a59705cd01b4acd62b2f71218ccb67e1e004e560410d2b"},
|
||||
{file = "mkdocs_material-9.5.39.tar.gz", hash = "sha256:25faa06142afa38549d2b781d475a86fb61de93189f532b88e69bf11e5e5c3be"},
|
||||
{file = "mkdocs_material-9.5.40-py3-none-any.whl", hash = "sha256:8e7a16ada34e79a7b6459ff2602584222f522c738b6a023d1bea853d5049da6f"},
|
||||
{file = "mkdocs_material-9.5.40.tar.gz", hash = "sha256:b69d70e667ec51fc41f65e006a3184dd00d95b2439d982cb1586e4c018943156"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
@ -1323,13 +1323,13 @@ files = [
|
||||
|
||||
[[package]]
|
||||
name = "mkdocstrings"
|
||||
version = "0.26.1"
|
||||
version = "0.26.2"
|
||||
description = "Automatic documentation from sources, for MkDocs."
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
python-versions = ">=3.9"
|
||||
files = [
|
||||
{file = "mkdocstrings-0.26.1-py3-none-any.whl", hash = "sha256:29738bfb72b4608e8e55cc50fb8a54f325dc7ebd2014e4e3881a49892d5983cf"},
|
||||
{file = "mkdocstrings-0.26.1.tar.gz", hash = "sha256:bb8b8854d6713d5348ad05b069a09f3b79edbc6a0f33a34c6821141adb03fe33"},
|
||||
{file = "mkdocstrings-0.26.2-py3-none-any.whl", hash = "sha256:1248f3228464f3b8d1a15bd91249ce1701fe3104ac517a5f167a0e01ca850ba5"},
|
||||
{file = "mkdocstrings-0.26.2.tar.gz", hash = "sha256:34a8b50f1e6cfd29546c6c09fbe02154adfb0b361bb758834bf56aa284ba876e"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
@ -1349,13 +1349,13 @@ python-legacy = ["mkdocstrings-python-legacy (>=0.2.1)"]
|
||||
|
||||
[[package]]
|
||||
name = "mkdocstrings-python"
|
||||
version = "1.11.1"
|
||||
version = "1.12.1"
|
||||
description = "A Python handler for mkdocstrings."
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
python-versions = ">=3.9"
|
||||
files = [
|
||||
{file = "mkdocstrings_python-1.11.1-py3-none-any.whl", hash = "sha256:a21a1c05acef129a618517bb5aae3e33114f569b11588b1e7af3e9d4061a71af"},
|
||||
{file = "mkdocstrings_python-1.11.1.tar.gz", hash = "sha256:8824b115c5359304ab0b5378a91f6202324a849e1da907a3485b59208b797322"},
|
||||
{file = "mkdocstrings_python-1.12.1-py3-none-any.whl", hash = "sha256:205244488199c9aa2a39787ad6a0c862d39b74078ea9aa2be817bc972399563f"},
|
||||
{file = "mkdocstrings_python-1.12.1.tar.gz", hash = "sha256:60d6a5ca912c9af4ad431db6d0111ce9f79c6c48d33377dde6a05a8f5f48d792"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
@ -1365,13 +1365,13 @@ mkdocstrings = ">=0.26"
|
||||
|
||||
[[package]]
|
||||
name = "model-bakery"
|
||||
version = "1.19.5"
|
||||
version = "1.20.0"
|
||||
description = "Smart object creation facility for Django."
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
files = [
|
||||
{file = "model_bakery-1.19.5-py3-none-any.whl", hash = "sha256:09ecbbf124d32614339581b642c82ac4a73147442f598c7bad23eece24187e5c"},
|
||||
{file = "model_bakery-1.19.5.tar.gz", hash = "sha256:37cece544a33f8899ed8f0488cd6a9d2b0b6925e7b478a4ff2786dece8c63745"},
|
||||
{file = "model_bakery-1.20.0-py3-none-any.whl", hash = "sha256:875326466f5982ee8f0281abdfa774d78893d5473562575dfd5a9304ac7c5b8c"},
|
||||
{file = "model_bakery-1.20.0.tar.gz", hash = "sha256:ec9dc846b9a00b20f92df38fac310263323ab61b59b6eeebf77a4aefb0412724"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
@ -1599,13 +1599,13 @@ testing = ["pytest", "pytest-benchmark"]
|
||||
|
||||
[[package]]
|
||||
name = "pre-commit"
|
||||
version = "3.8.0"
|
||||
version = "4.0.1"
|
||||
description = "A framework for managing and maintaining multi-language pre-commit hooks."
|
||||
optional = false
|
||||
python-versions = ">=3.9"
|
||||
files = [
|
||||
{file = "pre_commit-3.8.0-py2.py3-none-any.whl", hash = "sha256:9a90a53bf82fdd8778d58085faf8d83df56e40dfe18f45b19446e26bf1b3a63f"},
|
||||
{file = "pre_commit-3.8.0.tar.gz", hash = "sha256:8bb6494d4a20423842e198980c9ecf9f96607a07ea29549e180eef9ae80fe7af"},
|
||||
{file = "pre_commit-4.0.1-py2.py3-none-any.whl", hash = "sha256:efde913840816312445dc98787724647c65473daefe420785f885e8ed9a06878"},
|
||||
{file = "pre_commit-4.0.1.tar.gz", hash = "sha256:80905ac375958c0444c65e9cebebd948b3cdb518f335a091a670a89d652139d2"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
@ -2192,58 +2192,70 @@ use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"]
|
||||
|
||||
[[package]]
|
||||
name = "rjsmin"
|
||||
version = "1.2.2"
|
||||
version = "1.2.3"
|
||||
description = "Javascript Minifier"
|
||||
optional = false
|
||||
python-versions = "*"
|
||||
files = [
|
||||
{file = "rjsmin-1.2.2-cp27-cp27m-manylinux1_i686.whl", hash = "sha256:4420107304ba7a00b5b9b56cdcd166b9876b34e626829fc4552c85d8fdc3737a"},
|
||||
{file = "rjsmin-1.2.2-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:155a2f3312c1f8c6cec7b5080581cafc761dc0e41d64bfb5d46a772c5230ded8"},
|
||||
{file = "rjsmin-1.2.2-cp27-cp27mu-manylinux1_i686.whl", hash = "sha256:88fcb58d65f88cbfa752d51c1ebe5845553f9706def6d9671e98283411575e3e"},
|
||||
{file = "rjsmin-1.2.2-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:6eae13608b88f4ce32e0557c8fdef58e69bb4d293182202a03e800f0d33b5268"},
|
||||
{file = "rjsmin-1.2.2-cp310-cp310-manylinux1_i686.whl", hash = "sha256:81f92fb855fb613ebd04a6d6d46483e71fe3c4f22042dc30dcc938fbd748e59c"},
|
||||
{file = "rjsmin-1.2.2-cp310-cp310-manylinux1_x86_64.whl", hash = "sha256:897db9bf25538047e9388951d532dc291a629b5d041180a8a1a8c102e9d44b90"},
|
||||
{file = "rjsmin-1.2.2-cp310-cp310-manylinux2014_aarch64.whl", hash = "sha256:5938af8c46734f92f74fdc4d0b6324137c0e09f0a8c3825c83e4cfca1b532e40"},
|
||||
{file = "rjsmin-1.2.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:0424a7b9096fa2b0ab577c4dc7acd683e6cfb5c718ad39a9fb293cb6cbaba95b"},
|
||||
{file = "rjsmin-1.2.2-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:1714ed93c2bd40c5f970905d2eeda4a6844e09087ae11277d4d43b3e68c32a47"},
|
||||
{file = "rjsmin-1.2.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:35596fa6d2d44a5471715c464657123995da78aa6f79bccfbb4b8d6ff7d0a4b4"},
|
||||
{file = "rjsmin-1.2.2-cp311-cp311-manylinux1_i686.whl", hash = "sha256:3968667158948355b9a62e9641497aac7ac069c076a595e93199d0fe3a40217a"},
|
||||
{file = "rjsmin-1.2.2-cp311-cp311-manylinux1_x86_64.whl", hash = "sha256:d07d14354694f6a47f572f2aa2a1ad74b76723e62a0d2b6df796138b71888247"},
|
||||
{file = "rjsmin-1.2.2-cp311-cp311-manylinux2014_aarch64.whl", hash = "sha256:a78dfa6009235b902454ac53264252b7b94f1e43e3a9e97c4cadae88e409b882"},
|
||||
{file = "rjsmin-1.2.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:9b7a45001e58243a455d11d2de925cadb8c2a0dc737001de646a0f4d90cf0034"},
|
||||
{file = "rjsmin-1.2.2-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:86c5e657b74b6c9482bb96f18a79d61750f4e8204759cce179f7eb17d395c683"},
|
||||
{file = "rjsmin-1.2.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:8c2c30b86c7232443a4a726e1bbee34f800556e581e95fc07194ecbf8e02d1d2"},
|
||||
{file = "rjsmin-1.2.2-cp312-cp312-manylinux1_i686.whl", hash = "sha256:8982c3ef27fac26dd6b7d0c55ae98fa550fee72da2db010b87211e4b5dd78a67"},
|
||||
{file = "rjsmin-1.2.2-cp312-cp312-manylinux1_x86_64.whl", hash = "sha256:3fc27ae4ece99e2c994cd79df2f0d3f7ac650249f632d19aa8ce85118e33bf0f"},
|
||||
{file = "rjsmin-1.2.2-cp312-cp312-manylinux2014_aarch64.whl", hash = "sha256:41113d8d6cae7f7406b30143cc49cc045bbb3fadc2f28df398cea30e1daa60b1"},
|
||||
{file = "rjsmin-1.2.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:3aa09a89b2b7aa2b9251329fe0c3e36c2dc2f10f78b8811e5be92a072596348b"},
|
||||
{file = "rjsmin-1.2.2-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:5abb8d1241f4ea97950b872fa97a422ba8413fe02358f64128ff0cf745017f07"},
|
||||
{file = "rjsmin-1.2.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:5abc686a9ef7eaf208f9ad1fb5fb949556ecb7cc1fee27290eb7f194e01d97bd"},
|
||||
{file = "rjsmin-1.2.2-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:076adcf04c34f712c9427fd9ba6a75bbf7aab975650dfc78cbdd0fbdbe49ca63"},
|
||||
{file = "rjsmin-1.2.2-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:8cb8947ddd250fce58261b0357846cd5d55419419c0f7dfb131dc4b733579a26"},
|
||||
{file = "rjsmin-1.2.2-cp36-cp36m-manylinux2014_aarch64.whl", hash = "sha256:9069c48b6508b9c5b05435e2c6042c2a0e2f97b35d7b9c27ceaea5fd377ffdc5"},
|
||||
{file = "rjsmin-1.2.2-cp36-cp36m-musllinux_1_1_aarch64.whl", hash = "sha256:02b61cf9b6bc518fdac667f3ca3dab051cb8bd1bf4cba28b6d29153ec27990ad"},
|
||||
{file = "rjsmin-1.2.2-cp36-cp36m-musllinux_1_1_i686.whl", hash = "sha256:09eca8581797244587916e5e07e36c4c86d54a4b7e5c7697484a95b75803515d"},
|
||||
{file = "rjsmin-1.2.2-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:c52b9dd45c837f1c5c2e8d40776f9e63257f8dbd5f79b85f648cc70da6c1e4e9"},
|
||||
{file = "rjsmin-1.2.2-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:4fe4ce990412c053a6bcd47d55133927e22fd3d100233d73355f60f9053054c5"},
|
||||
{file = "rjsmin-1.2.2-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:aa883b9363b5134239066060879d5eb422a0d4ccf24ccf871f65a5b34c64926f"},
|
||||
{file = "rjsmin-1.2.2-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:6f4e95c5ac95b4cbb519917b3aa1d3d92fc6939c371637674c4a42b67b2b3f44"},
|
||||
{file = "rjsmin-1.2.2-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:ae3cd64e18e62aa330b24dd6f7b9809ce0a694afd1f01fe99c21f9acd1cb0ea6"},
|
||||
{file = "rjsmin-1.2.2-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:7999d797fcf805844d2d91598651785497249f592f31674da0964e794b3be019"},
|
||||
{file = "rjsmin-1.2.2-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:e733fea039a7b5ad7c06cc8bf215ee7afac81d462e273b3ab55c1ccc906cf127"},
|
||||
{file = "rjsmin-1.2.2-cp38-cp38-manylinux1_i686.whl", hash = "sha256:ccca74461bd53a99ff3304fcf299ea861df89846be3207329cb82d717ce47ea6"},
|
||||
{file = "rjsmin-1.2.2-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:88f59ad24f91bf9c25d5c2ca3c84a72eed0028f57a98e3b85a915ece5c25be1e"},
|
||||
{file = "rjsmin-1.2.2-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:7a8b56fbd64adcc4402637f0e07b90b441e9981d720a10eb6265118018b42682"},
|
||||
{file = "rjsmin-1.2.2-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:2c24686cfdf86e55692183f7867e72c9e982add479c244eda7b8390f96db2c6c"},
|
||||
{file = "rjsmin-1.2.2-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:6c0d9f9ea8d9cd48cbcdc74a1c2e85d4d588af12bb8f0b672070ae7c9b6e6306"},
|
||||
{file = "rjsmin-1.2.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:27abd32c9f5b6e0c0a3bcad43e8e24108c6d6c13a4e6c50c97497ea2b4614bb4"},
|
||||
{file = "rjsmin-1.2.2-cp39-cp39-manylinux1_i686.whl", hash = "sha256:e0e009f6f8460901f5144b34ac2948f94af2f9b8c9b5425da705dbc8152c36c2"},
|
||||
{file = "rjsmin-1.2.2-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:41e6013cb37a5b3563c19aa35f8e659fa536aa4197a0e3b6a57a381638294a15"},
|
||||
{file = "rjsmin-1.2.2-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:62cbd38c9f5090f0a6378a45c415b4f96ae871216cedab0dfa21965620c0be4c"},
|
||||
{file = "rjsmin-1.2.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:2fd5254d36f10a17564b63e8bf9ac579c7b5f211364e11e9753ff5b562843c67"},
|
||||
{file = "rjsmin-1.2.2-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:6cf0309d001a0d45d731dbaab1afd0c23d135c9e029fe56c935c1798094686fc"},
|
||||
{file = "rjsmin-1.2.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:bfbe333dab8d23f0a71da90e2d8e8b762a739cbd55a6f948b2dfda089b6d5853"},
|
||||
{file = "rjsmin-1.2.2.tar.gz", hash = "sha256:8c1bcd821143fecf23242012b55e13610840a839cd467b358f16359010d62dae"},
|
||||
{file = "rjsmin-1.2.3-cp27-cp27m-manylinux1_i686.whl", hash = "sha256:86e4257875d89b0f7968af9e7c0292e72454f6c75031d1818997782b2e8425a8"},
|
||||
{file = "rjsmin-1.2.3-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:f2ab72093591127e627b13c1243d4fef40c10593c733517999682f7f2ebf47ee"},
|
||||
{file = "rjsmin-1.2.3-cp27-cp27mu-manylinux1_i686.whl", hash = "sha256:448b9eb9fd7b6a70beb5c728a41bc23561dd011f0b8fcf7ed9855b6be198c9a2"},
|
||||
{file = "rjsmin-1.2.3-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:ebd3948f9bc912525bab93f61c694b11410296b5fd0806e988d42378ef302b8e"},
|
||||
{file = "rjsmin-1.2.3-cp310-cp310-manylinux1_i686.whl", hash = "sha256:823f856b40681328157e5dffc0a588dddefb4b6ce49f79de994dfca6084617be"},
|
||||
{file = "rjsmin-1.2.3-cp310-cp310-manylinux1_x86_64.whl", hash = "sha256:15e3019f0823a003741ddb93e0c70c5d22567acd0757a7edacc40face1517029"},
|
||||
{file = "rjsmin-1.2.3-cp310-cp310-manylinux2014_aarch64.whl", hash = "sha256:dece04e309e29879c12dca8af166ea5d77c497ec932cf82e4a1eb24d1489c398"},
|
||||
{file = "rjsmin-1.2.3-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:dd4a1e527568c3a9711ff1d5251763645c14df02d52a45aec089836600b664ea"},
|
||||
{file = "rjsmin-1.2.3-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:78aaa3b79a244a4e21164ce355ce22a5a0d7f2d7841a10343009406a3d34d9bb"},
|
||||
{file = "rjsmin-1.2.3-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:8ea4617618cbf78d98756878a292309f6f54fb4ea1b1ea406f79e88eda4d5d50"},
|
||||
{file = "rjsmin-1.2.3-cp311-cp311-manylinux1_i686.whl", hash = "sha256:85957171184ef2dee1957cef5e4adb93a7e2702c12c30bd74420ebace1756e89"},
|
||||
{file = "rjsmin-1.2.3-cp311-cp311-manylinux1_x86_64.whl", hash = "sha256:b6485014e9cbec9a41fb4a7b96ce511ab45a5db8c54ca57ad610f53747e7bab1"},
|
||||
{file = "rjsmin-1.2.3-cp311-cp311-manylinux2014_aarch64.whl", hash = "sha256:64ac6ef8753c56179a53e237ea4d2b3ccdef88b8b51141618311d48e31013207"},
|
||||
{file = "rjsmin-1.2.3-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:dbd5f653b5ebcd4920793009ffa210ad5523c523e39e45ee1a0770e4323126dc"},
|
||||
{file = "rjsmin-1.2.3-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:b0174d7786dcebab808485d1c27f049c74b97590cddcd62f6ed54796a2c6503b"},
|
||||
{file = "rjsmin-1.2.3-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:6cf24720ea214cbffa0ed96ba0dc24a5cf3ff3cbf59d44a1018292424b48452a"},
|
||||
{file = "rjsmin-1.2.3-cp312-cp312-manylinux1_i686.whl", hash = "sha256:ac911d1a12a6d7879ba52e08c56b0ad1a74377bae52610ea74f0f9d936d41785"},
|
||||
{file = "rjsmin-1.2.3-cp312-cp312-manylinux1_x86_64.whl", hash = "sha256:57a0b2f13402623e4ec44eb7ad8846387b2d5605aa8732a05ebefb2289c24b96"},
|
||||
{file = "rjsmin-1.2.3-cp312-cp312-manylinux2014_aarch64.whl", hash = "sha256:e28610cca3ab03e43113eadad4f7dd9ea235ddc29a8dc5462bb161a80e5d251f"},
|
||||
{file = "rjsmin-1.2.3-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:d4afb4fc3624dc44a7fbae4e41c0b5dc5d861a7f5de865ad463041ec1b5d835c"},
|
||||
{file = "rjsmin-1.2.3-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:ca26b80c7e63cf0788b41571a4bd08d175df7719364e0dd9a3cf7b6cb1ab834c"},
|
||||
{file = "rjsmin-1.2.3-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:fcc22001588b84d34bbf2c77afa519894244150c4b0754a6e573298ffac24666"},
|
||||
{file = "rjsmin-1.2.3-cp313-cp313-manylinux1_i686.whl", hash = "sha256:624d1a0a35122f3f8955d160a39305cf6f786a5b346ee34c516b391cb153a106"},
|
||||
{file = "rjsmin-1.2.3-cp313-cp313-manylinux1_x86_64.whl", hash = "sha256:72bd04b7db6190339d8214a5fd289ca31fc1ed30a240f8b0ca13acb9ce3a88af"},
|
||||
{file = "rjsmin-1.2.3-cp313-cp313-manylinux2014_aarch64.whl", hash = "sha256:7559f59f4749519b92d72bb68e33b68463f479a82a2a739f1b28a853067aa0e7"},
|
||||
{file = "rjsmin-1.2.3-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:aa8bdecf278f754d1a133ab51119a63a4d38500557912bb0930ae0fd61437ec6"},
|
||||
{file = "rjsmin-1.2.3-cp313-cp313-musllinux_1_1_i686.whl", hash = "sha256:2078acc2d02a005ef122eb330e941462c8c3102cf798ad49f1c5ec18ac714240"},
|
||||
{file = "rjsmin-1.2.3-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:fa40584fddb4f1d2236119505f6c2fe2b57a1ebaf6eaee2bb2eaac33d2a4ca73"},
|
||||
{file = "rjsmin-1.2.3-cp313-cp313t-manylinux1_i686.whl", hash = "sha256:bbe5d8340878b38dd4f7b879ed7728f6fc3d7524ad81a5cfbe4eb8ae63951407"},
|
||||
{file = "rjsmin-1.2.3-cp313-cp313t-manylinux1_x86_64.whl", hash = "sha256:c298c93f5633cf894325907cf49fc7fb010c0f75dc9cda90b0fc1684ad19e5a3"},
|
||||
{file = "rjsmin-1.2.3-cp313-cp313t-manylinux2014_aarch64.whl", hash = "sha256:35f18cffe3f1bf6d96bcfd977199378ebfd641d823b08e235d1e0bb0fbaa5532"},
|
||||
{file = "rjsmin-1.2.3-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:9aeadf4dd5f941bebf110fe83960a4bafdac176647537819bb7662f5e9a37aaa"},
|
||||
{file = "rjsmin-1.2.3-cp313-cp313t-musllinux_1_1_i686.whl", hash = "sha256:c3219e6e22897b31c8598cb412ed56bc12a722c1d4f88a71710c16efe8c07d0c"},
|
||||
{file = "rjsmin-1.2.3-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:bceccb06b118be890fe735fc09ee256851f4993708cb3647f6c71dd0151cce89"},
|
||||
{file = "rjsmin-1.2.3-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:f3620271f00b8ba3c7c5134ca1d99cde5fd1bf1e84aa96aa65c177ee634122f7"},
|
||||
{file = "rjsmin-1.2.3-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:f3d86f70fcca5f68b65eabbce365d07d80404ecd6aa9c55ba9e9f1042a3514c7"},
|
||||
{file = "rjsmin-1.2.3-cp36-cp36m-manylinux2014_aarch64.whl", hash = "sha256:1dae9230eae6d7eb2820a511cb640ca6f2e5b91ff78805d71332e8a65a898ea1"},
|
||||
{file = "rjsmin-1.2.3-cp36-cp36m-musllinux_1_1_aarch64.whl", hash = "sha256:b788c3ec9d68d8fda2240eb7831bdfb2cc0c88d5fb38c9ed6e0fd090eb5d1490"},
|
||||
{file = "rjsmin-1.2.3-cp36-cp36m-musllinux_1_1_i686.whl", hash = "sha256:4763efbfad7fbf3240a33f08f64991bf0db07453caf283eea51ade84053e9bb7"},
|
||||
{file = "rjsmin-1.2.3-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:e1379e448da75e2426205c756e79d7b9ba1b7ed616fe97122d72c3fe054e8cac"},
|
||||
{file = "rjsmin-1.2.3-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:27e134f4d91a5986cba6dced5cb539947a3ec61544ab5ef31b74b384ddc03931"},
|
||||
{file = "rjsmin-1.2.3-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:2674fcad70d0fab4c1c71e4ac1d4d67935f67e6ecc3924de0dd1264c80a9f9a2"},
|
||||
{file = "rjsmin-1.2.3-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:b1f2540bd0ce7eda326df7b3bfa360f6edd526bfcb959b5d136afdbccddf0765"},
|
||||
{file = "rjsmin-1.2.3-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:23f3b4adde995a0d0b7835558840dd4673adf99d2473b6d40474d30801d6c57b"},
|
||||
{file = "rjsmin-1.2.3-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:c5fb0574eb541d374a2751e9c0ae019fdd86c9e3eb2e7cf893756886e7b3923f"},
|
||||
{file = "rjsmin-1.2.3-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:18d6a3229d1ed511a0b0a9a7271ef58ff3b02ba408b92b426857b33b137e7f15"},
|
||||
{file = "rjsmin-1.2.3-cp38-cp38-manylinux1_i686.whl", hash = "sha256:7fe1181452fca7713f377cb6c43cd139638a9edc8c8c29c67119626df164b317"},
|
||||
{file = "rjsmin-1.2.3-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:57708a4e637aac571c578424a7092d3ec64afb1eabbb73e0c71659457eac9ee4"},
|
||||
{file = "rjsmin-1.2.3-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:4c1b5a888d43063a22e2e2c2b4db4d6139dfa6e0d2903ae9bb050ed63a340f40"},
|
||||
{file = "rjsmin-1.2.3-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:e4ac3f85df88d636a9680432fbbf5d3fe1f171821688106a6710738f06575fc2"},
|
||||
{file = "rjsmin-1.2.3-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:9dff6b14f92ca7a9f6fbf13548358715e47c5e69576aa5dd8b0ad5048fdc967f"},
|
||||
{file = "rjsmin-1.2.3-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:07c4f1efbbbcd16a645ada1f012595f3eb3e5d5933395effb6104d3731de2d96"},
|
||||
{file = "rjsmin-1.2.3-cp39-cp39-manylinux1_i686.whl", hash = "sha256:37a73f6ff49dd8c662399575a249a2a028d098c1fa940c6e88aa9082beb44eca"},
|
||||
{file = "rjsmin-1.2.3-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:73357ec91465cf69173d637ccde7b46ed3a8001161c9650325fa305a486e89a3"},
|
||||
{file = "rjsmin-1.2.3-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:236c792fbe18c3b18d4e0ad5ff1b1145f1fbe02126aee9f21bca757b00b63b7e"},
|
||||
{file = "rjsmin-1.2.3-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:3a630a3131a4e63e10665a0ea7cfe0784a3e1e1c854edf79a8ac0654e3756648"},
|
||||
{file = "rjsmin-1.2.3-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:a1c98f60ca57adbae023cf989eec91d052f0601df63ddc52a0a48303b21a7f9e"},
|
||||
{file = "rjsmin-1.2.3-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:32a0174efac83ac72a681edcb9acf5e1c87c5b6aae65ed3424468b5945a90f9d"},
|
||||
{file = "rjsmin-1.2.3.tar.gz", hash = "sha256:1388b52493a4c04fbc970a2d757c301fa05a3c37640314c2ce9dfc8d8a730cc6"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@ -2679,4 +2691,4 @@ filelock = ">=3.4"
|
||||
[metadata]
|
||||
lock-version = "2.0"
|
||||
python-versions = "^3.12"
|
||||
content-hash = "730b020b335ea67342069c40591f6959f1e5f01eef40806c95777def2f39eb37"
|
||||
content-hash = "cb47f6409e629d8369a19d82f44a57dbe9414c79e6e72bd88a6bcb34d78f0bc0"
|
||||
|
@ -65,7 +65,7 @@ optional = true
|
||||
# deps used for development purposes, but unneeded in prod
|
||||
django-debug-toolbar = "^4.4.6"
|
||||
ipython = "^8.26.0"
|
||||
pre-commit = "^3.8.0"
|
||||
pre-commit = "^4.0.1"
|
||||
ruff = "^0.6.9" # Version used in pipeline is controlled by pre-commit hooks in .pre-commit.config.yaml
|
||||
djhtml = "^3.0.6"
|
||||
faker = "^30.3.0"
|
||||
@ -77,14 +77,14 @@ freezegun = "^1.5.1" # used to test time-dependent code
|
||||
pytest = "^8.3.2"
|
||||
pytest-cov = "^5.0.0"
|
||||
pytest-django = "^4.9.0"
|
||||
model-bakery = "^1.19.5"
|
||||
model-bakery = "^1.20.0"
|
||||
|
||||
[tool.poetry.group.docs.dependencies]
|
||||
# deps used to work on the documentation
|
||||
mkdocs = "^1.6.1"
|
||||
mkdocs-material = "^9.5.39"
|
||||
mkdocstrings = "^0.26.1"
|
||||
mkdocstrings-python = "^1.11.1"
|
||||
mkdocs-material = "^9.5.40"
|
||||
mkdocstrings = "^0.26.2"
|
||||
mkdocstrings-python = "^1.12.0"
|
||||
mkdocs-include-markdown-plugin = "^6.2.2"
|
||||
|
||||
[tool.poetry.group.docs]
|
||||
@ -101,17 +101,30 @@ select = [
|
||||
"A", # shadowing of Python builtins
|
||||
"B",
|
||||
"C4", # use comprehensions when possible
|
||||
"I", # isort
|
||||
"DJ", # django-specific rules,
|
||||
"F401", # unused import
|
||||
"E", # pycodestyle (https://docs.astral.sh/ruff/rules/#pycodestyle-e-w)
|
||||
"ERA", # commented code
|
||||
"F", # pyflakes (https://docs.astral.sh/ruff/rules/#pyflakes-f)
|
||||
"FBT", # boolean trap
|
||||
"FLY", # f-string instead of str.join
|
||||
"FURB", # https://docs.astral.sh/ruff/rules/#refurb-furb
|
||||
"I", # isort
|
||||
"INT", # gettext
|
||||
"PERF", # performance
|
||||
"PLW", # pylint warnings (https://docs.astral.sh/ruff/rules/#pylint-pl)
|
||||
"RUF", # Ruff specific rules
|
||||
"SIM", # simplify (https://docs.astral.sh/ruff/rules/#flake8-simplify-sim)
|
||||
"T100", # breakpoint()
|
||||
"T2", # print statements
|
||||
"TCH", # type-checking block
|
||||
"UP008", # Use super() instead of super(__class__, self)
|
||||
"UP009", # utf-8 encoding declaration is unnecessary
|
||||
"T2", # print statements
|
||||
]
|
||||
|
||||
ignore = [
|
||||
"DJ001", # null=True in CharField/TextField. this one would require a migration
|
||||
"E501", # line too long. The rule is too harsh, and the formatter deals with it in most cases
|
||||
"RUF012" # mutable class attributes. This rule doesn't integrate well with django
|
||||
]
|
||||
|
||||
[tool.ruff.lint.pydocstyle]
|
||||
|
@ -44,8 +44,8 @@ class Command(BaseCommand):
|
||||
exit(1)
|
||||
|
||||
confirm = input(
|
||||
"User selected: %s\nDo you really want to delete all message from this user ? [y/N] "
|
||||
% (user,)
|
||||
"User selected: %s\nDo you really want "
|
||||
"to delete all message from this user ? [y/N] " % (user,)
|
||||
)
|
||||
|
||||
if not confirm.lower().startswith("y"):
|
||||
|
@ -66,11 +66,11 @@ class TestMergeUser(TestCase):
|
||||
self.to_keep = User.objects.get(pk=self.to_keep.pk)
|
||||
# fields of to_delete should be assigned to to_keep
|
||||
# if they were not set beforehand
|
||||
assert "Biggus" == self.to_keep.first_name
|
||||
assert "Dickus" == self.to_keep.last_name
|
||||
assert "B'ian" == self.to_keep.nick_name
|
||||
assert "Jerusalem" == self.to_keep.address
|
||||
assert "Rome" == self.to_keep.parent_address
|
||||
assert self.to_keep.first_name == "Biggus"
|
||||
assert self.to_keep.last_name == "Dickus"
|
||||
assert self.to_keep.nick_name == "B'ian"
|
||||
assert self.to_keep.address == "Jerusalem"
|
||||
assert self.to_keep.parent_address == "Rome"
|
||||
assert self.to_keep.groups.count() == 3
|
||||
groups = sorted(self.to_keep.groups.all(), key=lambda i: i.id)
|
||||
expected = sorted([subscribers, mde_admin, sas_admin], key=lambda i: i.id)
|
||||
|
@ -24,7 +24,11 @@
|
||||
|
||||
from django.urls import path
|
||||
|
||||
from rootplace.views import *
|
||||
from rootplace.views import (
|
||||
DeleteAllForumUserMessagesView,
|
||||
MergeUsersView,
|
||||
OperationLogListView,
|
||||
)
|
||||
|
||||
urlpatterns = [
|
||||
path("merge/", MergeUsersView.as_view(), name="merge"),
|
||||
|
@ -48,7 +48,8 @@ def __merge_subscriptions(u1: User, u2: User):
|
||||
Some examples :
|
||||
- if u1 is not subscribed, his subscription end date become the one of u2
|
||||
- if u1 is subscribed but not u2, nothing happen
|
||||
- if u1 is subscribed for, let's say, 2 remaining months and u2 is subscribed for 3 remaining months,
|
||||
- if u1 is subscribed for, let's say,
|
||||
2 remaining months and u2 is subscribed for 3 remaining months,
|
||||
he shall then be subscribed for 5 months
|
||||
"""
|
||||
last_subscription = (
|
||||
|
15
sas/admin.py
15
sas/admin.py
@ -15,7 +15,7 @@
|
||||
|
||||
from django.contrib import admin
|
||||
|
||||
from sas.models import Album, PeoplePictureRelation, Picture
|
||||
from sas.models import Album, PeoplePictureRelation, Picture, PictureModerationRequest
|
||||
|
||||
|
||||
@admin.register(Picture)
|
||||
@ -31,4 +31,15 @@ class PeoplePictureRelationAdmin(admin.ModelAdmin):
|
||||
autocomplete_fields = ("picture", "user")
|
||||
|
||||
|
||||
admin.site.register(Album)
|
||||
@admin.register(Album)
|
||||
class AlbumAdmin(admin.ModelAdmin):
|
||||
list_display = ("name", "parent", "date", "owner", "is_moderated")
|
||||
search_fields = ("name",)
|
||||
autocomplete_fields = ("owner", "parent", "edit_groups", "view_groups")
|
||||
|
||||
|
||||
@admin.register(PictureModerationRequest)
|
||||
class PictureModerationRequestAdmin(admin.ModelAdmin):
|
||||
list_display = ("author", "picture", "created_at")
|
||||
search_fields = ("author", "picture")
|
||||
autocomplete_fields = ("author", "picture")
|
||||
|
32
sas/api.py
32
sas/api.py
@ -9,10 +9,17 @@ from ninja_extra.permissions import IsAuthenticated
|
||||
from ninja_extra.schemas import PaginatedResponseSchema
|
||||
from pydantic import NonNegativeInt
|
||||
|
||||
from core.api_permissions import CanView, IsOwner
|
||||
from core.api_permissions import CanView, IsInGroup, IsRoot
|
||||
from core.models import Notification, User
|
||||
from sas.models import PeoplePictureRelation, Picture
|
||||
from sas.schemas import IdentifiedUserSchema, PictureFilterSchema, PictureSchema
|
||||
from sas.schemas import (
|
||||
IdentifiedUserSchema,
|
||||
ModerationRequestSchema,
|
||||
PictureFilterSchema,
|
||||
PictureSchema,
|
||||
)
|
||||
|
||||
IsSasAdmin = IsRoot | IsInGroup(settings.SITH_GROUP_SAS_ADMIN_ID)
|
||||
|
||||
|
||||
@api_controller("/sas/picture")
|
||||
@ -85,18 +92,35 @@ class PicturesController(ControllerBase):
|
||||
},
|
||||
)
|
||||
|
||||
@route.delete("/{picture_id}", permissions=[IsOwner])
|
||||
@route.delete("/{picture_id}", permissions=[IsSasAdmin])
|
||||
def delete_picture(self, picture_id: int):
|
||||
self.get_object_or_exception(Picture, pk=picture_id).delete()
|
||||
|
||||
@route.patch("/{picture_id}/moderate", permissions=[IsOwner])
|
||||
@route.patch(
|
||||
"/{picture_id}/moderation",
|
||||
permissions=[IsSasAdmin],
|
||||
url_name="picture_moderate",
|
||||
)
|
||||
def moderate_picture(self, picture_id: int):
|
||||
"""Mark a picture as moderated and remove its pending moderation requests."""
|
||||
picture = self.get_object_or_exception(Picture, pk=picture_id)
|
||||
picture.moderation_requests.all().delete()
|
||||
picture.is_moderated = True
|
||||
picture.moderator = self.context.request.user
|
||||
picture.asked_for_removal = False
|
||||
picture.save()
|
||||
|
||||
@route.get(
|
||||
"/{picture_id}/moderation",
|
||||
permissions=[IsSasAdmin],
|
||||
response=list[ModerationRequestSchema],
|
||||
url_name="picture_moderation_requests",
|
||||
)
|
||||
def fetch_moderation_requests(self, picture_id: int):
|
||||
"""Fetch the moderation requests issued on this picture."""
|
||||
picture = self.get_object_or_exception(Picture, pk=picture_id)
|
||||
return picture.moderation_requests.select_related("author")
|
||||
|
||||
|
||||
@api_controller("/sas/relation", tags="User identification on SAS pictures")
|
||||
class UsersIdentifiedController(ControllerBase):
|
||||
|
124
sas/forms.py
Normal file
124
sas/forms.py
Normal 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)
|
68
sas/migrations/0004_picturemoderationrequest_and_more.py
Normal file
68
sas/migrations/0004_picturemoderationrequest_and_more.py
Normal 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"
|
||||
),
|
||||
),
|
||||
]
|
@ -15,6 +15,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
from io import BytesIO
|
||||
from pathlib import Path
|
||||
from typing import ClassVar, Self
|
||||
@ -108,10 +109,8 @@ class Picture(SasFile):
|
||||
|
||||
def generate_thumbnails(self, *, overwrite=False):
|
||||
im = Image.open(BytesIO(self.file.read()))
|
||||
try:
|
||||
with contextlib.suppress(Exception):
|
||||
im = exif_auto_rotate(im)
|
||||
except:
|
||||
pass
|
||||
# convert the compressed image and the thumbnail into webp
|
||||
# The original image keeps its original type, because it's not
|
||||
# meant to be shown on the website, but rather to keep the real image
|
||||
@ -273,16 +272,12 @@ class PeoplePictureRelation(models.Model):
|
||||
User,
|
||||
verbose_name=_("user"),
|
||||
related_name="pictures",
|
||||
null=False,
|
||||
blank=False,
|
||||
on_delete=models.CASCADE,
|
||||
)
|
||||
picture = models.ForeignKey(
|
||||
Picture,
|
||||
verbose_name=_("picture"),
|
||||
related_name="people",
|
||||
null=False,
|
||||
blank=False,
|
||||
on_delete=models.CASCADE,
|
||||
)
|
||||
|
||||
@ -290,4 +285,39 @@ class PeoplePictureRelation(models.Model):
|
||||
unique_together = ["user", "picture"]
|
||||
|
||||
def __str__(self):
|
||||
return self.user.get_display_name() + " - " + str(self.picture)
|
||||
return f"Moderation request by {self.user.get_short_name()} - {self.picture}"
|
||||
|
||||
|
||||
class PictureModerationRequest(models.Model):
|
||||
"""A request to remove a Picture from the SAS."""
|
||||
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
author = models.ForeignKey(
|
||||
User,
|
||||
verbose_name=_("Author"),
|
||||
related_name="moderation_requests",
|
||||
on_delete=models.CASCADE,
|
||||
)
|
||||
picture = models.ForeignKey(
|
||||
Picture,
|
||||
verbose_name=_("Picture"),
|
||||
related_name="moderation_requests",
|
||||
on_delete=models.CASCADE,
|
||||
)
|
||||
reason = models.TextField(
|
||||
verbose_name=_("Reason"),
|
||||
default="",
|
||||
help_text=_("Why do you want this image to be removed ?"),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("Picture moderation request")
|
||||
verbose_name_plural = _("Picture moderation requests")
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=["author", "picture"], name="one_request_per_user_per_picture"
|
||||
)
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"Moderation request by {self.author.get_short_name()}"
|
||||
|
@ -4,8 +4,8 @@ from django.urls import reverse
|
||||
from ninja import FilterSchema, ModelSchema, Schema
|
||||
from pydantic import Field, NonNegativeInt
|
||||
|
||||
from core.schemas import UserProfileSchema
|
||||
from sas.models import Picture
|
||||
from core.schemas import SimpleUserSchema, UserProfileSchema
|
||||
from sas.models import Picture, PictureModerationRequest
|
||||
|
||||
|
||||
class PictureFilterSchema(FilterSchema):
|
||||
@ -52,3 +52,11 @@ class PictureRelationCreationSchema(Schema):
|
||||
class IdentifiedUserSchema(Schema):
|
||||
id: int
|
||||
user: UserProfileSchema
|
||||
|
||||
|
||||
class ModerationRequestSchema(ModelSchema):
|
||||
author: SimpleUserSchema
|
||||
|
||||
class Meta:
|
||||
model = PictureModerationRequest
|
||||
fields = ["id", "created_at", "reason"]
|
||||
|
@ -80,6 +80,7 @@
|
||||
object-fit: cover;
|
||||
opacity: 70%;
|
||||
}
|
||||
|
||||
.overlay {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
@ -204,6 +205,7 @@
|
||||
right: 0;
|
||||
}
|
||||
}
|
||||
|
||||
input {
|
||||
min-width: 100%;
|
||||
max-width: 100%;
|
||||
|
@ -1,24 +1,20 @@
|
||||
import { makeUrl, paginated } from "#core:utils/api";
|
||||
import { paginated } from "#core:utils/api";
|
||||
import { exportToHtml } from "#core:utils/globals";
|
||||
import { History } from "#core:utils/history";
|
||||
import {
|
||||
type AjaxResponse,
|
||||
type RemoteResult,
|
||||
remoteDataSource,
|
||||
sithSelect2,
|
||||
} from "#core:utils/select2";
|
||||
import type TomSelect from "tom-select";
|
||||
import {
|
||||
type IdentifiedUserSchema,
|
||||
type PictureSchema,
|
||||
type PicturesFetchIdentificationsResponse,
|
||||
type PicturesFetchModerationRequestsResponse,
|
||||
type PicturesFetchPicturesData,
|
||||
type UserProfileSchema,
|
||||
picturesDeletePicture,
|
||||
picturesFetchIdentifications,
|
||||
picturesFetchModerationRequests,
|
||||
picturesFetchPictures,
|
||||
picturesIdentifyUsers,
|
||||
picturesModeratePicture,
|
||||
userSearchUsers,
|
||||
usersidentifiedDeleteRelation,
|
||||
} from "#openapi";
|
||||
|
||||
@ -27,18 +23,20 @@ import {
|
||||
* able to prefetch its data.
|
||||
*/
|
||||
class PictureWithIdentifications {
|
||||
identifications: PicturesFetchIdentificationsResponse | null = null;
|
||||
identifications: PicturesFetchIdentificationsResponse = null;
|
||||
imageLoading = false;
|
||||
identificationsLoading = false;
|
||||
moderationLoading = false;
|
||||
id: number;
|
||||
// biome-ignore lint/style/useNamingConvention: api is in snake_case
|
||||
compressed_url: string;
|
||||
moderationRequests: PicturesFetchModerationRequestsResponse = null;
|
||||
|
||||
constructor(picture: PictureSchema) {
|
||||
Object.assign(this, picture);
|
||||
}
|
||||
|
||||
static fromPicture(picture: PictureSchema) {
|
||||
static fromPicture(picture: PictureSchema): PictureWithIdentifications {
|
||||
return new PictureWithIdentifications(picture);
|
||||
}
|
||||
|
||||
@ -46,7 +44,7 @@ class PictureWithIdentifications {
|
||||
* If not already done, fetch the users identified on this picture and
|
||||
* populate the identifications field
|
||||
*/
|
||||
async loadIdentifications(options?: { forceReload: boolean }) {
|
||||
async loadIdentifications(options?: { forceReload: boolean }): Promise<void> {
|
||||
if (this.identificationsLoading) {
|
||||
return; // The users are already being fetched.
|
||||
}
|
||||
@ -65,11 +63,29 @@ class PictureWithIdentifications {
|
||||
this.identificationsLoading = false;
|
||||
}
|
||||
|
||||
async loadModeration(options?: { forceReload: boolean }): Promise<void> {
|
||||
if (this.moderationLoading) {
|
||||
return; // The moderation requests are already being fetched.
|
||||
}
|
||||
if (!!this.moderationRequests && !options?.forceReload) {
|
||||
// The moderation requests are already fetched
|
||||
// and the user does not want to force the reload
|
||||
return;
|
||||
}
|
||||
this.moderationLoading = true;
|
||||
this.moderationRequests = (
|
||||
await picturesFetchModerationRequests({
|
||||
// biome-ignore lint/style/useNamingConvention: api is in snake_case
|
||||
path: { picture_id: this.id },
|
||||
})
|
||||
).data;
|
||||
this.moderationLoading = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Preload the photo and the identifications
|
||||
* @return {Promise<void>}
|
||||
*/
|
||||
async preload() {
|
||||
async preload(): Promise<void> {
|
||||
const img = new Image();
|
||||
img.src = this.compressed_url;
|
||||
if (!img.complete) {
|
||||
@ -87,12 +103,12 @@ interface ViewerConfig {
|
||||
userId: number;
|
||||
/** Url of the current album */
|
||||
albumUrl: string;
|
||||
/** Id of the album to displlay */
|
||||
/** Id of the album to display */
|
||||
albumId: number;
|
||||
/** id of the first picture to load on the page */
|
||||
firstPictureId: number;
|
||||
/** if the user is sas admin */
|
||||
userIsSasAdmin: number;
|
||||
userIsSasAdmin: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -103,9 +119,8 @@ exportToHtml("loadViewer", (config: ViewerConfig) => {
|
||||
Alpine.data("picture_viewer", () => ({
|
||||
/**
|
||||
* All the pictures that can be displayed on this picture viewer
|
||||
* @type PictureWithIdentifications[]
|
||||
**/
|
||||
pictures: [],
|
||||
pictures: [] as PictureWithIdentifications[],
|
||||
/**
|
||||
* The currently displayed picture
|
||||
* Default dummy data are pre-loaded to avoid javascript error
|
||||
@ -131,14 +146,12 @@ exportToHtml("loadViewer", (config: ViewerConfig) => {
|
||||
},
|
||||
/**
|
||||
* The picture which will be displayed next if the user press the "next" button
|
||||
* @type ?PictureWithIdentifications
|
||||
**/
|
||||
nextPicture: null,
|
||||
nextPicture: null as PictureWithIdentifications,
|
||||
/**
|
||||
* The picture which will be displayed next if the user press the "previous" button
|
||||
* @type ?PictureWithIdentifications
|
||||
**/
|
||||
previousPicture: null,
|
||||
previousPicture: null as PictureWithIdentifications,
|
||||
/**
|
||||
* The select2 component used to identify users
|
||||
**/
|
||||
@ -148,13 +161,11 @@ exportToHtml("loadViewer", (config: ViewerConfig) => {
|
||||
**/
|
||||
/**
|
||||
* Error message when a moderation operation fails
|
||||
* @type string
|
||||
**/
|
||||
moderationError: "",
|
||||
/**
|
||||
* Method of pushing new url to the browser history
|
||||
* Used by popstate event and always reset to it's default value when used
|
||||
* @type History
|
||||
**/
|
||||
pushstate: History.Push,
|
||||
|
||||
@ -165,20 +176,21 @@ exportToHtml("loadViewer", (config: ViewerConfig) => {
|
||||
query: { album_id: config.albumId },
|
||||
} as PicturesFetchPicturesData)
|
||||
).map(PictureWithIdentifications.fromPicture);
|
||||
this.selector = sithSelect2({
|
||||
element: $(this.$refs.search) as unknown as HTMLElement,
|
||||
dataSource: remoteDataSource(await makeUrl(userSearchUsers), {
|
||||
excluded: () => [
|
||||
this.selector = this.$refs.search;
|
||||
this.selector.filter = (users: UserProfileSchema[]) => {
|
||||
const resp: UserProfileSchema[] = [];
|
||||
const ids = [
|
||||
...(this.currentPicture.identifications || []).map(
|
||||
(i: IdentifiedUserSchema) => i.user.id,
|
||||
),
|
||||
],
|
||||
resultConverter: (obj: AjaxResponse) => {
|
||||
return { ...obj, text: (obj as UserProfileSchema).display_name };
|
||||
},
|
||||
}),
|
||||
pictureGetter: (user: RemoteResult) => user.profile_pict,
|
||||
});
|
||||
];
|
||||
for (const user of users) {
|
||||
if (!ids.includes(user.id)) {
|
||||
resp.push(user);
|
||||
}
|
||||
}
|
||||
return resp;
|
||||
};
|
||||
this.currentPicture = this.pictures.find(
|
||||
(i: PictureSchema) => i.id === config.firstPictureId,
|
||||
);
|
||||
@ -213,7 +225,7 @@ exportToHtml("loadViewer", (config: ViewerConfig) => {
|
||||
* and the previous picture, the next picture and
|
||||
* the list of identified users are updated.
|
||||
*/
|
||||
async updatePicture() {
|
||||
async updatePicture(): Promise<void> {
|
||||
const updateArgs = {
|
||||
data: { sasPictureId: this.currentPicture.id },
|
||||
unused: "",
|
||||
@ -231,16 +243,23 @@ exportToHtml("loadViewer", (config: ViewerConfig) => {
|
||||
}
|
||||
|
||||
this.moderationError = "";
|
||||
const index = this.pictures.indexOf(this.currentPicture);
|
||||
const index: number = this.pictures.indexOf(this.currentPicture);
|
||||
this.previousPicture = this.pictures[index - 1] || null;
|
||||
this.nextPicture = this.pictures[index + 1] || null;
|
||||
await this.currentPicture.loadIdentifications();
|
||||
this.$refs.mainPicture?.addEventListener("load", () => {
|
||||
// once the current picture is loaded,
|
||||
// start preloading the next and previous pictures
|
||||
this.nextPicture?.preload();
|
||||
this.previousPicture?.preload();
|
||||
});
|
||||
if (this.currentPicture.asked_for_removal && config.userIsSasAdmin) {
|
||||
await Promise.all([
|
||||
this.currentPicture.loadIdentifications(),
|
||||
this.currentPicture.loadModeration(),
|
||||
]);
|
||||
} else {
|
||||
await this.currentPicture.loadIdentifications();
|
||||
}
|
||||
},
|
||||
|
||||
async moderatePicture() {
|
||||
@ -253,7 +272,7 @@ exportToHtml("loadViewer", (config: ViewerConfig) => {
|
||||
return;
|
||||
}
|
||||
this.currentPicture.is_moderated = true;
|
||||
this.currentPicture.askedForRemoval = false;
|
||||
this.currentPicture.asked_for_removal = false;
|
||||
},
|
||||
|
||||
async deletePicture() {
|
||||
@ -277,33 +296,34 @@ exportToHtml("loadViewer", (config: ViewerConfig) => {
|
||||
/**
|
||||
* Send the identification request and update the list of identified users.
|
||||
*/
|
||||
async submitIdentification() {
|
||||
async submitIdentification(): Promise<void> {
|
||||
const widget: TomSelect = this.selector.widget;
|
||||
await picturesIdentifyUsers({
|
||||
path: {
|
||||
// biome-ignore lint/style/useNamingConvention: api is in snake_case
|
||||
picture_id: this.currentPicture.id,
|
||||
},
|
||||
body: this.selector.val().map((i: string) => Number.parseInt(i)),
|
||||
body: widget.items.map((i: string) => Number.parseInt(i)),
|
||||
});
|
||||
// refresh the identified users list
|
||||
await this.currentPicture.loadIdentifications({ forceReload: true });
|
||||
this.selector.empty().trigger("change");
|
||||
|
||||
// Clear selection and cache of retrieved user so they can be filtered again
|
||||
widget.clear(false);
|
||||
widget.clearOptions();
|
||||
},
|
||||
|
||||
/**
|
||||
* Check if an identification can be removed by the currently logged user
|
||||
* @param {PictureIdentification} identification
|
||||
* @return {boolean}
|
||||
*/
|
||||
canBeRemoved(identification: IdentifiedUserSchema) {
|
||||
canBeRemoved(identification: IdentifiedUserSchema): boolean {
|
||||
return config.userIsSasAdmin || identification.user.id === config.userId;
|
||||
},
|
||||
|
||||
/**
|
||||
* Untag a user from the current picture
|
||||
* @param {PictureIdentification} identification
|
||||
*/
|
||||
async removeIdentification(identification: IdentifiedUserSchema) {
|
||||
async removeIdentification(identification: IdentifiedUserSchema): Promise<void> {
|
||||
const res = await usersidentifiedDeleteRelation({
|
||||
// biome-ignore lint/style/useNamingConvention: api is in snake_case
|
||||
path: { relation_id: identification.id },
|
||||
|
28
sas/templates/sas/ask_picture_removal.jinja
Normal file
28
sas/templates/sas/ask_picture_removal.jinja
Normal 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 %}
|
@ -1,11 +1,12 @@
|
||||
{% extends "core/base.jinja" %}
|
||||
|
||||
{%- block additional_css -%}
|
||||
<link rel="stylesheet" href="{{ static('webpack/ajax-select-index.css') }}">
|
||||
<link rel="stylesheet" href="{{ static('sas/css/picture.scss') }}">
|
||||
<link rel="stylesheet" href="{{ static('webpack/sas/viewer-index.css') }}" defer>
|
||||
{%- endblock -%}
|
||||
|
||||
{%- block additional_js -%}
|
||||
<script defer src="{{ static('webpack/ajax-select-index.ts') }}"></script>
|
||||
<script defer src="{{ static("webpack/sas/viewer-index.ts") }}"></script>
|
||||
{%- endblock -%}
|
||||
|
||||
@ -30,10 +31,10 @@
|
||||
<br>
|
||||
|
||||
<template x-if="!currentPicture.is_moderated">
|
||||
<div class="alert alert-red">
|
||||
<div class="alert alert-red" @click="console.log(currentPicture)">
|
||||
<div class="alert-main">
|
||||
<template x-if="currentPicture.askedForRemoval">
|
||||
<span class="important">{% trans %}Asked for removal{% endtrans %}</span>
|
||||
<template x-if="currentPicture.asked_for_removal">
|
||||
<h3 class="alert-title">{% trans %}Asked for removal{% endtrans %}</h3>
|
||||
</template>
|
||||
<p>
|
||||
{% trans trimmed %}
|
||||
@ -41,16 +42,33 @@
|
||||
It will be hidden to other users until it has been moderated.
|
||||
{% endtrans %}
|
||||
</p>
|
||||
<template x-if="currentPicture.asked_for_removal">
|
||||
<div>
|
||||
<h5 @click="console.log(currentPicture.moderationRequests)">
|
||||
{% trans %}The following issues have been raised:{% endtrans %}
|
||||
</h5>
|
||||
<template x-for="req in (currentPicture.moderationRequests ?? [])" :key="req.id">
|
||||
<div>
|
||||
<h6
|
||||
x-text="`${req.author.first_name} ${req.author.last_name}`"
|
||||
></h6>
|
||||
<i x-text="Intl.DateTimeFormat(
|
||||
'{{ LANGUAGE_CODE }}',
|
||||
{dateStyle: 'long', timeStyle: 'short'}
|
||||
).format(new Date(req.created_at))"></i>
|
||||
<blockquote x-text="`> ${req.reason}`"></blockquote>
|
||||
</div>
|
||||
<div>
|
||||
<div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<div class="alert-aside">
|
||||
<button class="btn btn-blue" @click="moderatePicture()">
|
||||
{% trans %}Moderate{% endtrans %}
|
||||
</button>
|
||||
<button class="btn btn-red" @click.prevent="deletePicture()">
|
||||
{% trans %}Delete{% endtrans %}
|
||||
</button>
|
||||
</div>
|
||||
<p x-show="!!moderationError" x-text="moderationError"></p>
|
||||
</div>
|
||||
</div>
|
||||
@ -58,7 +76,6 @@
|
||||
|
||||
<div class="container" id="pict">
|
||||
<div class="main">
|
||||
|
||||
<div class="photo" :aria-busy="currentPicture.imageLoading">
|
||||
<img
|
||||
:src="currentPicture.compressed_url"
|
||||
@ -96,7 +113,9 @@
|
||||
{% trans %}HD version{% endtrans %}
|
||||
</a>
|
||||
<br>
|
||||
<a class="text danger" href="?ask_removal">{% trans %}Ask for removal{% endtrans %}</a>
|
||||
<a class="text danger" :href="`/sas/picture/${currentPicture.id}/report`">
|
||||
{% trans %}Ask for removal{% endtrans %}
|
||||
</a>
|
||||
</div>
|
||||
<div class="buttons">
|
||||
<a class="button" :href="`/sas/picture/${currentPicture.id}/edit/`"><i class="fa-regular fa-pen-to-square edit-action"></i></a>
|
||||
@ -138,7 +157,12 @@
|
||||
<h5>{% trans %}People{% endtrans %}</h5>
|
||||
{% if user.was_subscribed %}
|
||||
<form @submit.prevent="submitIdentification" x-show="!!selector">
|
||||
<select x-ref="search" multiple="multiple"></select>
|
||||
<ajax-select
|
||||
x-ref="search"
|
||||
multiple
|
||||
data-delay="300"
|
||||
data-placeholder="{%- trans -%}Identify users on pictures{%- endtrans -%}"
|
||||
></ajax-select>
|
||||
<input type="submit" value="{% trans %}Go{% endtrans %}"/>
|
||||
</form>
|
||||
{% endif %}
|
||||
|
@ -3,35 +3,45 @@ from django.db import transaction
|
||||
from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
from model_bakery import baker
|
||||
from model_bakery.recipe import Recipe
|
||||
from pytest_django.asserts import assertNumQueries
|
||||
|
||||
from core.baker_recipes import old_subscriber_user, subscriber_user
|
||||
from core.models import RealGroup, User
|
||||
from core.models import RealGroup, SithFile, User
|
||||
from sas.baker_recipes import picture_recipe
|
||||
from sas.models import Album, PeoplePictureRelation, Picture
|
||||
from sas.models import Album, PeoplePictureRelation, Picture, PictureModerationRequest
|
||||
|
||||
|
||||
class TestSas(TestCase):
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
Picture.objects.all().delete()
|
||||
sas = SithFile.objects.get(pk=settings.SITH_SAS_ROOT_DIR_ID)
|
||||
|
||||
Picture.objects.exclude(id=sas.id).delete()
|
||||
owner = User.objects.get(username="root")
|
||||
|
||||
cls.user_a = old_subscriber_user.make()
|
||||
cls.user_b, cls.user_c = subscriber_user.make(_quantity=2)
|
||||
|
||||
picture = picture_recipe.extend(owner=owner)
|
||||
cls.album_a = baker.make(Album, is_in_sas=True)
|
||||
cls.album_b = baker.make(Album, is_in_sas=True)
|
||||
cls.album_a = baker.make(Album, is_in_sas=True, parent=sas)
|
||||
cls.album_b = baker.make(Album, is_in_sas=True, parent=sas)
|
||||
relation_recipe = Recipe(PeoplePictureRelation)
|
||||
relations = []
|
||||
for album in cls.album_a, cls.album_b:
|
||||
pictures = picture.make(parent=album, _quantity=5, _bulk_create=True)
|
||||
baker.make(PeoplePictureRelation, picture=pictures[1], user=cls.user_a)
|
||||
baker.make(PeoplePictureRelation, picture=pictures[2], user=cls.user_a)
|
||||
baker.make(PeoplePictureRelation, picture=pictures[2], user=cls.user_b)
|
||||
baker.make(PeoplePictureRelation, picture=pictures[3], user=cls.user_b)
|
||||
baker.make(PeoplePictureRelation, picture=pictures[4], user=cls.user_a)
|
||||
baker.make(PeoplePictureRelation, picture=pictures[4], user=cls.user_b)
|
||||
baker.make(PeoplePictureRelation, picture=pictures[4], user=cls.user_c)
|
||||
relations.extend(
|
||||
[
|
||||
relation_recipe.prepare(picture=pictures[1], user=cls.user_a),
|
||||
relation_recipe.prepare(picture=pictures[2], user=cls.user_a),
|
||||
relation_recipe.prepare(picture=pictures[2], user=cls.user_b),
|
||||
relation_recipe.prepare(picture=pictures[3], user=cls.user_b),
|
||||
relation_recipe.prepare(picture=pictures[4], user=cls.user_a),
|
||||
relation_recipe.prepare(picture=pictures[4], user=cls.user_b),
|
||||
relation_recipe.prepare(picture=pictures[4], user=cls.user_c),
|
||||
]
|
||||
)
|
||||
PeoplePictureRelation.objects.bulk_create(relations)
|
||||
|
||||
|
||||
class TestPictureSearch(TestSas):
|
||||
@ -170,3 +180,49 @@ class TestPictureRelation(TestSas):
|
||||
res = self.client.delete(f"/api/sas/relation/{relation.id}")
|
||||
assert res.status_code == 404
|
||||
assert PeoplePictureRelation.objects.count() == relation_count
|
||||
|
||||
|
||||
class TestPictureModeration(TestSas):
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
super().setUpTestData()
|
||||
cls.sas_admin = baker.make(
|
||||
User, groups=[RealGroup.objects.get(pk=settings.SITH_GROUP_SAS_ADMIN_ID)]
|
||||
)
|
||||
cls.picture = Picture.objects.filter(parent=cls.album_a)[0]
|
||||
cls.picture.is_moderated = False
|
||||
cls.picture.asked_for_removal = True
|
||||
cls.picture.save()
|
||||
cls.url = reverse("api:picture_moderate", kwargs={"picture_id": cls.picture.id})
|
||||
baker.make(PictureModerationRequest, picture=cls.picture, author=cls.user_a)
|
||||
|
||||
def test_moderation_route_forbidden(self):
|
||||
"""Test that basic users (even if owner) cannot moderate a picture."""
|
||||
self.picture.owner = self.user_b
|
||||
|
||||
for user in baker.make(User), subscriber_user.make(), self.user_b:
|
||||
self.client.force_login(user)
|
||||
res = self.client.patch(self.url)
|
||||
assert res.status_code == 403
|
||||
|
||||
def test_moderation_route_authorized(self):
|
||||
"""Test that sas admins can moderate a picture."""
|
||||
self.client.force_login(self.sas_admin)
|
||||
res = self.client.patch(self.url)
|
||||
assert res.status_code == 200
|
||||
self.picture.refresh_from_db()
|
||||
assert self.picture.is_moderated
|
||||
assert not self.picture.asked_for_removal
|
||||
assert not self.picture.moderation_requests.exists()
|
||||
|
||||
def test_get_moderation_requests(self):
|
||||
"""Test that fetching moderation requests work."""
|
||||
|
||||
url = reverse(
|
||||
"api:picture_moderation_requests", kwargs={"picture_id": self.picture.id}
|
||||
)
|
||||
self.client.force_login(self.sas_admin)
|
||||
res = self.client.get(url)
|
||||
assert res.status_code == 200
|
||||
assert len(res.json()) == 1
|
||||
assert res.json()[0]["author"]["id"] == self.user_a.id
|
||||
|
@ -20,7 +20,7 @@ from django.core.cache import cache
|
||||
from django.test import Client, TestCase
|
||||
from django.urls import reverse
|
||||
from model_bakery import baker
|
||||
from pytest_django.asserts import assertRedirects
|
||||
from pytest_django.asserts import assertInHTML, assertRedirects
|
||||
|
||||
from core.baker_recipes import old_subscriber_user, subscriber_user
|
||||
from core.models import RealGroup, User
|
||||
@ -70,7 +70,9 @@ def test_album_access_non_subscriber(client: Client):
|
||||
class TestSasModeration(TestCase):
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
album = baker.make(Album, parent_id=settings.SITH_SAS_ROOT_DIR_ID)
|
||||
album = baker.make(
|
||||
Album, parent_id=settings.SITH_SAS_ROOT_DIR_ID, is_moderated=True
|
||||
)
|
||||
cls.pictures = picture_recipe.make(
|
||||
parent=album, _quantity=10, _bulk_create=True
|
||||
)
|
||||
@ -82,6 +84,9 @@ class TestSasModeration(TestCase):
|
||||
)
|
||||
cls.simple_user = subscriber_user.make()
|
||||
|
||||
def setUp(self):
|
||||
cache.clear()
|
||||
|
||||
def test_moderation_page_sas_admin(self):
|
||||
"""Test that a moderator can see the pictures needing moderation."""
|
||||
self.client.force_login(self.moderator)
|
||||
@ -132,3 +137,37 @@ class TestSasModeration(TestCase):
|
||||
)
|
||||
assert res.status_code == 403
|
||||
assert Picture.objects.filter(pk=self.to_moderate.id).exists()
|
||||
|
||||
def test_request_moderation_form_access(self):
|
||||
"""Test that regular can access the form to ask for moderation."""
|
||||
self.client.force_login(self.simple_user)
|
||||
res = self.client.get(
|
||||
reverse(
|
||||
"sas:picture_ask_removal", kwargs={"picture_id": self.pictures[1].id}
|
||||
),
|
||||
)
|
||||
assert res.status_code == 200
|
||||
|
||||
def test_request_moderation_form_submit(self):
|
||||
"""Test that moderation requests are created."""
|
||||
self.client.force_login(self.simple_user)
|
||||
message = "J'aime pas cette photo (ni la Cocarde)."
|
||||
url = reverse(
|
||||
"sas:picture_ask_removal", kwargs={"picture_id": self.pictures[1].id}
|
||||
)
|
||||
res = self.client.post(url, data={"reason": message})
|
||||
assertRedirects(
|
||||
res, reverse("sas:album", kwargs={"album_id": self.pictures[1].parent_id})
|
||||
)
|
||||
assert self.pictures[1].moderation_requests.count() == 1
|
||||
assert self.pictures[1].moderation_requests.first().reason == message
|
||||
|
||||
# test that the user cannot ask for moderation twice
|
||||
res = self.client.post(url, data={"reason": message})
|
||||
assert res.status_code == 200
|
||||
assert self.pictures[1].moderation_requests.count() == 1
|
||||
assertInHTML(
|
||||
'<ul class="errorlist nonfield"><li>'
|
||||
"Vous avez déjà déposé une demande de retrait pour cette photo.</li></ul>",
|
||||
res.content.decode(),
|
||||
)
|
||||
|
34
sas/urls.py
34
sas/urls.py
@ -15,24 +15,38 @@
|
||||
|
||||
from django.urls import path
|
||||
|
||||
from sas.views import *
|
||||
from sas.views import (
|
||||
AlbumEditView,
|
||||
AlbumUploadView,
|
||||
AlbumView,
|
||||
ModerationView,
|
||||
PictureAskRemovalView,
|
||||
PictureEditView,
|
||||
PictureView,
|
||||
SASMainView,
|
||||
send_album,
|
||||
send_compressed,
|
||||
send_pict,
|
||||
send_thumb,
|
||||
)
|
||||
|
||||
urlpatterns = [
|
||||
path("", SASMainView.as_view(), name="main"),
|
||||
path("moderation/", ModerationView.as_view(), name="moderation"),
|
||||
path("album/<int:album_id>/", AlbumView.as_view(), name="album"),
|
||||
path(
|
||||
"album/<int:album_id>/upload/",
|
||||
AlbumUploadView.as_view(),
|
||||
name="album_upload",
|
||||
"album/<int:album_id>/upload/", AlbumUploadView.as_view(), name="album_upload"
|
||||
),
|
||||
path("album/<int:album_id>/edit/", AlbumEditView.as_view(), name="album_edit"),
|
||||
path("album/<int:album_id>/preview/", send_album, name="album_preview"),
|
||||
path("picture/<int:picture_id>/", PictureView.as_view(), name="picture"),
|
||||
path(
|
||||
"picture/<int:picture_id>/edit/",
|
||||
PictureEditView.as_view(),
|
||||
name="picture_edit",
|
||||
"picture/<int:picture_id>/edit/", PictureEditView.as_view(), name="picture_edit"
|
||||
),
|
||||
path(
|
||||
"picture/<int:picture_id>/report",
|
||||
PictureAskRemovalView.as_view(),
|
||||
name="picture_ask_removal",
|
||||
),
|
||||
path("picture/<int:picture_id>/download/", send_pict, name="download"),
|
||||
path(
|
||||
@ -40,9 +54,5 @@ urlpatterns = [
|
||||
send_compressed,
|
||||
name="download_compressed",
|
||||
),
|
||||
path(
|
||||
"picture/<int:picture_id>/download/thumb/",
|
||||
send_thumb,
|
||||
name="download_thumb",
|
||||
),
|
||||
path("picture/<int:picture_id>/download/thumb/", send_thumb, name="download_thumb"),
|
||||
]
|
||||
|
146
sas/views.py
146
sas/views.py
@ -12,14 +12,12 @@
|
||||
# OR WITHIN THE LOCAL FILE "LICENSE"
|
||||
#
|
||||
#
|
||||
from typing import Any
|
||||
|
||||
from ajax_select import make_ajax_field
|
||||
from ajax_select.fields import AutoCompleteSelectMultipleField
|
||||
from django import forms
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import PermissionDenied
|
||||
from django.http import Http404, HttpResponse
|
||||
from django.shortcuts import get_object_or_404, redirect
|
||||
from django.http import Http404, HttpResponse, HttpResponseRedirect
|
||||
from django.shortcuts import get_object_or_404
|
||||
from django.urls import reverse, reverse_lazy
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.views.generic import DetailView, TemplateView
|
||||
@ -27,71 +25,14 @@ from django.views.generic.edit import FormMixin, FormView, UpdateView
|
||||
|
||||
from core.models import SithFile, User
|
||||
from core.views import CanEditMixin, CanViewMixin
|
||||
from core.views.files import FileView, MultipleImageField, send_file
|
||||
from core.views.forms import SelectDate
|
||||
from sas.models import Album, PeoplePictureRelation, Picture
|
||||
|
||||
|
||||
class SASForm(forms.Form):
|
||||
album_name = forms.CharField(
|
||||
label=_("Add a new album"), max_length=Album.NAME_MAX_LENGTH, required=False
|
||||
)
|
||||
images = MultipleImageField(
|
||||
label=_("Upload images"),
|
||||
required=False,
|
||||
)
|
||||
|
||||
def process(self, parent, owner, files, *, automodere=False):
|
||||
try:
|
||||
if self.cleaned_data["album_name"] != "":
|
||||
album = Album(
|
||||
parent=parent,
|
||||
name=self.cleaned_data["album_name"],
|
||||
owner=owner,
|
||||
is_moderated=automodere,
|
||||
)
|
||||
album.clean()
|
||||
album.save()
|
||||
except Exception as e:
|
||||
self.add_error(
|
||||
None,
|
||||
_("Error creating album %(album)s: %(msg)s")
|
||||
% {"album": self.cleaned_data["album_name"], "msg": repr(e)},
|
||||
)
|
||||
for f in files:
|
||||
new_file = Picture(
|
||||
parent=parent,
|
||||
name=f.name,
|
||||
file=f,
|
||||
owner=owner,
|
||||
mime_type=f.content_type,
|
||||
size=f.size,
|
||||
is_folder=False,
|
||||
is_moderated=automodere,
|
||||
)
|
||||
if automodere:
|
||||
new_file.moderator = owner
|
||||
try:
|
||||
new_file.clean()
|
||||
new_file.generate_thumbnails()
|
||||
new_file.save()
|
||||
except Exception as e:
|
||||
self.add_error(
|
||||
None,
|
||||
_("Error uploading file %(file_name)s: %(msg)s")
|
||||
% {"file_name": f, "msg": repr(e)},
|
||||
)
|
||||
|
||||
|
||||
class RelationForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = PeoplePictureRelation
|
||||
fields = ["picture"]
|
||||
widgets = {"picture": forms.HiddenInput}
|
||||
|
||||
users = AutoCompleteSelectMultipleField(
|
||||
"users", show_help_text=False, help_text="", label=_("Add user"), required=False
|
||||
from core.views.files import FileView, send_file
|
||||
from sas.forms import (
|
||||
AlbumEditForm,
|
||||
PictureEditForm,
|
||||
PictureModerationRequestForm,
|
||||
SASForm,
|
||||
)
|
||||
from sas.models import Album, Picture
|
||||
|
||||
|
||||
class SASMainView(FormView):
|
||||
@ -138,11 +79,6 @@ class PictureView(CanViewMixin, DetailView):
|
||||
self.object.rotate(270)
|
||||
if "rotate_left" in request.GET:
|
||||
self.object.rotate(90)
|
||||
if "ask_removal" in request.GET.keys():
|
||||
self.object.is_moderated = False
|
||||
self.object.asked_for_removal = True
|
||||
self.object.save()
|
||||
return redirect("sas:album", album_id=self.object.parent.id)
|
||||
return super().get(request, *args, **kwargs)
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
@ -179,8 +115,7 @@ class AlbumUploadView(CanViewMixin, DetailView, FormMixin):
|
||||
self.form = self.get_form()
|
||||
parent = SithFile.objects.filter(id=self.object.id).first()
|
||||
files = request.FILES.getlist("images")
|
||||
if request.user.is_authenticated and request.user.is_subscribed:
|
||||
if self.form.is_valid():
|
||||
if request.user.is_subscribed and self.form.is_valid():
|
||||
self.form.process(
|
||||
parent=parent,
|
||||
owner=request.user,
|
||||
@ -210,7 +145,7 @@ class AlbumView(CanViewMixin, DetailView, FormMixin):
|
||||
|
||||
def get(self, request, *args, **kwargs):
|
||||
self.form = self.get_form()
|
||||
if "clipboard" not in request.session.keys():
|
||||
if "clipboard" not in request.session:
|
||||
request.session["clipboard"] = []
|
||||
return super().get(request, *args, **kwargs)
|
||||
|
||||
@ -219,7 +154,7 @@ class AlbumView(CanViewMixin, DetailView, FormMixin):
|
||||
if not self.object.file:
|
||||
self.object.generate_thumbnail()
|
||||
self.form = self.get_form()
|
||||
if "clipboard" not in request.session.keys():
|
||||
if "clipboard" not in request.session:
|
||||
request.session["clipboard"] = []
|
||||
if request.user.can_edit(self.object): # Handle the copy-paste functions
|
||||
FileView.handle_clipboard(request, self.object)
|
||||
@ -293,26 +228,6 @@ class ModerationView(TemplateView):
|
||||
return kwargs
|
||||
|
||||
|
||||
class PictureEditForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = Picture
|
||||
fields = ["name", "parent"]
|
||||
|
||||
parent = make_ajax_field(Picture, "parent", "files", help_text="")
|
||||
|
||||
|
||||
class AlbumEditForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = Album
|
||||
fields = ["name", "date", "file", "parent", "edit_groups"]
|
||||
|
||||
name = forms.CharField(max_length=Album.NAME_MAX_LENGTH, label=_("file name"))
|
||||
date = forms.DateField(label=_("Date"), widget=SelectDate, required=True)
|
||||
parent = make_ajax_field(Album, "parent", "files", help_text="")
|
||||
edit_groups = make_ajax_field(Album, "edit_groups", "groups", help_text="")
|
||||
recursive = forms.BooleanField(label=_("Apply rights recursively"), required=False)
|
||||
|
||||
|
||||
class PictureEditView(CanEditMixin, UpdateView):
|
||||
model = Picture
|
||||
form_class = PictureEditForm
|
||||
@ -320,6 +235,41 @@ class PictureEditView(CanEditMixin, UpdateView):
|
||||
pk_url_kwarg = "picture_id"
|
||||
|
||||
|
||||
class PictureAskRemovalView(CanViewMixin, DetailView, FormView):
|
||||
"""View to allow users to ask pictures to be removed."""
|
||||
|
||||
model = Picture
|
||||
template_name = "sas/ask_picture_removal.jinja"
|
||||
pk_url_kwarg = "picture_id"
|
||||
form_class = PictureModerationRequestForm
|
||||
|
||||
def get_form_kwargs(self) -> dict[str, Any]:
|
||||
"""Add the user and picture to the form kwargs.
|
||||
|
||||
Those are required to create the PictureModerationRequest,
|
||||
and aren't part of the form itself
|
||||
(picture is a path parameter, and user is the request user).
|
||||
"""
|
||||
return super().get_form_kwargs() | {
|
||||
"user": self.request.user,
|
||||
"picture": self.object,
|
||||
}
|
||||
|
||||
def get_success_url(self) -> str:
|
||||
"""Return the URL to the album containing the picture."""
|
||||
album = Album.objects.filter(pk=self.object.parent_id).first()
|
||||
if not album:
|
||||
return reverse("sas:main")
|
||||
return album.get_absolute_url()
|
||||
|
||||
def form_valid(self, form: PictureModerationRequestForm) -> HttpResponseRedirect:
|
||||
form.save()
|
||||
self.object.is_moderated = False
|
||||
self.object.asked_for_removal = True
|
||||
self.object.save()
|
||||
return super().form_valid(form)
|
||||
|
||||
|
||||
class AlbumEditView(CanEditMixin, UpdateView):
|
||||
model = Album
|
||||
form_class = AlbumEditForm
|
||||
|
@ -346,8 +346,8 @@ SITH_LAUNDERETTE_MANAGER = {
|
||||
# Main root for club pages
|
||||
SITH_CLUB_ROOT_PAGE = "clubs"
|
||||
|
||||
# Define the date in the year serving as reference for the subscriptions calendar
|
||||
# (month, day)
|
||||
# Define the date in the year serving as
|
||||
# reference for the subscriptions calendar (month, day)
|
||||
SITH_SEMESTER_START_AUTUMN = (8, 15) # 15 August
|
||||
SITH_SEMESTER_START_SPRING = (2, 15) # 15 February
|
||||
|
||||
@ -510,10 +510,12 @@ SITH_ACCOUNT_INACTIVITY_DELTA = relativedelta(years=2)
|
||||
SITH_ACCOUNT_DUMP_DELTA = timedelta(days=30)
|
||||
"""timedelta between the warning mail and the actual account dump"""
|
||||
|
||||
# Defines which product type is the refilling type, and thus increases the account amount
|
||||
# Defines which product type is the refilling type,
|
||||
# and thus increases the account amount
|
||||
SITH_COUNTER_PRODUCTTYPE_REFILLING = 3
|
||||
|
||||
# Defines which product is the one year subscription and which one is the six month subscription
|
||||
# Defines which product is the one year subscription
|
||||
# and which one is the six month subscription
|
||||
SITH_PRODUCT_SUBSCRIPTION_ONE_SEMESTER = 1
|
||||
SITH_PRODUCT_SUBSCRIPTION_TWO_SEMESTERS = 2
|
||||
SITH_PRODUCTTYPE_SUBSCRIPTION = 2
|
||||
@ -701,15 +703,15 @@ TOXIC_DOMAINS_PROVIDERS = [
|
||||
]
|
||||
|
||||
try:
|
||||
from .settings_custom import *
|
||||
from .settings_custom import * # noqa F403 (this star-import is actually useful)
|
||||
|
||||
logging.getLogger("django").info("Custom settings imported")
|
||||
except:
|
||||
except ImportError:
|
||||
logging.getLogger("django").warning("Custom settings failed")
|
||||
|
||||
if DEBUG:
|
||||
INSTALLED_APPS += ("debug_toolbar",)
|
||||
MIDDLEWARE = ("debug_toolbar.middleware.DebugToolbarMiddleware",) + MIDDLEWARE
|
||||
MIDDLEWARE = ("debug_toolbar.middleware.DebugToolbarMiddleware", *MIDDLEWARE)
|
||||
DEBUG_TOOLBAR_PANELS = [
|
||||
"debug_toolbar.panels.versions.VersionsPanel",
|
||||
"debug_toolbar.panels.timer.TimerPanel",
|
||||
@ -723,7 +725,8 @@ if DEBUG:
|
||||
"debug_toolbar.panels.signals.SignalsPanel",
|
||||
"debug_toolbar.panels.redirects.RedirectsPanel",
|
||||
]
|
||||
SENTRY_ENV = "development"
|
||||
if not TESTING:
|
||||
SENTRY_ENV = "development" # We can't test if it gets overridden in settings
|
||||
|
||||
if TESTING:
|
||||
CAPTCHA_TEST_MODE = True
|
||||
|
32
sith/tests.py
Normal file
32
sith/tests.py
Normal 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
|
11
sith/urls.py
11
sith/urls.py
@ -17,6 +17,7 @@ from ajax_select import urls as ajax_select_urls
|
||||
from django.conf import settings
|
||||
from django.conf.urls.static import static
|
||||
from django.contrib import admin
|
||||
from django.http import Http404
|
||||
from django.urls import include, path
|
||||
from django.views.i18n import JavaScriptCatalog
|
||||
from ninja_extra import NinjaExtraAPI
|
||||
@ -71,7 +72,8 @@ if settings.DEBUG:
|
||||
|
||||
urlpatterns += [path("__debug__/", include(debug_toolbar.urls))]
|
||||
|
||||
if settings.SENTRY_ENV == "development":
|
||||
|
||||
def sentry_debug(request):
|
||||
"""Sentry debug endpoint
|
||||
|
||||
This function always crash and allows us to test
|
||||
@ -83,8 +85,9 @@ if settings.SENTRY_ENV == "development":
|
||||
|
||||
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
Loading…
Reference in New Issue
Block a user