mirror of
https://github.com/ae-utbm/sith.git
synced 2026-03-13 15:15:03 +00:00
refactor sas moderation view permission
This commit is contained in:
@@ -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<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}`;
|
||||
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<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
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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,
|
||||
);
|
||||
}
|
||||
},
|
||||
}));
|
||||
});
|
||||
|
||||
@@ -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 %}
|
||||
<main x-data="picture_viewer">
|
||||
<main x-data="picture_viewer(config)">
|
||||
<code>
|
||||
<a href="{{ url('sas:main') }}">SAS</a> / {{ print_path(album) }} <span x-text="currentPicture.name"></span>
|
||||
</code>
|
||||
@@ -50,15 +48,13 @@
|
||||
It will be hidden to other users until it has been moderated.
|
||||
{% endtrans %}
|
||||
</p>
|
||||
{% if user_is_sas_admin %}
|
||||
{% if user.has_perm("sas.moderate_sasfile") %}
|
||||
<template x-if="currentPicture.asked_for_removal">
|
||||
<div>
|
||||
<h5>{% trans %}The following issues have been raised:{% endtrans %}</h5>
|
||||
<template x-for="req in (currentPicture.moderationRequests ?? [])" :key="req.id">
|
||||
<div>
|
||||
<h6
|
||||
x-text="`${req.author.first_name} ${req.author.last_name}`"
|
||||
></h6>
|
||||
<h6 x-text="`${req.author.first_name} ${req.author.last_name}`"></h6>
|
||||
<i x-text="Intl.DateTimeFormat(
|
||||
'{{ LANGUAGE_CODE }}',
|
||||
{dateStyle: 'long', timeStyle: 'short'}
|
||||
@@ -70,7 +66,7 @@
|
||||
</template>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if user_is_sas_admin %}
|
||||
{% if user.has_perm("sas.moderate_sasfile") %}
|
||||
<div class="alert-aside">
|
||||
<button class="btn btn-blue" @click="moderatePicture()">
|
||||
{% trans %}Moderate{% endtrans %}
|
||||
@@ -204,16 +200,13 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block script %}
|
||||
{{ super() }}
|
||||
<script>
|
||||
window.addEventListener("DOMContentLoaded", () => {
|
||||
loadViewer({
|
||||
albumId: {{ album.id }} ,
|
||||
albumUrl: "{{ album.get_absolute_url() }}",
|
||||
firstPictureId: {{ picture.id }}, {# id of the first picture to show after page load #}
|
||||
userId: {{ user.id }},
|
||||
userIsSasAdmin: {{ user_is_sas_admin|tojson }}
|
||||
});
|
||||
})
|
||||
const config = {
|
||||
albumId: {{ album.id }},
|
||||
albumUrl: "{{ album.get_absolute_url() }}",
|
||||
firstPictureId: {{ picture.id }}, {# id of the first picture to show after page load #}
|
||||
userId: {{ user.id }},
|
||||
userCanModerate: {{ user.has_perm("sas.moderate_sasfile")|tojson }}
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -161,16 +161,22 @@ class TestSasModeration(TestCase):
|
||||
assert len(res.context_data["pictures"]) == 1
|
||||
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):
|
||||
self.client.force_login(self.simple_user)
|
||||
res = self.client.get(reverse("sas:moderation"))
|
||||
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):
|
||||
self.client.force_login(self.moderator)
|
||||
res = self.client.get(
|
||||
|
||||
29
sas/views.py
29
sas/views.py
@@ -15,10 +15,10 @@
|
||||
from typing import Any
|
||||
|
||||
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.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.utils.safestring import SafeString
|
||||
from django.views.generic import CreateView, DetailView, TemplateView
|
||||
@@ -191,26 +191,21 @@ class UserPicturesView(UserTabsMixin, CanViewMixin, DetailView):
|
||||
# Admin views
|
||||
|
||||
|
||||
class ModerationView(TemplateView):
|
||||
class ModerationView(PermissionRequiredMixin, TemplateView):
|
||||
template_name = "sas/moderation.jinja"
|
||||
|
||||
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
|
||||
permission_required = "sas.moderate_sasfile"
|
||||
|
||||
def post(self, request, *args, **kwargs):
|
||||
if "album_id" not in request.POST:
|
||||
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"])
|
||||
if "moderate" in request.POST:
|
||||
album.moderator = request.user
|
||||
album.is_moderated = True
|
||||
album.save()
|
||||
elif "delete" in request.POST:
|
||||
album.delete()
|
||||
return super().get(request, *args, **kwargs)
|
||||
album = get_object_or_404(Album, pk=request.POST["album_id"])
|
||||
if "moderate" in request.POST:
|
||||
album.moderator = request.user
|
||||
album.is_moderated = True
|
||||
album.save()
|
||||
elif "delete" in request.POST:
|
||||
album.delete()
|
||||
return redirect(self.request.path)
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
kwargs = super().get_context_data(**kwargs)
|
||||
|
||||
Reference in New Issue
Block a user