refactor sas moderation view permission

This commit is contained in:
imperosol
2026-02-21 18:33:55 +01:00
parent 52759764a1
commit 84ed180c1e
4 changed files with 239 additions and 252 deletions

View File

@@ -109,232 +109,225 @@ interface ViewerConfig {
/** id of the first picture to load on the page */ /** id of the first picture to load on the page */
firstPictureId: number; firstPictureId: number;
/** if the user is sas admin */ /** if the user is sas admin */
userIsSasAdmin: boolean; userCanModerate: boolean;
} }
/** /**
* Load user picture page with a nice download bar * Load user picture page with a nice download bar
**/ **/
exportToHtml("loadViewer", (config: ViewerConfig) => { document.addEventListener("alpine:init", () => {
document.addEventListener("alpine:init", () => { Alpine.data("picture_viewer", (config: ViewerConfig) => ({
Alpine.data("picture_viewer", () => ({ /**
/** * All the pictures that can be displayed on this picture viewer
* All the pictures that can be displayed on this picture viewer **/
**/ pictures: [] as PictureWithIdentifications[],
pictures: [] as PictureWithIdentifications[], /**
/** * The currently displayed picture
* The currently displayed picture * Default dummy data are pre-loaded to avoid javascript error
* Default dummy data are pre-loaded to avoid javascript error * when loading the page at the beginning
* when loading the page at the beginning * @type PictureWithIdentifications
* @type PictureWithIdentifications **/
**/ currentPicture: {
currentPicture: { // biome-ignore lint/style/useNamingConvention: api is in snake_case
// biome-ignore lint/style/useNamingConvention: api is in snake_case is_moderated: true,
is_moderated: true, id: null as number,
id: null as number, name: "",
name: "", // biome-ignore lint/style/useNamingConvention: api is in snake_case
// biome-ignore lint/style/useNamingConvention: api is in snake_case display_name: "",
display_name: "", // biome-ignore lint/style/useNamingConvention: api is in snake_case
// biome-ignore lint/style/useNamingConvention: api is in snake_case compressed_url: "",
compressed_url: "", // biome-ignore lint/style/useNamingConvention: api is in snake_case
// biome-ignore lint/style/useNamingConvention: api is in snake_case profile_url: "",
profile_url: "", // biome-ignore lint/style/useNamingConvention: api is in snake_case
// biome-ignore lint/style/useNamingConvention: api is in snake_case full_size_url: "",
full_size_url: "", owner: "",
owner: "", date: new Date(),
date: new Date(), identifications: [] as IdentifiedUserSchema[],
identifications: [] as IdentifiedUserSchema[], },
}, /**
/** * The picture which will be displayed next if the user press the "next" button
* The picture which will be displayed next if the user press the "next" button **/
**/ nextPicture: null as PictureWithIdentifications,
nextPicture: null as PictureWithIdentifications, /**
/** * The picture which will be displayed next if the user press the "previous" button
* The picture which will be displayed next if the user press the "previous" button **/
**/ previousPicture: null as PictureWithIdentifications,
previousPicture: null as PictureWithIdentifications, /**
/** * The select2 component used to identify users
* The select2 component used to identify users **/
**/ selector: undefined as UserAjaxSelect,
selector: undefined as UserAjaxSelect, /**
/** * Error message when a moderation operation fails
* Error message when a moderation operation fails **/
**/ moderationError: "",
moderationError: "", /**
/** * Method of pushing new url to the browser history
* Method of pushing new url to the browser history * Used by popstate event and always reset to it's default value when used
* Used by popstate event and always reset to it's default value when used **/
**/ pushstate: History.Push,
pushstate: History.Push,
async init() { async init() {
this.pictures = ( this.pictures = (
await paginated(picturesFetchPictures, { await paginated(picturesFetchPictures, {
// biome-ignore lint/style/useNamingConvention: api is in snake_case // biome-ignore lint/style/useNamingConvention: api is in snake_case
query: { album_id: config.albumId }, query: { album_id: config.albumId },
} as PicturesFetchPicturesData) } as PicturesFetchPicturesData)
).map(PictureWithIdentifications.fromPicture); ).map(PictureWithIdentifications.fromPicture);
this.selector = this.$refs.search; this.selector = this.$refs.search;
this.selector.setFilter((users: UserProfileSchema[]) => { this.selector.setFilter((users: UserProfileSchema[]) => {
const resp: UserProfileSchema[] = []; const resp: UserProfileSchema[] = [];
const ids = [ const ids = [
...(this.currentPicture.identifications || []).map( ...(this.currentPicture.identifications || []).map(
(i: IdentifiedUserSchema) => i.user.id, (i: IdentifiedUserSchema) => i.user.id,
), ),
]; ];
for (const user of users) { for (const user of users) {
if (!ids.includes(user.id)) { if (!ids.includes(user.id)) {
resp.push(user); resp.push(user);
}
} }
return resp; }
}); return resp;
this.currentPicture = this.pictures.find( });
(i: PictureSchema) => i.id === config.firstPictureId, this.currentPicture = this.pictures.find(
); (i: PictureSchema) => i.id === config.firstPictureId,
this.$watch( );
"currentPicture", this.$watch(
(current: PictureSchema, previous: PictureSchema) => { "currentPicture",
if (current === previous) { (current: PictureSchema, previous: PictureSchema) => {
/* Avoid recursive updates */ if (current === previous) {
return; /* Avoid recursive updates */
}
this.updatePicture();
},
);
window.addEventListener("popstate", async (event) => {
if (!event.state || event.state.sasPictureId === undefined) {
return; return;
} }
this.pushstate = History.Replace; this.updatePicture();
this.currentPicture = this.pictures.find( },
(i: PictureSchema) => );
i.id === Number.parseInt(event.state.sasPictureId, 10), window.addEventListener("popstate", async (event) => {
); if (!event.state || event.state.sasPictureId === undefined) {
});
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<void> {
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}`;
return; return;
} }
this.currentPicture.is_moderated = true; this.pushstate = History.Replace;
this.currentPicture.asked_for_removal = false; 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<void> {
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<void> {
const widget: TomSelect = this.selector.widget;
await picturesIdentifyUsers({
path: {
// biome-ignore lint/style/useNamingConvention: api is in snake_case // biome-ignore lint/style/useNamingConvention: api is in snake_case
path: { picture_id: this.currentPicture.id }, picture_id: this.currentPicture.id,
}); },
if (res.error) { body: widget.items.map((i: string) => Number.parseInt(i, 10)),
this.moderationError = `${gettext("Couldn't delete picture")} : ${(res.error as { detail: string }).detail}`; });
return; // refresh the identified users list
} await this.currentPicture.loadIdentifications({ forceReload: true });
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;
},
/** // Clear selection and cache of retrieved user so they can be filtered again
* Send the identification request and update the list of identified users. widget.clear(false);
*/ widget.clearOptions();
async submitIdentification(): Promise<void> { widget.setTextboxValue("");
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); * Check if an identification can be removed by the currently logged user
widget.clearOptions(); */
widget.setTextboxValue(""); canBeRemoved(identification: IdentifiedUserSchema): boolean {
}, return config.userCanModerate || identification.user.id === config.userId;
},
/** /**
* Check if an identification can be removed by the currently logged user * Untag a user from the current picture
*/ */
canBeRemoved(identification: IdentifiedUserSchema): boolean { async removeIdentification(identification: IdentifiedUserSchema): Promise<void> {
return config.userIsSasAdmin || identification.user.id === config.userId; const res = await usersidentifiedDeleteRelation({
}, // biome-ignore lint/style/useNamingConvention: api is in snake_case
path: { relation_id: identification.id },
/** });
* Untag a user from the current picture if (!res.error && Array.isArray(this.currentPicture.identifications)) {
*/ this.currentPicture.identifications =
async removeIdentification(identification: IdentifiedUserSchema): Promise<void> { this.currentPicture.identifications.filter(
const res = await usersidentifiedDeleteRelation({ (i: IdentifiedUserSchema) => i.id !== identification.id,
// 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,
);
}
},
}));
});
}); });

View File

@@ -17,10 +17,8 @@
{% from "sas/macros.jinja" import print_path %} {% 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 %} {% block content %}
<main x-data="picture_viewer"> <main x-data="picture_viewer(config)">
<code> <code>
<a href="{{ url('sas:main') }}">SAS</a> / {{ print_path(album) }} <span x-text="currentPicture.name"></span> <a href="{{ url('sas:main') }}">SAS</a> / {{ print_path(album) }} <span x-text="currentPicture.name"></span>
</code> </code>
@@ -50,15 +48,13 @@
It will be hidden to other users until it has been moderated. It will be hidden to other users until it has been moderated.
{% endtrans %} {% endtrans %}
</p> </p>
{% if user_is_sas_admin %} {% if user.has_perm("sas.moderate_sasfile") %}
<template x-if="currentPicture.asked_for_removal"> <template x-if="currentPicture.asked_for_removal">
<div> <div>
<h5>{% trans %}The following issues have been raised:{% endtrans %}</h5> <h5>{% trans %}The following issues have been raised:{% endtrans %}</h5>
<template x-for="req in (currentPicture.moderationRequests ?? [])" :key="req.id"> <template x-for="req in (currentPicture.moderationRequests ?? [])" :key="req.id">
<div> <div>
<h6 <h6 x-text="`${req.author.first_name} ${req.author.last_name}`"></h6>
x-text="`${req.author.first_name} ${req.author.last_name}`"
></h6>
<i x-text="Intl.DateTimeFormat( <i x-text="Intl.DateTimeFormat(
'{{ LANGUAGE_CODE }}', '{{ LANGUAGE_CODE }}',
{dateStyle: 'long', timeStyle: 'short'} {dateStyle: 'long', timeStyle: 'short'}
@@ -70,7 +66,7 @@
</template> </template>
{% endif %} {% endif %}
</div> </div>
{% if user_is_sas_admin %} {% if user.has_perm("sas.moderate_sasfile") %}
<div class="alert-aside"> <div class="alert-aside">
<button class="btn btn-blue" @click="moderatePicture()"> <button class="btn btn-blue" @click="moderatePicture()">
{% trans %}Moderate{% endtrans %} {% trans %}Moderate{% endtrans %}
@@ -204,16 +200,13 @@
{% endblock %} {% endblock %}
{% block script %} {% block script %}
{{ super() }}
<script> <script>
window.addEventListener("DOMContentLoaded", () => { const config = {
loadViewer({ albumId: {{ album.id }},
albumId: {{ album.id }} , albumUrl: "{{ album.get_absolute_url() }}",
albumUrl: "{{ album.get_absolute_url() }}", firstPictureId: {{ picture.id }}, {# id of the first picture to show after page load #}
firstPictureId: {{ picture.id }}, {# id of the first picture to show after page load #} userId: {{ user.id }},
userId: {{ user.id }}, userCanModerate: {{ user.has_perm("sas.moderate_sasfile")|tojson }}
userIsSasAdmin: {{ user_is_sas_admin|tojson }} }
});
})
</script> </script>
{% endblock %} {% endblock %}

View File

@@ -161,16 +161,22 @@ class TestSasModeration(TestCase):
assert len(res.context_data["pictures"]) == 1 assert len(res.context_data["pictures"]) == 1
assert res.context_data["pictures"][0] == self.to_moderate assert res.context_data["pictures"][0] == self.to_moderate
res = self.client.post(
reverse("sas:moderation"),
data={"album_id": self.to_moderate.id, "picture_id": self.to_moderate.id},
)
def test_moderation_page_forbidden(self): def test_moderation_page_forbidden(self):
self.client.force_login(self.simple_user) self.client.force_login(self.simple_user)
res = self.client.get(reverse("sas:moderation")) res = self.client.get(reverse("sas:moderation"))
assert res.status_code == 403 assert res.status_code == 403
def test_moderate_album(self):
self.client.force_login(self.moderator)
url = reverse("sas:moderation")
album = baker.make(
Album, is_moderated=False, parent_id=settings.SITH_SAS_ROOT_DIR_ID
)
res = self.client.post(url, data={"album_id": album.id, "moderate": ""})
assertRedirects(res, url)
album.refresh_from_db()
assert album.is_moderated
def test_moderate_picture(self): def test_moderate_picture(self):
self.client.force_login(self.moderator) self.client.force_login(self.moderator)
res = self.client.get( res = self.client.get(

View File

@@ -15,10 +15,10 @@
from typing import Any from typing import Any
from django.conf import settings from django.conf import settings
from django.core.exceptions import PermissionDenied from django.contrib.auth.mixins import PermissionRequiredMixin
from django.db.models import Count, OuterRef, Subquery from django.db.models import Count, OuterRef, Subquery
from django.http import Http404, HttpResponseRedirect from django.http import Http404, HttpResponseRedirect
from django.shortcuts import get_object_or_404 from django.shortcuts import get_object_or_404, redirect
from django.urls import reverse from django.urls import reverse
from django.utils.safestring import SafeString from django.utils.safestring import SafeString
from django.views.generic import CreateView, DetailView, TemplateView from django.views.generic import CreateView, DetailView, TemplateView
@@ -191,26 +191,21 @@ class UserPicturesView(UserTabsMixin, CanViewMixin, DetailView):
# Admin views # Admin views
class ModerationView(TemplateView): class ModerationView(PermissionRequiredMixin, TemplateView):
template_name = "sas/moderation.jinja" template_name = "sas/moderation.jinja"
permission_required = "sas.moderate_sasfile"
def get(self, request, *args, **kwargs):
if request.user.is_in_group(pk=settings.SITH_GROUP_SAS_ADMIN_ID):
return super().get(request, *args, **kwargs)
raise PermissionDenied
def post(self, request, *args, **kwargs): def post(self, request, *args, **kwargs):
if "album_id" not in request.POST: if "album_id" not in request.POST:
raise Http404 raise Http404
if request.user.is_in_group(pk=settings.SITH_GROUP_SAS_ADMIN_ID): album = get_object_or_404(Album, pk=request.POST["album_id"])
album = get_object_or_404(Album, pk=request.POST["album_id"]) if "moderate" in request.POST:
if "moderate" in request.POST: album.moderator = request.user
album.moderator = request.user album.is_moderated = True
album.is_moderated = True album.save()
album.save() elif "delete" in request.POST:
elif "delete" in request.POST: album.delete()
album.delete() return redirect(self.request.path)
return super().get(request, *args, **kwargs)
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
kwargs = super().get_context_data(**kwargs) kwargs = super().get_context_data(**kwargs)