-
{% endblock %}
diff --git a/core/tests/test_core.py b/core/tests/test_core.py
index 72cde11c..4523e147 100644
--- a/core/tests/test_core.py
+++ b/core/tests/test_core.py
@@ -38,6 +38,7 @@ from core.markdown import markdown
from core.models import AnonymousUser, Group, Page, User
from core.utils import get_semester_code, get_start_of_semester
from core.views import AllowFragment
+from counter.models import Customer
from sith import settings
@@ -151,24 +152,44 @@ class TestUserLogin:
def user(self) -> User:
return baker.make(User, password=make_password("plop"))
- def test_login_fail(self, client, user):
+ @pytest.mark.parametrize(
+ "identifier_getter",
+ [
+ lambda user: user.username,
+ lambda user: user.email,
+ lambda user: Customer.get_or_create(user)[0].account_id,
+ ],
+ )
+ def test_login_fail(self, client, user, identifier_getter):
"""Should not login a user correctly."""
+ identifier = identifier_getter(user)
response = client.post(
reverse("core:login"),
- {"username": user.username, "password": "wrong-password"},
+ {"username": identifier, "password": "wrong-password"},
)
assert response.status_code == 200
- assert (
- '
Votre nom d\'utilisateur '
- "et votre mot de passe ne correspondent pas. Merci de réessayer.
"
- ) in response.text
assert response.wsgi_request.user.is_anonymous
+ soup = BeautifulSoup(response.text, "lxml")
+ form = soup.find(id="login-form")
+ assert (
+ form.find(class_="alert alert-red").get_text(strip=True)
+ == "Vos identifiants ne correspondent pas. Veuillez réessayer."
+ )
+ assert form.find("input", attrs={"name": "username"}).get("value") == identifier
- def test_login_success(self, client, user):
+ @pytest.mark.parametrize(
+ "identifier_getter",
+ [
+ lambda user: user.username,
+ lambda user: user.email,
+ lambda user: Customer.get_or_create(user)[0].account_id,
+ ],
+ )
+ def test_login_success(self, client, user, identifier_getter):
"""Should login a user correctly."""
response = client.post(
reverse("core:login"),
- {"username": user.username, "password": "plop"},
+ {"username": identifier_getter(user), "password": "plop"},
)
assertRedirects(response, reverse("core:index"))
assert response.wsgi_request.user == user
@@ -361,17 +382,9 @@ class TestUserIsInGroup(TestCase):
@classmethod
def setUpTestData(cls):
- cls.root_group = Group.objects.get(name="Root")
- cls.public_group = Group.objects.get(name="Public")
+ cls.public_group = Group.objects.get(id=settings.SITH_GROUP_PUBLIC_ID)
cls.public_user = baker.make(User)
- cls.subscribers = Group.objects.get(name="Subscribers")
- cls.old_subscribers = Group.objects.get(name="Old subscribers")
- cls.accounting_admin = Group.objects.get(name="Accounting admin")
- cls.com_admin = Group.objects.get(name="Communication admin")
- cls.counter_admin = Group.objects.get(name="Counter admin")
- cls.sas_admin = Group.objects.get(name="SAS admin")
cls.club = baker.make(Club)
- cls.main_club = Club.objects.get(id=1)
def assert_in_public_group(self, user):
assert user.is_in_group(pk=self.public_group.id)
@@ -379,15 +392,7 @@ class TestUserIsInGroup(TestCase):
def assert_only_in_public_group(self, user):
self.assert_in_public_group(user)
- for group in (
- self.root_group,
- self.accounting_admin,
- self.sas_admin,
- self.subscribers,
- self.old_subscribers,
- self.club.members_group,
- self.club.board_group,
- ):
+ for group in Group.objects.exclude(id=self.public_group.id):
assert not user.is_in_group(pk=group.pk)
assert not user.is_in_group(name=group.name)
diff --git a/core/views/forms.py b/core/views/forms.py
index 02f2ae26..a8bbdfd6 100644
--- a/core/views/forms.py
+++ b/core/views/forms.py
@@ -132,29 +132,31 @@ class FutureDateTimeField(forms.DateTimeField):
class LoginForm(AuthenticationForm):
def __init__(self, *arg, **kwargs):
- if "data" in kwargs:
- from counter.models import Customer
-
- data = kwargs["data"].copy()
- account_code = re.compile(r"^[0-9]+[A-Za-z]$")
- try:
- if account_code.match(data["username"]):
- user = (
- Customer.objects.filter(account_id__iexact=data["username"])
- .first()
- .user
- )
- elif "@" in data["username"]:
- user = User.objects.filter(email__iexact=data["username"]).first()
- else:
- user = User.objects.filter(username=data["username"]).first()
- data["username"] = user.username
- except: # noqa E722 I don't know what error is supposed to be raised here
- pass
- kwargs["data"] = data
super().__init__(*arg, **kwargs)
self.fields["username"].label = _("Username, email, or account number")
+ def clean_username(self):
+ identifier: str = self.cleaned_data["username"]
+ account_code = re.compile(r"^[0-9]+[A-Za-z]$")
+ if account_code.match(identifier):
+ qs_filter = "customer__account_id__iexact"
+ elif identifier.count("@") == 1:
+ qs_filter = "email"
+ else:
+ qs_filter = None
+ if qs_filter:
+ # if the user gave an email or an account code instead of
+ # a username, retrieve and return the corresponding username.
+ # If there is no username, return an empty string, so that
+ # Django will properly handle the error when failing the authentication
+ identifier = (
+ User.objects.filter(**{qs_filter: identifier})
+ .values_list("username", flat=True)
+ .first()
+ or ""
+ )
+ return identifier
+
class RegisteringForm(UserCreationForm):
error_css_class = "error"
diff --git a/counter/admin.py b/counter/admin.py
index 10f04c8d..de1d9d0b 100644
--- a/counter/admin.py
+++ b/counter/admin.py
@@ -41,6 +41,7 @@ class ProductAdmin(SearchModelAdmin):
"profit",
"archived",
)
+ list_select_related = ("product_type",)
search_fields = ("name", "code")
@@ -81,20 +82,13 @@ class AccountDumpAdmin(admin.ModelAdmin):
"customer",
"warning_mail_sent_at",
"warning_mail_error",
- "dump_operation",
+ "dump_operation__date",
"amount",
)
+ list_select_related = ("customer", "customer__user", "dump_operation")
autocomplete_fields = ("customer", "dump_operation")
list_filter = ("warning_mail_error",)
- def get_queryset(self, request):
- # the `amount` property requires to know the customer and the dump_operation
- return (
- super()
- .get_queryset(request)
- .select_related("customer", "customer__user", "dump_operation")
- )
-
@admin.register(Counter)
class CounterAdmin(admin.ModelAdmin):
@@ -113,11 +107,14 @@ class RefillingAdmin(SearchModelAdmin):
"customer__account_id",
"counter__name",
)
+ list_filter = (("counter", admin.RelatedOnlyFieldListFilter),)
+ date_hierarchy = "date"
@admin.register(Selling)
class SellingAdmin(SearchModelAdmin):
list_display = ("customer", "label", "unit_price", "quantity", "counter", "date")
+ list_select_related = ("customer", "customer__user", "counter")
search_fields = (
"customer__user__username",
"customer__user__first_name",
@@ -126,6 +123,8 @@ class SellingAdmin(SearchModelAdmin):
"counter__name",
)
autocomplete_fields = ("customer", "seller")
+ list_filter = (("counter", admin.RelatedOnlyFieldListFilter),)
+ date_hierarchy = "date"
@admin.register(Permanency)
diff --git a/counter/static/bundled/counter/counter-click-index.ts b/counter/static/bundled/counter/counter-click-index.ts
index 99f6bdb7..f594cdd8 100644
--- a/counter/static/bundled/counter/counter-click-index.ts
+++ b/counter/static/bundled/counter/counter-click-index.ts
@@ -1,3 +1,4 @@
+import { AlertMessage } from "#core:utils/alert-message";
import { BasketItem } from "#counter:counter/basket";
import type { CounterConfig, ErrorMessage } from "#counter:counter/types";
import type { CounterProductSelect } from "./components/counter-product-select-index.ts";
@@ -5,14 +6,9 @@ import type { CounterProductSelect } from "./components/counter-product-select-i
document.addEventListener("alpine:init", () => {
Alpine.data("counter", (config: CounterConfig) => ({
basket: {} as Record
,
- errors: [],
customerBalance: config.customerBalance,
codeField: null as CounterProductSelect | null,
- alertMessage: {
- content: "",
- show: false,
- timeout: null,
- },
+ alertMessage: new AlertMessage({ defaultDuration: 2000 }),
init() {
// Fill the basket with the initial data
@@ -77,22 +73,10 @@ document.addEventListener("alpine:init", () => {
return total;
},
- showAlertMessage(message: string) {
- if (this.alertMessage.timeout !== null) {
- clearTimeout(this.alertMessage.timeout);
- }
- this.alertMessage.content = message;
- this.alertMessage.show = true;
- this.alertMessage.timeout = setTimeout(() => {
- this.alertMessage.show = false;
- this.alertMessage.timeout = null;
- }, 2000);
- },
-
addToBasketWithMessage(id: string, quantity: number) {
const message = this.addToBasket(id, quantity);
if (message.length > 0) {
- this.showAlertMessage(message);
+ this.alertMessage.display(message, { success: false });
}
},
@@ -109,7 +93,9 @@ document.addEventListener("alpine:init", () => {
finish() {
if (this.getBasketSize() === 0) {
- this.showAlertMessage(gettext("You can't send an empty basket."));
+ this.alertMessage.display(gettext("You can't send an empty basket."), {
+ success: false,
+ });
return;
}
this.$refs.basketForm.submit();
diff --git a/counter/static/bundled/counter/product-list-index.ts b/counter/static/bundled/counter/product-list-index.ts
index a7cd3f86..de0381b9 100644
--- a/counter/static/bundled/counter/product-list-index.ts
+++ b/counter/static/bundled/counter/product-list-index.ts
@@ -167,7 +167,7 @@ document.addEventListener("alpine:init", () => {
});
// if products to download are already in-memory, directly take them.
// If not, fetch them.
- const products =
+ const products: ProductSchema[] =
this.nbPages > 1
? await paginated(productSearchProductsDetailed, this.getQueryParams())
: Object.values(this.products).flat();
diff --git a/counter/static/bundled/counter/product-type-index.ts b/counter/static/bundled/counter/product-type-index.ts
index f200e9b2..37de445d 100644
--- a/counter/static/bundled/counter/product-type-index.ts
+++ b/counter/static/bundled/counter/product-type-index.ts
@@ -1,15 +1,11 @@
+import { AlertMessage } from "#core:utils/alert-message";
import Alpine from "alpinejs";
import { producttypeReorder } from "#openapi";
document.addEventListener("alpine:init", () => {
Alpine.data("productTypesList", () => ({
loading: false,
- alertMessage: {
- open: false,
- success: true,
- content: "",
- timeout: null,
- },
+ alertMessage: new AlertMessage({ defaultDuration: 2000 }),
async reorder(itemId: number, newPosition: number) {
// The sort plugin of Alpine doesn't manage dynamic lists with x-sort
@@ -41,23 +37,14 @@ document.addEventListener("alpine:init", () => {
},
openAlertMessage(response: Response) {
- if (response.ok) {
- this.alertMessage.success = true;
- this.alertMessage.content = gettext("Products types reordered!");
- } else {
- this.alertMessage.success = false;
- this.alertMessage.content = interpolate(
- gettext("Product type reorganisation failed with status code : %d"),
- [response.status],
- );
- }
- this.alertMessage.open = true;
- if (this.alertMessage.timeout !== null) {
- clearTimeout(this.alertMessage.timeout);
- }
- this.alertMessage.timeout = setTimeout(() => {
- this.alertMessage.open = false;
- }, 2000);
+ const success = response.ok;
+ const content = response.ok
+ ? gettext("Products types reordered!")
+ : interpolate(
+ gettext("Product type reorganisation failed with status code : %d"),
+ [response.status],
+ );
+ this.alertMessage.display(content, { success: success });
this.loading = false;
},
}));
diff --git a/counter/static/bundled/counter/types.d.ts b/counter/static/bundled/counter/types.d.ts
index 4a22a916..18fea258 100644
--- a/counter/static/bundled/counter/types.d.ts
+++ b/counter/static/bundled/counter/types.d.ts
@@ -1,4 +1,4 @@
-type ErrorMessage = string;
+export type ErrorMessage = string;
export interface InitialFormData {
/* Used to refill the form when the backend raises an error */
diff --git a/counter/tests/test_counter.py b/counter/tests/test_counter.py
index 5b96a471..d90f9510 100644
--- a/counter/tests/test_counter.py
+++ b/counter/tests/test_counter.py
@@ -17,6 +17,7 @@ from datetime import timedelta
from decimal import Decimal
import pytest
+from dateutil.relativedelta import relativedelta
from django.conf import settings
from django.contrib.auth.models import Permission, make_password
from django.core.cache import cache
@@ -823,3 +824,53 @@ class TestClubCounterClickAccess(TestCase):
self.client.force_login(self.user)
res = self.client.get(self.click_url)
assert res.status_code == 200
+
+
+@pytest.mark.django_db
+class TestCounterLogout:
+ def test_logout_simple(self, client: Client):
+ perm_counter = baker.make(Counter, type="BAR")
+ permanence = baker.make(
+ Permanency,
+ counter=perm_counter,
+ start=now() - timedelta(hours=1),
+ activity=now() - timedelta(minutes=10),
+ )
+ with freeze_time():
+ res = client.post(
+ reverse("counter:logout", kwargs={"counter_id": permanence.counter_id}),
+ data={"user_id": permanence.user_id},
+ )
+ assertRedirects(
+ res,
+ reverse(
+ "counter:details", kwargs={"counter_id": permanence.counter_id}
+ ),
+ )
+ permanence.refresh_from_db()
+ assert permanence.end == now()
+
+ def test_logout_doesnt_change_old_permanences(self, client: Client):
+ perm_counter = baker.make(Counter, type="BAR")
+ permanence = baker.make(
+ Permanency,
+ counter=perm_counter,
+ start=now() - timedelta(hours=1),
+ activity=now() - timedelta(minutes=10),
+ )
+ old_end = now() - relativedelta(year=10)
+ old_permanence = baker.make(
+ Permanency,
+ counter=perm_counter,
+ end=old_end,
+ activity=now() - relativedelta(year=8),
+ )
+ with freeze_time():
+ client.post(
+ reverse("counter:logout", kwargs={"counter_id": permanence.counter_id}),
+ data={"user_id": permanence.user_id},
+ )
+ permanence.refresh_from_db()
+ assert permanence.end == now()
+ old_permanence.refresh_from_db()
+ assert old_permanence.end == old_end
diff --git a/counter/views/auth.py b/counter/views/auth.py
index 87cce72c..eba165d0 100644
--- a/counter/views/auth.py
+++ b/counter/views/auth.py
@@ -13,10 +13,10 @@
#
#
-from django.db.models import F
from django.http import HttpRequest, HttpResponseRedirect
from django.shortcuts import get_object_or_404, redirect
from django.utils import timezone
+from django.utils.timezone import now
from django.views.decorators.http import require_POST
from core.views.forms import LoginForm
@@ -47,7 +47,7 @@ def counter_login(request: HttpRequest, counter_id: int) -> HttpResponseRedirect
@require_POST
def counter_logout(request: HttpRequest, counter_id: int) -> HttpResponseRedirect:
"""End the permanency of a user in this counter."""
- Permanency.objects.filter(counter=counter_id, user=request.POST["user_id"]).update(
- end=F("activity")
- )
+ Permanency.objects.filter(
+ counter=counter_id, user=request.POST["user_id"], end=None
+ ).update(end=now())
return redirect("counter:details", counter_id=counter_id)
diff --git a/locale/fr/LC_MESSAGES/django.po b/locale/fr/LC_MESSAGES/django.po
index 01b3f706..d6549184 100644
--- a/locale/fr/LC_MESSAGES/django.po
+++ b/locale/fr/LC_MESSAGES/django.po
@@ -6,7 +6,7 @@
msgid ""
msgstr ""
"Report-Msgid-Bugs-To: \n"
-"POT-Creation-Date: 2025-06-16 14:54+0200\n"
+"POT-Creation-Date: 2025-06-25 16:29+0200\n"
"PO-Revision-Date: 2016-07-18\n"
"Last-Translator: Maréchal \n"
@@ -2015,10 +2015,8 @@ msgid "Please login or create an account to see this page."
msgstr "Merci de vous identifier ou de créer un compte pour voir cette page."
#: core/templates/core/login.jinja
-msgid "Your username and password didn't match. Please try again."
-msgstr ""
-"Votre nom d'utilisateur et votre mot de passe ne correspondent pas. Merci de "
-"réessayer."
+msgid "Your credentials didn't match. Please try again."
+msgstr "Vos identifiants ne correspondent pas. Veuillez réessayer."
#: core/templates/core/login.jinja
msgid "Lost password?"
diff --git a/package-lock.json b/package-lock.json
index a0228831..44e71934 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -45,7 +45,11 @@
"@hey-api/openapi-ts": "^0.73.0",
"@rollup/plugin-inject": "^5.0.5",
"@types/alpinejs": "^3.13.10",
+ "@types/cytoscape-cxtmenu": "^3.4.4",
+ "@types/cytoscape-klay": "^3.1.4",
"@types/jquery": "^3.5.31",
+ "@types/js-cookie": "^3.0.6",
+ "typescript": "^5.8.3",
"vite": "^6.2.5",
"vite-bundle-visualizer": "^1.2.1",
"vite-plugin-static-copy": "^3.0.2"
@@ -2819,6 +2823,33 @@
"@types/tern": "*"
}
},
+ "node_modules/@types/cytoscape": {
+ "version": "3.21.9",
+ "resolved": "https://registry.npmjs.org/@types/cytoscape/-/cytoscape-3.21.9.tgz",
+ "integrity": "sha512-JyrG4tllI6jvuISPjHK9j2Xv/LTbnLekLke5otGStjFluIyA9JjgnvgZrSBsp8cEDpiTjwgZUZwpPv8TSBcoLw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/cytoscape-cxtmenu": {
+ "version": "3.4.4",
+ "resolved": "https://registry.npmjs.org/@types/cytoscape-cxtmenu/-/cytoscape-cxtmenu-3.4.4.tgz",
+ "integrity": "sha512-cuv+IdbKekswDRBIrHn97IYOzWS2/UjVr0kDIHCOYvqWy3iZkuGGM4qmHNPQ+63Dn7JgtmD0l3MKW1moyhoaKw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/cytoscape": "*"
+ }
+ },
+ "node_modules/@types/cytoscape-klay": {
+ "version": "3.1.4",
+ "resolved": "https://registry.npmjs.org/@types/cytoscape-klay/-/cytoscape-klay-3.1.4.tgz",
+ "integrity": "sha512-H+tIadpcVjmDGWKFUfibwzIpH/kddfwAFsuhPparjiC+bWBm+MeNqIwwY+19ofkJZWcqWqZL6Jp8lkp+sP8Aig==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/cytoscape": "*"
+ }
+ },
"node_modules/@types/estree": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
@@ -2835,6 +2866,13 @@
"@types/sizzle": "*"
}
},
+ "node_modules/@types/js-cookie": {
+ "version": "3.0.6",
+ "resolved": "https://registry.npmjs.org/@types/js-cookie/-/js-cookie-3.0.6.tgz",
+ "integrity": "sha512-wkw9yd1kEXOPnvEeEV1Go1MmxtBJL0RR79aOTAApecWFVu7w0NNXNqhcWgvw2YgZDYadliXkl14pa3WXw5jlCQ==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/@types/json-schema": {
"version": "7.0.15",
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
@@ -5558,7 +5596,6 @@
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
"dev": true,
"license": "Apache-2.0",
- "peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
diff --git a/package.json b/package.json
index 9d7cf43a..0e986ab9 100644
--- a/package.json
+++ b/package.json
@@ -10,7 +10,7 @@
"openapi": "openapi-ts",
"analyse-dev": "vite-bundle-visualizer --mode development",
"analyse-prod": "vite-bundle-visualizer --mode production",
- "check": "biome check --write"
+ "check": "tsc && biome check --write"
},
"keywords": [],
"author": "",
@@ -30,7 +30,11 @@
"@hey-api/openapi-ts": "^0.73.0",
"@rollup/plugin-inject": "^5.0.5",
"@types/alpinejs": "^3.13.10",
+ "@types/cytoscape-cxtmenu": "^3.4.4",
+ "@types/cytoscape-klay": "^3.1.4",
"@types/jquery": "^3.5.31",
+ "@types/js-cookie": "^3.0.6",
+ "typescript": "^5.8.3",
"vite": "^6.2.5",
"vite-bundle-visualizer": "^1.2.1",
"vite-plugin-static-copy": "^3.0.2"
diff --git a/pyproject.toml b/pyproject.toml
index 15c75eb6..e8ea292e 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -92,7 +92,7 @@ docs = [
default-groups = ["dev", "tests", "docs"]
[tool.xapian]
-version = "1.4.25"
+version = "1.4.29"
[tool.ruff]
output-format = "concise" # makes ruff error logs easier to read
diff --git a/rootplace/tests/test_merge_users.py b/rootplace/tests/test_merge_users.py
index baaa8ca9..294e2bae 100644
--- a/rootplace/tests/test_merge_users.py
+++ b/rootplace/tests/test_merge_users.py
@@ -53,9 +53,9 @@ class TestMergeUser(TestCase):
self.to_keep.address = "Jerusalem"
self.to_delete.parent_address = "Rome"
self.to_delete.address = "Rome"
- subscribers = Group.objects.get(name="Subscribers")
+ subscribers = Group.objects.get(id=settings.SITH_GROUP_SUBSCRIBERS_ID)
mde_admin = Group.objects.get(name="MDE admin")
- sas_admin = Group.objects.get(name="SAS admin")
+ sas_admin = Group.objects.get(id=settings.SITH_GROUP_SAS_ADMIN_ID)
self.to_keep.groups.add(subscribers.id)
self.to_delete.groups.add(mde_admin.id)
self.to_keep.groups.add(sas_admin.id)
diff --git a/sas/migrations/0005_alter_sasfile_options.py b/sas/migrations/0005_alter_sasfile_options.py
new file mode 100644
index 00000000..426c9bdc
--- /dev/null
+++ b/sas/migrations/0005_alter_sasfile_options.py
@@ -0,0 +1,19 @@
+# Generated by Django 5.2.3 on 2025-06-17 18:53
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+ dependencies = [("sas", "0004_picturemoderationrequest_and_more")]
+
+ operations = [
+ migrations.AlterModelOptions(
+ name="sasfile",
+ options={
+ "permissions": [
+ ("moderate_sasfile", "Can moderate SAS files"),
+ ("view_unmoderated_sasfile", "Can view not moderated SAS files"),
+ ]
+ },
+ ),
+ ]
diff --git a/sas/models.py b/sas/models.py
index 3a0a8428..0355c7da 100644
--- a/sas/models.py
+++ b/sas/models.py
@@ -41,6 +41,10 @@ class SasFile(SithFile):
class Meta:
proxy = True
+ permissions = [
+ ("moderate_sasfile", "Can moderate SAS files"),
+ ("view_unmoderated_sasfile", "Can view not moderated SAS files"),
+ ]
def can_be_viewed_by(self, user):
if user.is_anonymous:
@@ -59,7 +63,7 @@ class SasFile(SithFile):
return self.id in viewable
def can_be_edited_by(self, user):
- return user.is_root or user.is_in_group(pk=settings.SITH_GROUP_SAS_ADMIN_ID)
+ return user.has_perm("sas.change_sasfile")
class PictureQuerySet(models.QuerySet):
@@ -69,7 +73,7 @@ class PictureQuerySet(models.QuerySet):
Warning:
Calling this queryset method may add several additional requests.
"""
- if user.is_root or user.is_in_group(pk=settings.SITH_GROUP_SAS_ADMIN_ID):
+ if user.has_perm("sas.moderate_sasfile"):
return self.all()
if user.was_subscribed:
return self.filter(Q(is_moderated=True) | Q(owner=user))
@@ -182,7 +186,7 @@ class AlbumQuerySet(models.QuerySet):
Warning:
Calling this queryset method may add several additional requests.
"""
- if user.is_root or user.is_in_group(pk=settings.SITH_GROUP_SAS_ADMIN_ID):
+ if user.has_perm("sas.moderate_sasfile"):
return self.all()
if user.was_subscribed:
return self.filter(Q(is_moderated=True) | Q(owner=user))
diff --git a/sas/static/bundled/sas/album-index.ts b/sas/static/bundled/sas/album-index.ts
index 32f0f02f..b56b8e02 100644
--- a/sas/static/bundled/sas/album-index.ts
+++ b/sas/static/bundled/sas/album-index.ts
@@ -83,7 +83,6 @@ document.addEventListener("alpine:init", () => {
Alpine.data("pictureUpload", (albumId: number) => ({
errors: [] as string[],
- pictures: [],
sending: false,
progress: null as HTMLProgressElement,
diff --git a/sas/static/bundled/sas/user/pictures-index.ts b/sas/static/bundled/sas/user/pictures-index.ts
index f5f2fcbc..0a77159b 100644
--- a/sas/static/bundled/sas/user/pictures-index.ts
+++ b/sas/static/bundled/sas/user/pictures-index.ts
@@ -9,28 +9,35 @@ interface PagePictureConfig {
userId: number;
}
+interface Album {
+ id: number;
+ name: string;
+ pictures: PictureSchema[];
+}
+
document.addEventListener("alpine:init", () => {
Alpine.data("user_pictures", (config: PagePictureConfig) => ({
loading: true,
- pictures: [] as PictureSchema[],
- albums: {} as Record,
+ albums: [] as Album[],
async init() {
- this.pictures = await paginated(picturesFetchPictures, {
+ const pictures = await paginated(picturesFetchPictures, {
// biome-ignore lint/style/useNamingConvention: from python api
query: { users_identified: [config.userId] },
} as PicturesFetchPicturesData);
-
- this.albums = this.pictures.reduce(
- (acc: Record, picture: PictureSchema) => {
- if (!acc[picture.album.id]) {
- acc[picture.album.id] = [];
- }
- acc[picture.album.id].push(picture);
- return acc;
- },
- {},
- );
+ const groupedAlbums = Object.groupBy(pictures, (i: PictureSchema) => i.album.id);
+ this.albums = Object.values(groupedAlbums).map((pictures: PictureSchema[]) => {
+ return {
+ id: pictures[0].album.id,
+ name: pictures[0].album.name,
+ pictures: pictures,
+ };
+ });
+ this.albums.sort((a: Album, b: Album) => b.id - a.id);
+ const hash = document.location.hash.replace("#", "");
+ if (hash.startsWith("album-")) {
+ this.$nextTick(() => document.getElementById(hash)?.scrollIntoView()).then();
+ }
this.loading = false;
},
}));
diff --git a/sas/static/bundled/sas/viewer-index.ts b/sas/static/bundled/sas/viewer-index.ts
index 59718b26..0eec9d36 100644
--- a/sas/static/bundled/sas/viewer-index.ts
+++ b/sas/static/bundled/sas/viewer-index.ts
@@ -1,3 +1,4 @@
+import type { UserAjaxSelect } from "#core:core/components/ajax-select-index";
import { paginated } from "#core:utils/api";
import { exportToHtml } from "#core:utils/globals";
import { History } from "#core:utils/history";
@@ -130,7 +131,7 @@ exportToHtml("loadViewer", (config: ViewerConfig) => {
currentPicture: {
// biome-ignore lint/style/useNamingConvention: api is in snake_case
is_moderated: true,
- id: null,
+ id: null as number,
name: "",
// biome-ignore lint/style/useNamingConvention: api is in snake_case
display_name: "",
@@ -142,7 +143,7 @@ exportToHtml("loadViewer", (config: ViewerConfig) => {
full_size_url: "",
owner: "",
date: new Date(),
- identifications: [],
+ identifications: [] as IdentifiedUserSchema[],
},
/**
* The picture which will be displayed next if the user press the "next" button
@@ -155,7 +156,7 @@ exportToHtml("loadViewer", (config: ViewerConfig) => {
/**
* The select2 component used to identify users
**/
- selector: undefined,
+ selector: undefined as UserAjaxSelect,
/**
* Error message when a moderation operation fails
**/
diff --git a/sas/templates/sas/macros.jinja b/sas/templates/sas/macros.jinja
index aa4afa48..a00c2b6c 100644
--- a/sas/templates/sas/macros.jinja
+++ b/sas/templates/sas/macros.jinja
@@ -50,7 +50,7 @@
#}
{% macro download_button(name) %}
-
+