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 { - const updateArgs = { - data: { sasPictureId: this.currentPicture.id }, - unused: "", - url: this.currentPicture.sas_url, - }; - if (this.pushstate === History.Replace) { - window.history.replaceState( - updateArgs.data, - updateArgs.unused, - updateArgs.url, - ); - this.pushstate = History.Push; - } else { - window.history.pushState(updateArgs.data, updateArgs.unused, updateArgs.url); - } - - this.moderationError = ""; - const index: number = this.pictures.indexOf(this.currentPicture); - this.previousPicture = this.pictures[index - 1] || null; - this.nextPicture = this.pictures[index + 1] || null; - 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() { - const res = await picturesModeratePicture({ - // biome-ignore lint/style/useNamingConvention: api is in snake_case - path: { picture_id: this.currentPicture.id }, - }); - if (res.error) { - this.moderationError = `${gettext("Couldn't moderate picture")} : ${(res.error as { detail: string }).detail}`; + this.updatePicture(); + }, + ); + window.addEventListener("popstate", async (event) => { + if (!event.state || event.state.sasPictureId === undefined) { return; } - this.currentPicture.is_moderated = true; - this.currentPicture.asked_for_removal = false; - }, + 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(); + }, - async deletePicture() { - const res = await picturesDeletePicture({ + /** + * 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 { + const updateArgs = { + data: { sasPictureId: this.currentPicture.id }, + unused: "", + url: this.currentPicture.sas_url, + }; + if (this.pushstate === History.Replace) { + window.history.replaceState(updateArgs.data, updateArgs.unused, updateArgs.url); + this.pushstate = History.Push; + } else { + window.history.pushState(updateArgs.data, updateArgs.unused, updateArgs.url); + } + + this.moderationError = ""; + const index: number = this.pictures.indexOf(this.currentPicture); + this.previousPicture = this.pictures[index - 1] || null; + this.nextPicture = this.pictures[index + 1] || null; + 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.userCanModerate) { + await Promise.all([ + this.currentPicture.loadIdentifications(), + this.currentPicture.loadModeration(), + ]); + } else { + await this.currentPicture.loadIdentifications(); + } + }, + + async moderatePicture() { + const res = await picturesModeratePicture({ + // biome-ignore lint/style/useNamingConvention: api is in snake_case + path: { picture_id: this.currentPicture.id }, + }); + if (res.error) { + this.moderationError = `${gettext("Couldn't moderate picture")} : ${(res.error as { detail: string }).detail}`; + return; + } + this.currentPicture.is_moderated = true; + this.currentPicture.asked_for_removal = false; + }, + + async deletePicture() { + const res = await picturesDeletePicture({ + // biome-ignore lint/style/useNamingConvention: api is in snake_case + path: { picture_id: this.currentPicture.id }, + }); + if (res.error) { + this.moderationError = `${gettext("Couldn't delete picture")} : ${(res.error as { detail: string }).detail}`; + return; + } + this.pictures.splice(this.pictures.indexOf(this.currentPicture), 1); + if (this.pictures.length === 0) { + // The deleted picture was the only one in the list. + // As the album is now empty, go back to the parent page + document.location.href = config.albumUrl; + } + this.currentPicture = this.nextPicture || this.previousPicture; + }, + + /** + * Send the identification request and update the list of identified users. + */ + async submitIdentification(): Promise { + const widget: TomSelect = this.selector.widget; + await picturesIdentifyUsers({ + path: { // biome-ignore lint/style/useNamingConvention: api is in snake_case - path: { picture_id: this.currentPicture.id }, - }); - if (res.error) { - this.moderationError = `${gettext("Couldn't delete picture")} : ${(res.error as { detail: string }).detail}`; - return; - } - this.pictures.splice(this.pictures.indexOf(this.currentPicture), 1); - if (this.pictures.length === 0) { - // The deleted picture was the only one in the list. - // As the album is now empty, go back to the parent page - document.location.href = config.albumUrl; - } - this.currentPicture = this.nextPicture || this.previousPicture; - }, + picture_id: this.currentPicture.id, + }, + body: widget.items.map((i: string) => Number.parseInt(i, 10)), + }); + // refresh the identified users list + await this.currentPicture.loadIdentifications({ forceReload: true }); - /** - * Send the identification request and update the list of identified users. - */ - async submitIdentification(): Promise { - 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: widget.items.map((i: string) => Number.parseInt(i, 10)), - }); - // refresh the identified users list - await this.currentPicture.loadIdentifications({ forceReload: true }); + // Clear selection and cache of retrieved user so they can be filtered again + widget.clear(false); + widget.clearOptions(); + widget.setTextboxValue(""); + }, - // Clear selection and cache of retrieved user so they can be filtered again - widget.clear(false); - widget.clearOptions(); - widget.setTextboxValue(""); - }, + /** + * Check if an identification can be removed by the currently logged user + */ + canBeRemoved(identification: IdentifiedUserSchema): boolean { + return config.userCanModerate || identification.user.id === config.userId; + }, - /** - * Check if an identification can be removed by the currently logged user - */ - canBeRemoved(identification: IdentifiedUserSchema): boolean { - return config.userIsSasAdmin || identification.user.id === config.userId; - }, - - /** - * Untag a user from the current picture - */ - async removeIdentification(identification: IdentifiedUserSchema): Promise { - const res = await usersidentifiedDeleteRelation({ - // biome-ignore lint/style/useNamingConvention: api is in snake_case - path: { relation_id: identification.id }, - }); - if (!res.error && Array.isArray(this.currentPicture.identifications)) { - this.currentPicture.identifications = - this.currentPicture.identifications.filter( - (i: IdentifiedUserSchema) => i.id !== identification.id, - ); - } - }, - })); - }); + /** + * Untag a user from the current picture + */ + async removeIdentification(identification: IdentifiedUserSchema): Promise { + const res = await usersidentifiedDeleteRelation({ + // biome-ignore lint/style/useNamingConvention: api is in snake_case + path: { relation_id: identification.id }, + }); + if (!res.error && Array.isArray(this.currentPicture.identifications)) { + this.currentPicture.identifications = + this.currentPicture.identifications.filter( + (i: IdentifiedUserSchema) => i.id !== identification.id, + ); + } + }, + })); }); diff --git a/sas/templates/sas/picture.jinja b/sas/templates/sas/picture.jinja index b68312d5..b37f232d 100644 --- a/sas/templates/sas/picture.jinja +++ b/sas/templates/sas/picture.jinja @@ -17,10 +17,8 @@ {% from "sas/macros.jinja" import print_path %} -{% set user_is_sas_admin = user.is_root or user.is_in_group(pk = settings.SITH_GROUP_SAS_ADMIN_ID) %} - {% block content %} -
+
SAS / {{ print_path(album) }} @@ -50,15 +48,13 @@ It will be hidden to other users until it has been moderated. {% endtrans %}

- {% if user_is_sas_admin %} + {% if user.has_perm("sas.moderate_sasfile") %}