SAS / {{ print_path(album) }}
@@ -50,15 +48,13 @@
It will be hidden to other users until it has been moderated.
{% endtrans %}
diff --git a/core/auth/mixins.py b/core/auth/mixins.py
index 917200ed..28012d50 100644
--- a/core/auth/mixins.py
+++ b/core/auth/mixins.py
@@ -307,6 +307,7 @@ class PermissionOrClubBoardRequiredMixin(PermissionRequiredMixin):
return False
if super().has_permission():
return True
- return self.club is not None and any(
- g.id == self.club.board_group_id for g in self.request.user.cached_groups
+ return (
+ self.club is not None
+ and self.club.board_group_id in self.request.user.all_groups
)
diff --git a/core/models.py b/core/models.py
index 27744775..3b533751 100644
--- a/core/models.py
+++ b/core/models.py
@@ -356,23 +356,27 @@ class User(AbstractUser):
)
if group_id is None:
return False
- if group_id == settings.SITH_GROUP_SUBSCRIBERS_ID:
- return self.is_subscribed
- if group_id == settings.SITH_GROUP_ROOT_ID:
- return self.is_root
- return any(g.id == group_id for g in self.cached_groups)
+ return group_id in self.all_groups
@cached_property
- def cached_groups(self) -> list[Group]:
+ def all_groups(self) -> dict[int, Group]:
"""Get the list of groups this user is in."""
- return list(self.groups.all())
+ additional_groups = []
+ if self.is_subscribed:
+ additional_groups.append(settings.SITH_GROUP_SUBSCRIBERS_ID)
+ if self.is_superuser:
+ additional_groups.append(settings.SITH_GROUP_ROOT_ID)
+ qs = self.groups.all()
+ if additional_groups:
+ # This is somewhat counter-intuitive, but this query runs way faster with
+ # a UNION rather than a OR (in average, 0.25ms vs 14ms).
+ # For the why, cf. https://dba.stackexchange.com/questions/293836/why-is-an-or-statement-slower-than-union
+ qs = qs.union(Group.objects.filter(id__in=additional_groups))
+ return {g.id: g for g in qs}
@cached_property
def is_root(self) -> bool:
- if self.is_superuser:
- return True
- root_id = settings.SITH_GROUP_ROOT_ID
- return any(g.id == root_id for g in self.cached_groups)
+ return self.is_superuser or settings.SITH_GROUP_ROOT_ID in self.all_groups
@cached_property
def is_board_member(self) -> bool:
@@ -1099,10 +1103,7 @@ class PageQuerySet(models.QuerySet):
return self.filter(view_groups=settings.SITH_GROUP_PUBLIC_ID)
if user.has_perm("core.view_page"):
return self.all()
- groups_ids = [g.id for g in user.cached_groups]
- if user.is_subscribed:
- groups_ids.append(settings.SITH_GROUP_SUBSCRIBERS_ID)
- return self.filter(view_groups__in=groups_ids)
+ return self.filter(view_groups__in=user.all_groups)
# This function prevents generating migration upon settings change
@@ -1376,7 +1377,7 @@ class PageRev(models.Model):
return self.page.can_be_edited_by(user)
def is_owned_by(self, user: User) -> bool:
- return any(g.id == self.page.owner_group_id for g in user.cached_groups)
+ return self.page.owner_group_id in user.all_groups
def similarity_ratio(self, text: str) -> float:
"""Similarity ratio between this revision's content and the given text.
diff --git a/core/tests/test_core.py b/core/tests/test_core.py
index 631e5b51..f6dc8570 100644
--- a/core/tests/test_core.py
+++ b/core/tests/test_core.py
@@ -418,16 +418,16 @@ class TestUserIsInGroup(TestCase):
group_in = baker.make(Group)
self.public_user.groups.add(group_in)
- # clear the cached property `User.cached_groups`
- self.public_user.__dict__.pop("cached_groups", None)
+ # clear the cached property `User.all_groups`
+ self.public_user.__dict__.pop("all_groups", None)
# Test when the user is in the group
- with self.assertNumQueries(1):
+ with self.assertNumQueries(2):
self.public_user.is_in_group(pk=group_in.id)
with self.assertNumQueries(0):
self.public_user.is_in_group(pk=group_in.id)
group_not_in = baker.make(Group)
- self.public_user.__dict__.pop("cached_groups", None)
+ self.public_user.__dict__.pop("all_groups", None)
# Test when the user is not in the group
with self.assertNumQueries(1):
self.public_user.is_in_group(pk=group_not_in.id)
diff --git a/election/views.py b/election/views.py
index addadb1a..63cd70d9 100644
--- a/election/views.py
+++ b/election/views.py
@@ -1,7 +1,6 @@
from typing import TYPE_CHECKING
from cryptography.utils import cached_property
-from django.conf import settings
from django.contrib import messages
from django.contrib.auth.mixins import (
LoginRequiredMixin,
@@ -115,16 +114,9 @@ class VoteFormView(LoginRequiredMixin, UserPassesTestMixin, FormView):
def test_func(self):
if not self.election.can_vote(self.request.user):
return False
-
- groups = set(self.election.vote_groups.values_list("id", flat=True))
- if (
- settings.SITH_GROUP_SUBSCRIBERS_ID in groups
- and self.request.user.is_subscribed
- ):
- # the subscriber group isn't truly attached to users,
- # so it must be dealt with separately
- return True
- return self.request.user.groups.filter(id__in=groups).exists()
+ return self.election.vote_groups.filter(
+ id__in=self.request.user.all_groups
+ ).exists()
def vote(self, election_data):
with transaction.atomic():
@@ -238,15 +230,9 @@ class RoleCreateView(LoginRequiredMixin, UserPassesTestMixin, CreateView):
return False
if self.request.user.has_perm("election.add_role"):
return True
- groups = set(self.election.edit_groups.values_list("id", flat=True))
- if (
- settings.SITH_GROUP_SUBSCRIBERS_ID in groups
- and self.request.user.is_subscribed
- ):
- # the subscriber group isn't truly attached to users,
- # so it must be dealt with separately
- return True
- return self.request.user.groups.filter(id__in=groups).exists()
+ return self.election.edit_groups.filter(
+ id__in=self.request.user.all_groups
+ ).exists()
def get_initial(self):
return {"election": self.election}
@@ -279,14 +265,7 @@ class ElectionListCreateView(LoginRequiredMixin, UserPassesTestMixin, CreateView
.union(self.election.edit_groups.values("id"))
.values_list("id", flat=True)
)
- if (
- settings.SITH_GROUP_SUBSCRIBERS_ID in groups
- and self.request.user.is_subscribed
- ):
- # the subscriber group isn't truly attached to users,
- # so it must be dealt with separately
- return True
- return self.request.user.groups.filter(id__in=groups).exists()
+ return not groups.isdisjoint(self.request.user.all_groups.keys())
def get_initial(self):
return {"election": self.election}
diff --git a/sas/static/bundled/sas/viewer-index.ts b/sas/static/bundled/sas/viewer-index.ts
index 8b07b0cf..69d29592 100644
--- a/sas/static/bundled/sas/viewer-index.ts
+++ b/sas/static/bundled/sas/viewer-index.ts
@@ -109,232 +109,225 @@ interface ViewerConfig {
/** id of the first picture to load on the page */
firstPictureId: number;
/** if the user is sas admin */
- userIsSasAdmin: boolean;
+ userCanModerate: boolean;
}
/**
* Load user picture page with a nice download bar
**/
-exportToHtml("loadViewer", (config: ViewerConfig) => {
- document.addEventListener("alpine:init", () => {
- Alpine.data("picture_viewer", () => ({
- /**
- * All the pictures that can be displayed on this picture viewer
- **/
- pictures: [] as PictureWithIdentifications[],
- /**
- * The currently displayed picture
- * Default dummy data are pre-loaded to avoid javascript error
- * when loading the page at the beginning
- * @type PictureWithIdentifications
- **/
- currentPicture: {
- // biome-ignore lint/style/useNamingConvention: api is in snake_case
- is_moderated: true,
- id: null as number,
- name: "",
- // biome-ignore lint/style/useNamingConvention: api is in snake_case
- display_name: "",
- // biome-ignore lint/style/useNamingConvention: api is in snake_case
- compressed_url: "",
- // biome-ignore lint/style/useNamingConvention: api is in snake_case
- profile_url: "",
- // biome-ignore lint/style/useNamingConvention: api is in snake_case
- full_size_url: "",
- owner: "",
- date: new Date(),
- identifications: [] as IdentifiedUserSchema[],
- },
- /**
- * The picture which will be displayed next if the user press the "next" button
- **/
- nextPicture: null as PictureWithIdentifications,
- /**
- * The picture which will be displayed next if the user press the "previous" button
- **/
- previousPicture: null as PictureWithIdentifications,
- /**
- * The select2 component used to identify users
- **/
- selector: undefined as UserAjaxSelect,
- /**
- * Error message when a moderation operation fails
- **/
- moderationError: "",
- /**
- * Method of pushing new url to the browser history
- * Used by popstate event and always reset to it's default value when used
- **/
- pushstate: History.Push,
+document.addEventListener("alpine:init", () => {
+ Alpine.data("picture_viewer", (config: ViewerConfig) => ({
+ /**
+ * All the pictures that can be displayed on this picture viewer
+ **/
+ pictures: [] as PictureWithIdentifications[],
+ /**
+ * The currently displayed picture
+ * Default dummy data are pre-loaded to avoid javascript error
+ * when loading the page at the beginning
+ * @type PictureWithIdentifications
+ **/
+ currentPicture: {
+ // biome-ignore lint/style/useNamingConvention: api is in snake_case
+ is_moderated: true,
+ id: null as number,
+ name: "",
+ // biome-ignore lint/style/useNamingConvention: api is in snake_case
+ display_name: "",
+ // biome-ignore lint/style/useNamingConvention: api is in snake_case
+ compressed_url: "",
+ // biome-ignore lint/style/useNamingConvention: api is in snake_case
+ profile_url: "",
+ // biome-ignore lint/style/useNamingConvention: api is in snake_case
+ full_size_url: "",
+ owner: "",
+ date: new Date(),
+ identifications: [] as IdentifiedUserSchema[],
+ },
+ /**
+ * The picture which will be displayed next if the user press the "next" button
+ **/
+ nextPicture: null as PictureWithIdentifications,
+ /**
+ * The picture which will be displayed next if the user press the "previous" button
+ **/
+ previousPicture: null as PictureWithIdentifications,
+ /**
+ * The select2 component used to identify users
+ **/
+ selector: undefined as UserAjaxSelect,
+ /**
+ * Error message when a moderation operation fails
+ **/
+ moderationError: "",
+ /**
+ * Method of pushing new url to the browser history
+ * Used by popstate event and always reset to it's default value when used
+ **/
+ pushstate: History.Push,
- async init() {
- this.pictures = (
- await paginated(picturesFetchPictures, {
- // biome-ignore lint/style/useNamingConvention: api is in snake_case
- query: { album_id: config.albumId },
- } as PicturesFetchPicturesData)
- ).map(PictureWithIdentifications.fromPicture);
- this.selector = this.$refs.search;
- this.selector.setFilter((users: UserProfileSchema[]) => {
- const resp: UserProfileSchema[] = [];
- const ids = [
- ...(this.currentPicture.identifications || []).map(
- (i: IdentifiedUserSchema) => i.user.id,
- ),
- ];
- for (const user of users) {
- if (!ids.includes(user.id)) {
- resp.push(user);
- }
+ async init() {
+ this.pictures = (
+ await paginated(picturesFetchPictures, {
+ // biome-ignore lint/style/useNamingConvention: api is in snake_case
+ query: { album_id: config.albumId },
+ } as PicturesFetchPicturesData)
+ ).map(PictureWithIdentifications.fromPicture);
+ this.selector = this.$refs.search;
+ this.selector.setFilter((users: UserProfileSchema[]) => {
+ const resp: UserProfileSchema[] = [];
+ const ids = [
+ ...(this.currentPicture.identifications || []).map(
+ (i: IdentifiedUserSchema) => i.user.id,
+ ),
+ ];
+ for (const user of users) {
+ if (!ids.includes(user.id)) {
+ resp.push(user);
}
- return resp;
- });
- this.currentPicture = this.pictures.find(
- (i: PictureSchema) => i.id === config.firstPictureId,
- );
- this.$watch(
- "currentPicture",
- (current: PictureSchema, previous: PictureSchema) => {
- if (current === previous) {
- /* Avoid recursive updates */
- return;
- }
- this.updatePicture();
- },
- );
- window.addEventListener("popstate", async (event) => {
- if (!event.state || event.state.sasPictureId === undefined) {
+ }
+ return resp;
+ });
+ this.currentPicture = this.pictures.find(
+ (i: PictureSchema) => i.id === config.firstPictureId,
+ );
+ this.$watch(
+ "currentPicture",
+ (current: PictureSchema, previous: PictureSchema) => {
+ if (current === previous) {
+ /* Avoid recursive updates */
return;
}
- this.pushstate = History.Replace;
- this.currentPicture = this.pictures.find(
- (i: PictureSchema) =>
- i.id === Number.parseInt(event.state.sasPictureId, 10),
- );
- });
- this.pushstate = History.Replace; /* Avoid first url push */
- await this.updatePicture();
- },
-
- /**
- * Update the page.
- * Called when the `currentPicture` property changes.
- *
- * The url is modified without reloading the page,
- * and the previous picture, the next picture and
- * the list of identified users are updated.
- */
- async updatePicture(): Promise
SAS / {{ print_path(album) }}
@@ -50,15 +48,13 @@
It will be hidden to other users until it has been moderated.
{% endtrans %}