From 84ed180c1e49efdd7d856ff89c5b361f12039c40 Mon Sep 17 00:00:00 2001 From: imperosol Date: Sat, 21 Feb 2026 18:33:55 +0100 Subject: [PATCH] refactor sas moderation view permission --- sas/static/bundled/sas/viewer-index.ts | 417 ++++++++++++------------- sas/templates/sas/picture.jinja | 29 +- sas/tests/test_views.py | 16 +- sas/views.py | 29 +- 4 files changed, 239 insertions(+), 252 deletions(-) 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") %}