Merge pull request #1025 from ae-utbm/dl_pictures

download button for user pictures and albums
This commit is contained in:
Bartuccio Antoine 2025-02-24 07:39:00 +01:00 committed by GitHub
commit 8705fbe4b2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
20 changed files with 336 additions and 303 deletions

View File

@ -1,101 +0,0 @@
import { paginated } from "#core:utils/api";
import { HttpReader, ZipWriter } from "@zip.js/zip.js";
import { showSaveFilePicker } from "native-file-system-adapter";
import { picturesFetchPictures } from "#openapi";
/**
* @typedef UserProfile
* @property {number} id
* @property {string} first_name
* @property {string} last_name
* @property {string} nick_name
* @property {string} display_name
* @property {string} profile_url
* @property {string} profile_pict
*/
/**
* @typedef Picture
* @property {number} id
* @property {string} name
* @property {number} size
* @property {string} date
* @property {UserProfile} owner
* @property {string} full_size_url
* @property {string} compressed_url
* @property {string} thumb_url
* @property {string} album
* @property {boolean} is_moderated
* @property {boolean} asked_for_removal
*/
/**
* @typedef PicturePageConfig
* @property {number} userId Id of the user to get the pictures from
**/
/**
* Load user picture page with a nice download bar
* @param {PicturePageConfig} config
**/
window.loadPicturePage = (config) => {
document.addEventListener("alpine:init", () => {
Alpine.data("user_pictures", () => ({
isDownloading: false,
loading: true,
pictures: [],
albums: {},
async init() {
this.pictures = await paginated(picturesFetchPictures, {
// biome-ignore lint/style/useNamingConvention: api is in snake_case
query: { users_identified: [config.userId] },
});
this.albums = this.pictures.reduce((acc, picture) => {
if (!acc[picture.album]) {
acc[picture.album] = [];
}
acc[picture.album].push(picture);
return acc;
}, {});
this.loading = false;
},
async downloadZip() {
this.isDownloading = true;
const bar = this.$refs.progress;
bar.value = 0;
bar.max = this.pictures.length;
const incrementProgressBar = () => {
bar.value++;
};
const fileHandle = await showSaveFilePicker({
_preferPolyfill: false,
suggestedName: interpolate(
gettext("pictures.%(extension)s"),
{ extension: "zip" },
true,
),
types: {},
excludeAcceptAllOption: false,
});
const zipWriter = new ZipWriter(await fileHandle.createWritable());
await Promise.all(
this.pictures.map((p) => {
const imgName = `${p.album}/IMG_${p.date.replaceAll(/[:\-]/g, "_")}${p.name.slice(p.name.lastIndexOf("."))}`;
return zipWriter.add(imgName, new HttpReader(p.full_size_url), {
level: 9,
lastModDate: new Date(p.date),
onstart: incrementProgressBar,
});
}),
);
await zipWriter.close();
this.isDownloading = false;
},
}));
});
};

View File

@ -272,9 +272,12 @@ body {
} }
} }
i { &:not(.btn-no-text) {
margin-right: 4px; i {
margin-right: 4px;
}
} }
} }
/** /**

View File

@ -64,40 +64,6 @@ class TestImageAccess:
assert not picture.is_owned_by(user) assert not picture.is_owned_by(user)
@pytest.mark.django_db
class TestUserPicture:
def test_anonymous_user_unauthorized(self, client):
"""An anonymous user shouldn't have access to an user's photo page."""
response = client.get(
reverse(
"core:user_pictures",
kwargs={"user_id": User.objects.get(username="sli").pk},
)
)
assert response.status_code == 403
@pytest.mark.parametrize(
("username", "status"),
[
("guy", 403),
("root", 200),
("skia", 200),
("sli", 200),
],
)
def test_page_is_working(self, client, username, status):
"""Only user that subscribed (or admins) should be able to see the page."""
# Test for simple user
client.force_login(User.objects.get(username=username))
response = client.get(
reverse(
"core:user_pictures",
kwargs={"user_id": User.objects.get(username="sli").pk},
)
)
assert response.status_code == status
# TODO: many tests on the pages: # TODO: many tests on the pages:
# - renaming a page # - renaming a page
# - changing a page's parent --> check that page's children's full_name # - changing a page's parent --> check that page's children's full_name

View File

@ -23,6 +23,7 @@
# #
from django.urls import path, re_path, register_converter from django.urls import path, re_path, register_converter
from django.views.generic import RedirectView
from core.converters import ( from core.converters import (
BooleanStringConverter, BooleanStringConverter,
@ -68,7 +69,6 @@ from core.views import (
UserGodfathersView, UserGodfathersView,
UserListView, UserListView,
UserMiniView, UserMiniView,
UserPicturesView,
UserPreferencesView, UserPreferencesView,
UserStatsView, UserStatsView,
UserToolsView, UserToolsView,
@ -144,7 +144,8 @@ urlpatterns = [
path("user/<int:user_id>/mini/", UserMiniView.as_view(), name="user_profile_mini"), path("user/<int:user_id>/mini/", UserMiniView.as_view(), name="user_profile_mini"),
path("user/<int:user_id>/", UserView.as_view(), name="user_profile"), path("user/<int:user_id>/", UserView.as_view(), name="user_profile"),
path( path(
"user/<int:user_id>/pictures/", UserPicturesView.as_view(), name="user_pictures" "user/<int:user_id>/pictures/",
RedirectView.as_view(pattern_name="sas:user_pictures", permanent=True),
), ),
path( path(
"user/<int:user_id>/godfathers/", "user/<int:user_id>/godfathers/",

View File

@ -200,7 +200,7 @@ class UserTabsMixin(TabedViewMixin):
"name": _("Family"), "name": _("Family"),
}, },
{ {
"url": reverse("core:user_pictures", kwargs={"user_id": user.id}), "url": reverse("sas:user_pictures", kwargs={"user_id": user.id}),
"slug": "pictures", "slug": "pictures",
"name": _("Pictures"), "name": _("Pictures"),
}, },
@ -297,16 +297,6 @@ class UserView(UserTabsMixin, CanViewMixin, DetailView):
return kwargs return kwargs
class UserPicturesView(UserTabsMixin, CanViewMixin, DetailView):
"""Display a user's pictures."""
model = User
pk_url_kwarg = "user_id"
context_object_name = "profile"
template_name = "core/user_pictures.jinja"
current_tab = "pictures"
def delete_user_godfather(request, user_id, godfather_id, is_father): def delete_user_godfather(request, user_id, godfather_id, is_father):
user_is_admin = request.user.is_root or request.user.is_board_member user_is_admin = request.user.is_root or request.user.is_board_member
if user_id != request.user.id and not user_is_admin: if user_id != request.user.id and not user_is_admin:

View File

@ -6,7 +6,7 @@
msgid "" msgid ""
msgstr "" msgstr ""
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-02-12 15:55+0100\n" "POT-Creation-Date: 2025-02-18 15:03+0100\n"
"PO-Revision-Date: 2016-07-18\n" "PO-Revision-Date: 2016-07-18\n"
"Last-Translator: Maréchal <thomas.girod@utbm.fr\n" "Last-Translator: Maréchal <thomas.girod@utbm.fr\n"
"Language-Team: AE info <ae.info@utbm.fr>\n" "Language-Team: AE info <ae.info@utbm.fr>\n"
@ -935,6 +935,10 @@ msgstr "rôle"
msgid "description" msgid "description"
msgstr "description" msgstr "description"
#: club/models.py
msgid "past member"
msgstr "ancien membre"
#: club/models.py #: club/models.py
msgid "Email address" msgid "Email address"
msgstr "Adresse email" msgstr "Adresse email"
@ -1425,8 +1429,9 @@ msgstr ""
#: com/templates/com/macros.jinja #: com/templates/com/macros.jinja
#, python-format #, python-format
msgid "" msgid ""
"This event will take place every week for %(nb)s weeks. If you moderate or delete " "This event will take place every week for %(nb)s weeks. If you moderate or "
"this event, it will also be moderated (or deleted) for the following weeks." "delete this event, it will also be moderated (or deleted) for the following "
"weeks."
msgstr "" msgstr ""
"Cet événement se déroulera chaque semaine pendant %(nb)s semaines. Si vous " "Cet événement se déroulera chaque semaine pendant %(nb)s semaines. Si vous "
"modérez ou supprimez cet événement, il sera également modéré (ou supprimé) " "modérez ou supprimez cet événement, il sera également modéré (ou supprimé) "
@ -3070,20 +3075,6 @@ msgstr "Éditer les groupes pour %(user_name)s"
msgid "User list" msgid "User list"
msgstr "Liste d'utilisateurs" msgstr "Liste d'utilisateurs"
#: core/templates/core/user_pictures.jinja
#, python-format
msgid "%(user_name)s's pictures"
msgstr "Photos de %(user_name)s"
#: core/templates/core/user_pictures.jinja
msgid "Download all my pictures"
msgstr "Télécharger toutes mes photos"
#: core/templates/core/user_pictures.jinja sas/templates/sas/album.jinja
#: sas/templates/sas/macros.jinja
msgid "To be moderated"
msgstr "A modérer"
#: core/templates/core/user_preferences.jinja core/views/user.py #: core/templates/core/user_preferences.jinja core/views/user.py
msgid "Preferences" msgid "Preferences"
msgstr "Préférences" msgstr "Préférences"
@ -3336,8 +3327,8 @@ msgstr "Nom d'utilisateur, email, ou numéro de compte AE"
#: core/views/forms.py #: core/views/forms.py
msgid "" msgid ""
"Profile: you need to be visible on the picture, in order to be recognized (e." "Profile: you need to be visible on the picture, in order to be recognized "
"g. by the barmen)" "(e.g. by the barmen)"
msgstr "" msgstr ""
"Photo de profil: vous devez être visible sur la photo afin d'être reconnu " "Photo de profil: vous devez être visible sur la photo afin d'être reconnu "
"(par exemple par les barmen)" "(par exemple par les barmen)"
@ -3947,8 +3938,8 @@ msgstr ""
#: counter/templates/counter/mails/account_dump.jinja #: counter/templates/counter/mails/account_dump.jinja
msgid "If you think this was a mistake, please mail us at ae@utbm.fr." msgid "If you think this was a mistake, please mail us at ae@utbm.fr."
msgstr "" msgstr ""
"Si vous pensez qu'il s'agit d'une erreur, veuillez envoyer un mail à ae@utbm." "Si vous pensez qu'il s'agit d'une erreur, veuillez envoyer un mail à "
"fr." "ae@utbm.fr."
#: counter/templates/counter/mails/account_dump.jinja #: counter/templates/counter/mails/account_dump.jinja
msgid "" msgid ""
@ -5217,6 +5208,15 @@ msgstr "SAS"
msgid "Albums" msgid "Albums"
msgstr "Albums" msgstr "Albums"
#: sas/templates/sas/album.jinja
msgid "Download album"
msgstr "Télécharger l'album"
#: sas/templates/sas/album.jinja sas/templates/sas/macros.jinja
#: sas/templates/sas/user_pictures.jinja
msgid "To be moderated"
msgstr "A modérer"
#: sas/templates/sas/album.jinja #: sas/templates/sas/album.jinja
msgid "Upload" msgid "Upload"
msgstr "Envoyer" msgstr "Envoyer"
@ -5286,6 +5286,15 @@ msgstr "Personne(s)"
msgid "Identify users on pictures" msgid "Identify users on pictures"
msgstr "Identifiez les utilisateurs sur les photos" msgstr "Identifiez les utilisateurs sur les photos"
#: sas/templates/sas/user_pictures.jinja
#, python-format
msgid "%(user_name)s's pictures"
msgstr "Photos de %(user_name)s"
#: sas/templates/sas/user_pictures.jinja
msgid "Download all my pictures"
msgstr "Télécharger toutes mes photos"
#: sith/settings.py #: sith/settings.py
msgid "English" msgid "English"
msgstr "Anglais" msgstr "Anglais"
@ -6022,6 +6031,3 @@ msgstr "Vous ne pouvez plus écrire de commentaires, la date est passée."
#, python-format #, python-format
msgid "Maximum characters: %(max_length)s" msgid "Maximum characters: %(max_length)s"
msgstr "Nombre de caractères max: %(max_length)s" msgstr "Nombre de caractères max: %(max_length)s"
#~ msgid "past member"
#~ msgstr "ancien membre"

View File

@ -104,7 +104,7 @@ class PicturesController(ControllerBase):
viewed=False, viewed=False,
type="NEW_PICTURES", type="NEW_PICTURES",
defaults={ defaults={
"url": reverse("core:user_pictures", kwargs={"user_id": u.id}) "url": reverse("sas:user_pictures", kwargs={"user_id": u.id})
}, },
) )

View File

@ -39,6 +39,8 @@ class PictureSchema(ModelSchema):
compressed_url: str compressed_url: str
thumb_url: str thumb_url: str
album: str album: str
report_url: str
edit_url: str
@staticmethod @staticmethod
def resolve_sas_url(obj: Picture) -> str: def resolve_sas_url(obj: Picture) -> str:
@ -56,6 +58,14 @@ class PictureSchema(ModelSchema):
def resolve_thumb_url(obj: Picture) -> str: def resolve_thumb_url(obj: Picture) -> str:
return obj.get_download_thumb_url() return obj.get_download_thumb_url()
@staticmethod
def resolve_report_url(obj: Picture) -> str:
return reverse("sas:picture_ask_removal", kwargs={"picture_id": obj.id})
@staticmethod
def resolve_edit_url(obj: Picture) -> str:
return reverse("sas:picture_edit", kwargs={"picture_id": obj.id})
class PictureRelationCreationSchema(Schema): class PictureRelationCreationSchema(Schema):
picture: NonNegativeInt picture: NonNegativeInt

View File

@ -1,59 +0,0 @@
import { History, initialUrlParams, updateQueryString } from "#core:utils/history";
import { picturesFetchPictures } from "#openapi";
/**
* @typedef AlbumConfig
* @property {number} albumId id of the album to visualize
* @property {number} maxPageSize maximum number of elements to show on a page
**/
/**
* Create a family graph of an user
* @param {AlbumConfig} config
**/
window.loadAlbum = (config) => {
document.addEventListener("alpine:init", () => {
Alpine.data("pictures", () => ({
pictures: {},
page: Number.parseInt(initialUrlParams.get("page")) || 1,
pushstate: History.Push /* Used to avoid pushing a state on a back action */,
loading: false,
async init() {
await this.fetchPictures();
this.$watch("page", () => {
updateQueryString("page", this.page === 1 ? null : this.page, this.pushstate);
this.pushstate = History.Push;
this.fetchPictures();
});
window.addEventListener("popstate", () => {
this.pushstate = History.Replace;
this.page =
Number.parseInt(new URLSearchParams(window.location.search).get("page")) ||
1;
});
},
async fetchPictures() {
this.loading = true;
this.pictures = (
await picturesFetchPictures({
query: {
// biome-ignore lint/style/useNamingConvention: API is in snake_case
album_id: config.albumId,
page: this.page,
// biome-ignore lint/style/useNamingConvention: API is in snake_case
page_size: config.maxPageSize,
},
})
).data;
this.loading = false;
},
nbPages() {
return Math.ceil(this.pictures.count / config.maxPageSize);
},
}));
});
};

View File

@ -0,0 +1,58 @@
import { paginated } from "#core:utils/api";
import { History, initialUrlParams, updateQueryString } from "#core:utils/history";
import {
type PictureSchema,
type PicturesFetchPicturesData,
picturesFetchPictures,
} from "#openapi";
interface AlbumConfig {
albumId: number;
maxPageSize: number;
}
document.addEventListener("alpine:init", () => {
Alpine.data("pictures", (config: AlbumConfig) => ({
pictures: [] as PictureSchema[],
page: Number.parseInt(initialUrlParams.get("page")) || 1,
pushstate: History.Push /* Used to avoid pushing a state on a back action */,
loading: false,
async init() {
await this.fetchPictures();
this.$watch("page", () => {
updateQueryString("page", this.page === 1 ? null : this.page, this.pushstate);
this.pushstate = History.Push;
});
window.addEventListener("popstate", () => {
this.pushstate = History.Replace;
this.page =
Number.parseInt(new URLSearchParams(window.location.search).get("page")) || 1;
});
this.config = config;
},
getPage(page: number) {
return this.pictures.slice(
(page - 1) * config.maxPageSize,
config.maxPageSize * page,
);
},
async fetchPictures() {
this.loading = true;
this.pictures = await paginated(picturesFetchPictures, {
query: {
// biome-ignore lint/style/useNamingConvention: API is in snake_case
album_id: config.albumId,
} as PicturesFetchPicturesData["query"],
});
this.loading = false;
},
nbPages() {
return Math.ceil(this.pictures.length / config.maxPageSize);
},
}));
});

View File

@ -0,0 +1,46 @@
import { HttpReader, ZipWriter } from "@zip.js/zip.js";
import { showSaveFilePicker } from "native-file-system-adapter";
import type { PictureSchema } from "#openapi";
document.addEventListener("alpine:init", () => {
Alpine.data("pictures_download", () => ({
isDownloading: false,
async downloadZip() {
this.isDownloading = true;
const bar = this.$refs.progress;
bar.value = 0;
bar.max = this.pictures.length;
const incrementProgressBar = (_total: number): undefined => {
bar.value++;
return undefined;
};
const fileHandle = await showSaveFilePicker({
_preferPolyfill: false,
suggestedName: interpolate(
gettext("pictures.%(extension)s"),
{ extension: "zip" },
true,
),
excludeAcceptAllOption: false,
});
const zipWriter = new ZipWriter(await fileHandle.createWritable());
await Promise.all(
this.pictures.map((p: PictureSchema) => {
const imgName = `${p.album}/IMG_${p.date.replace(/[:\-]/g, "_")}${p.name.slice(p.name.lastIndexOf("."))}`;
return zipWriter.add(imgName, new HttpReader(p.full_size_url), {
level: 9,
lastModDate: new Date(p.date),
onstart: incrementProgressBar,
});
}),
);
await zipWriter.close();
this.isDownloading = false;
},
}));
});

View File

@ -0,0 +1,39 @@
import { paginated } from "#core:utils/api";
import {
type PictureSchema,
type PicturesFetchPicturesData,
picturesFetchPictures,
} from "#openapi";
interface PagePictureConfig {
userId: number;
}
document.addEventListener("alpine:init", () => {
Alpine.data("user_pictures", (config: PagePictureConfig) => ({
loading: true,
pictures: [] as PictureSchema[],
albums: {} as Record<string, PictureSchema[]>,
async init() {
this.pictures = await paginated(picturesFetchPictures, {
query: {
// biome-ignore lint/style/useNamingConvention: from python api
users_identified: [config.userId],
} as PicturesFetchPicturesData["query"],
});
this.albums = this.pictures.reduce(
(acc: Record<string, PictureSchema[]>, picture: PictureSchema) => {
if (!acc[picture.album]) {
acc[picture.album] = [];
}
acc[picture.album].push(picture);
return acc;
},
{},
);
this.loading = false;
},
}));
});

View File

@ -20,8 +20,8 @@ main {
flex-wrap: wrap; flex-wrap: wrap;
gap: 5px; gap: 5px;
> a, >a,
> input { >input {
padding: 0.4em; padding: 0.4em;
margin: 0.1em; margin: 0.1em;
font-size: 1.2em; font-size: 1.2em;
@ -46,14 +46,14 @@ main {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
> .inputs { >.inputs {
align-items: flex-end; align-items: flex-end;
display: flex; display: flex;
flex-direction: row; flex-direction: row;
flex-wrap: wrap; flex-wrap: wrap;
gap: 10px; gap: 10px;
> p { >p {
box-sizing: border-box; box-sizing: border-box;
max-width: 300px; max-width: 300px;
width: 100%; width: 100%;
@ -62,7 +62,7 @@ main {
max-width: 100%; max-width: 100%;
} }
> input { >input {
box-sizing: border-box; box-sizing: border-box;
max-width: 100%; max-width: 100%;
width: 100%; width: 100%;
@ -72,8 +72,8 @@ main {
} }
} }
> div > input, >div>input,
> input { >input {
box-sizing: border-box; box-sizing: border-box;
height: 40px; height: 40px;
width: 100%; width: 100%;
@ -84,12 +84,12 @@ main {
} }
} }
> div { >div {
width: 100%; width: 100%;
max-width: 300px; max-width: 300px;
} }
> input[type=submit]:hover { >input[type=submit]:hover {
background-color: #287fb8; background-color: #287fb8;
color: white; color: white;
} }
@ -100,27 +100,27 @@ main {
.clipboard { .clipboard {
margin-top: 10px; margin-top: 10px;
padding: 10px; padding: 10px;
background-color: rgba(0,0,0,.1); background-color: rgba(0, 0, 0, .1);
border-radius: 10px; border-radius: 10px;
} }
.photos, .photos,
.albums { .albums {
margin: 20px; margin: 20px;
min-height: 50px; // To contain the aria-busy loading wheel, even if empty min-height: 50px; // To contain the aria-busy loading wheel, even if empty
box-sizing: border-box; box-sizing: border-box;
display: flex; display: flex;
flex-direction: row; flex-direction: row;
flex-wrap: wrap; flex-wrap: wrap;
gap: 5px; gap: 5px;
> div { >div {
background: rgba(0, 0, 0, .5); background: rgba(0, 0, 0, .5);
cursor: not-allowed; cursor: not-allowed;
} }
> div, >div,
> a { >a {
box-sizing: border-box; box-sizing: border-box;
position: relative; position: relative;
height: 128px; height: 128px;
@ -138,7 +138,7 @@ main {
background: rgba(0, 0, 0, .5); background: rgba(0, 0, 0, .5);
} }
> input[type=checkbox] { >input[type=checkbox] {
position: absolute; position: absolute;
top: 0; top: 0;
right: 0; right: 0;
@ -149,8 +149,8 @@ main {
cursor: pointer; cursor: pointer;
} }
> .photo, >.photo,
> .album { >.album {
box-sizing: border-box; box-sizing: border-box;
background-color: #333333; background-color: #333333;
background-size: contain; background-size: contain;
@ -166,25 +166,32 @@ main {
border: 1px solid rgba(0, 0, 0, .3); border: 1px solid rgba(0, 0, 0, .3);
>img {
object-position: top bottom;
object-fit: contain;
height: 100%;
width: 100%
}
@media (max-width: 500px) { @media (max-width: 500px) {
width: 100%; width: 100%;
height: 100%; height: 100%;
} }
&:hover > .text { &:hover>.text {
background-color: rgba(0, 0, 0, .5); background-color: rgba(0, 0, 0, .5);
} }
&:hover > .overlay { &:hover>.overlay {
-webkit-backdrop-filter: blur(2px); -webkit-backdrop-filter: blur(2px);
backdrop-filter: blur(2px); backdrop-filter: blur(2px);
~ .text { ~.text {
background-color: transparent; background-color: transparent;
} }
} }
> .text { >.text {
position: absolute; position: absolute;
box-sizing: border-box; box-sizing: border-box;
top: 0; top: 0;
@ -201,7 +208,7 @@ main {
color: white; color: white;
} }
> .overlay { >.overlay {
position: absolute; position: absolute;
width: 100%; width: 100%;
height: 100%; height: 100%;
@ -227,14 +234,14 @@ main {
} }
} }
> .album > div { >.album>div {
background: rgba(0, 0, 0, .5); background: rgba(0, 0, 0, .5);
background: linear-gradient(0deg, rgba(0, 0, 0, .5) 0%, rgba(0, 0, 0, 0) 100%); background: linear-gradient(0deg, rgba(0, 0, 0, .5) 0%, rgba(0, 0, 0, 0) 100%);
text-align: left; text-align: left;
word-break: break-word; word-break: break-word;
} }
> .photo > .text { >.photo>.text {
align-items: center; align-items: center;
padding-bottom: 30px; padding-bottom: 30px;
} }

View File

@ -1,12 +1,14 @@
{% extends "core/base.jinja" %} {% extends "core/base.jinja" %}
{% from 'core/macros.jinja' import paginate_alpine %} {% from 'core/macros.jinja' import paginate_alpine %}
{% from "sas/macros.jinja" import download_button %}
{%- block additional_css -%} {%- block additional_css -%}
<link rel="stylesheet" href="{{ static('sas/css/album.scss') }}"> <link rel="stylesheet" href="{{ static('sas/css/album.scss') }}">
{%- endblock -%} {%- endblock -%}
{%- block additional_js -%} {%- block additional_js -%}
<script type="module" src="{{ static('bundled/sas/album-index.js') }}"></script> <script type="module" src="{{ static('bundled/sas/album-index.ts') }}"></script>
<script type="module" src="{{ static('bundled/sas/pictures-download-index.ts') }}"></script>
{%- endblock -%} {%- endblock -%}
{% block title %} {% block title %}
@ -27,7 +29,6 @@
{% if is_sas_admin %} {% if is_sas_admin %}
<form action="" method="post" enctype="multipart/form-data"> <form action="" method="post" enctype="multipart/form-data">
{% csrf_token %} {% csrf_token %}
<div class="album-navbar"> <div class="album-navbar">
<h3>{{ album.get_display_name() }}</h3> <h3>{{ album.get_display_name() }}</h3>
@ -63,16 +64,22 @@
<br> <br>
{% endif %} {% endif %}
<div x-data="pictures"> <div x-data="pictures({
albumId: {{ album.id }},
maxPageSize: {{ settings.SITH_SAS_IMAGES_PER_PAGE }},
})">
{{ download_button(_("Download album")) }}
<h4>{% trans %}Pictures{% endtrans %}</h4> <h4>{% trans %}Pictures{% endtrans %}</h4>
<div class="photos" :aria-busy="loading"> <div class="photos" :aria-busy="loading">
<template x-for="picture in pictures.results"> <template x-for="picture in getPage(page)">
<a :href="`/sas/picture/${picture.id}`"> <a :href="picture.sas_url">
<div <div
class="photo" class="photo"
:class="{not_moderated: !picture.is_moderated}" :class="{not_moderated: !picture.is_moderated}"
:style="`background-image: url(${picture.thumb_url})`"
> >
<img :src="picture.thumb_url" :alt="picture.name" loading="lazy" />
<template x-if="!picture.is_moderated"> <template x-if="!picture.is_moderated">
<div class="overlay">&nbsp;</div> <div class="overlay">&nbsp;</div>
<div class="text">{% trans %}To be moderated{% endtrans %}</div> <div class="text">{% trans %}To be moderated{% endtrans %}</div>
@ -112,13 +119,6 @@
{% block script %} {% block script %}
{{ super() }} {{ super() }}
<script> <script>
window.addEventListener("DOMContentLoaded", () => {
loadAlbum({
albumId: {{ album.id }},
maxPageSize: {{ settings.SITH_SAS_IMAGES_PER_PAGE }},
});
});
// Todo: migrate to alpine.js if we have some time // Todo: migrate to alpine.js if we have some time
$("form#upload_form").submit(function (event) { $("form#upload_form").submit(function (event) {
let formData = new FormData($(this)[0]); let formData = new FormData($(this)[0]);
@ -225,6 +225,5 @@
} }
}); });
</script> </script>
{% endblock %} {% endblock %}

View File

@ -2,15 +2,19 @@
<a href="{{ url('sas:album', album_id=a.id) }}"> <a href="{{ url('sas:album', album_id=a.id) }}">
{% if a.file %} {% if a.file %}
{% set img = a.get_download_url() %} {% set img = a.get_download_url() %}
{% set src = a.name %}
{% elif a.children.filter(is_folder=False, is_moderated=True).exists() %} {% elif a.children.filter(is_folder=False, is_moderated=True).exists() %}
{% set img = a.children.filter(is_folder=False).first().as_picture.get_download_thumb_url() %} {% set picture = a.children.filter(is_folder=False).first().as_picture %}
{% set img = picture.get_download_thumb_url() %}
{% set src = picture.name %}
{% else %} {% else %}
{% set img = static('core/img/sas.jpg') %} {% set img = static('core/img/sas.jpg') %}
{% set src = "sas.jpg" %}
{% endif %} {% endif %}
<div <div
class="album{% if not a.is_moderated %} not_moderated{% endif %}" class="album{% if not a.is_moderated %} not_moderated{% endif %}"
style="background-image: url('{{ img }}');"
> >
<img src="{{ img }}" alt="{{ src }}" loading="lazy" />
{% if not a.is_moderated %} {% if not a.is_moderated %}
<div class="overlay">&nbsp;</div> <div class="overlay">&nbsp;</div>
<div class="text">{% trans %}To be moderated{% endtrans %}</div> <div class="text">{% trans %}To be moderated{% endtrans %}</div>
@ -30,3 +34,31 @@
<a href="{{ url('sas:album', album_id=file.id) }}">{{ file.get_display_name() }}</a> / <a href="{{ url('sas:album', album_id=file.id) }}">{{ file.get_display_name() }}</a> /
{% endif %} {% endif %}
{% endmacro %} {% endmacro %}
{# Helper macro to create a download button for a
record of albums with alpine
This needs to be used inside an alpine environment.
Downloaded pictures will be `pictures` from the
parent data store.
Note:
This requires importing `bundled/sas/pictures-download-index.ts`
Parameters:
name (str): name displayed on the button
#}
{% macro download_button(name) %}
<div x-data="pictures_download">
<div x-show="pictures.length > 0" x-cloak>
<button
:disabled="isDownloading"
class="btn btn-blue {% if name == "" %}btn-no-text{% endif %}"
@click="downloadZip()"
>
<i class="fa fa-download"></i>{{ name }}
</button>
<progress x-ref="progress" x-show="isDownloading"></progress>
</div>
</div>
{% endmacro %}

View File

@ -114,12 +114,12 @@
{% trans %}HD version{% endtrans %} {% trans %}HD version{% endtrans %}
</a> </a>
<br> <br>
<a class="text danger" :href="`/sas/picture/${currentPicture.id}/report`"> <a class="text danger" :href="currentPicture.report_url">
{% trans %}Ask for removal{% endtrans %} {% trans %}Ask for removal{% endtrans %}
</a> </a>
</div> </div>
<div class="buttons"> <div class="buttons">
<a class="button" :href="`/sas/picture/${currentPicture.id}/edit/`"><i class="fa-regular fa-pen-to-square edit-action"></i></a> <a class="button" :href="currentPicture.edit_url"><i class="fa-regular fa-pen-to-square edit-action"></i></a>
<a class="button" href="?rotate_left"><i class="fa-solid fa-rotate-left"></i></a> <a class="button" href="?rotate_left"><i class="fa-solid fa-rotate-left"></i></a>
<a class="button" href="?rotate_right"><i class="fa-solid fa-rotate-right"></i></a> <a class="button" href="?rotate_right"><i class="fa-solid fa-rotate-right"></i></a>
</div> </div>

View File

@ -1,11 +1,13 @@
{% extends "core/base.jinja" %} {% extends "core/base.jinja" %}
{% from "sas/macros.jinja" import download_button %}
{%- block additional_css -%} {%- block additional_css -%}
<link rel="stylesheet" href="{{ static('sas/css/album.scss') }}"> <link rel="stylesheet" href="{{ static('sas/css/album.scss') }}">
{%- endblock -%} {%- endblock -%}
{% block additional_js %} {% block additional_js %}
<script type="module" src="{{ static('bundled/user/pictures-index.js') }}"></script> <script type="module" src="{{ static('bundled/sas/user/pictures-index.ts') }}"></script>
<script type="module" src="{{ static('bundled/sas/pictures-download-index.ts') }}"></script>
{% endblock %} {% endblock %}
{% block title %} {% block title %}
@ -13,33 +15,28 @@
{% endblock %} {% endblock %}
{% block content %} {% block content %}
<main x-data="user_pictures"> <main x-data="user_pictures({ userId: {{ object.id }} })">
{% if user.id == object.id %} {% if user.id == object.id %}
<div x-show="pictures.length > 0" x-cloak> {{ download_button(_("Download all my pictures")) }}
<button
:disabled="isDownloading"
class="btn btn-blue"
@click="downloadZip()"
>
<i class="fa fa-download"></i>
{% trans %}Download all my pictures{% endtrans %}
</button>
<progress x-ref="progress" x-show="isDownloading"></progress>
</div>
{% endif %} {% endif %}
<template x-for="[album, pictures] in Object.entries(albums)" x-cloak> <template x-for="[album, pictures] in Object.entries(albums)" x-cloak>
<section> <section>
<br /> <br />
<h4 x-text="album"></h4> <div class="row">
<h4 x-text="album"></h4>
{% if user.id == object.id %}
&nbsp;{{ download_button("") }}
{% endif %}
</div>
<div class="photos"> <div class="photos">
<template x-for="picture in pictures"> <template x-for="picture in pictures">
<a :href="`/sas/picture/${picture.id}`"> <a :href="picture.sas_url">
<div <div
class="photo" class="photo"
:class="{not_moderated: !picture.is_moderated}" :class="{not_moderated: !picture.is_moderated}"
:style="`background-image: url(${picture.thumb_url})`"
> >
<img :src="picture.thumb_url" :alt="picture.name" loading="lazy" />
<template x-if="!picture.is_moderated"> <template x-if="!picture.is_moderated">
<div class="overlay">&nbsp;</div> <div class="overlay">&nbsp;</div>
<div class="text">{% trans %}To be moderated{% endtrans %}</div> <div class="text">{% trans %}To be moderated{% endtrans %}</div>
@ -56,13 +53,3 @@
<div class="photos" :aria-busy="loading"></div> <div class="photos" :aria-busy="loading"></div>
</main> </main>
{% endblock content %} {% endblock content %}
{% block script %}
{{ super() }}
<script>
window.addEventListener("DOMContentLoaded", () => {
loadPicturePage({ userId: {{ object.id }} });
})
</script>
{% endblock script %}

View File

@ -171,3 +171,37 @@ class TestSasModeration(TestCase):
"Vous avez déjà déposé une demande de retrait pour cette photo.</li></ul>", "Vous avez déjà déposé une demande de retrait pour cette photo.</li></ul>",
res.content.decode(), res.content.decode(),
) )
@pytest.mark.django_db
class TestUserPicture:
def test_anonymous_user_unauthorized(self, client):
"""An anonymous user shouldn't have access to an user's photo page."""
response = client.get(
reverse(
"sas:user_pictures",
kwargs={"user_id": User.objects.get(username="sli").pk},
)
)
assert response.status_code == 403
@pytest.mark.parametrize(
("username", "status"),
[
("guy", 403),
("root", 200),
("skia", 200),
("sli", 200),
],
)
def test_page_is_working(self, client, username, status):
"""Only user that subscribed (or admins) should be able to see the page."""
# Test for simple user
client.force_login(User.objects.get(username=username))
response = client.get(
reverse(
"sas:user_pictures",
kwargs={"user_id": User.objects.get(username="sli").pk},
)
)
assert response.status_code == status

View File

@ -24,6 +24,7 @@ from sas.views import (
PictureEditView, PictureEditView,
PictureView, PictureView,
SASMainView, SASMainView,
UserPicturesView,
send_album, send_album,
send_compressed, send_compressed,
send_pict, send_pict,
@ -55,4 +56,7 @@ urlpatterns = [
name="download_compressed", name="download_compressed",
), ),
path("picture/<int:picture_id>/download/thumb/", send_thumb, name="download_thumb"), path("picture/<int:picture_id>/download/thumb/", send_thumb, name="download_thumb"),
path(
"user/<int:user_id>/pictures/", UserPicturesView.as_view(), name="user_pictures"
),
] ]

View File

@ -26,6 +26,7 @@ from django.views.generic.edit import FormMixin, FormView, UpdateView
from core.auth.mixins import CanEditMixin, CanViewMixin from core.auth.mixins import CanEditMixin, CanViewMixin
from core.models import SithFile, User from core.models import SithFile, User
from core.views.files import FileView, send_file from core.views.files import FileView, send_file
from core.views.user import UserTabsMixin
from sas.forms import ( from sas.forms import (
AlbumEditForm, AlbumEditForm,
PictureEditForm, PictureEditForm,
@ -193,6 +194,16 @@ class AlbumView(CanViewMixin, DetailView, FormMixin):
return kwargs return kwargs
class UserPicturesView(UserTabsMixin, CanViewMixin, DetailView):
"""Display a user's pictures."""
model = User
pk_url_kwarg = "user_id"
context_object_name = "profile"
template_name = "sas/user_pictures.jinja"
current_tab = "pictures"
# Admin views # Admin views