diff --git a/core/static/bundled/user/pictures-index.js b/core/static/bundled/user/pictures-index.js deleted file mode 100644 index 68f08d25..00000000 --- a/core/static/bundled/user/pictures-index.js +++ /dev/null @@ -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; - }, - })); - }); -}; diff --git a/core/static/core/style.scss b/core/static/core/style.scss index 06208579..22c8a583 100644 --- a/core/static/core/style.scss +++ b/core/static/core/style.scss @@ -272,9 +272,12 @@ body { } } - i { - margin-right: 4px; + &:not(.btn-no-text) { + i { + margin-right: 4px; + } } + } /** diff --git a/core/tests/test_files.py b/core/tests/test_files.py index 998ceab5..2f86507a 100644 --- a/core/tests/test_files.py +++ b/core/tests/test_files.py @@ -64,40 +64,6 @@ class TestImageAccess: 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: # - renaming a page # - changing a page's parent --> check that page's children's full_name diff --git a/core/urls.py b/core/urls.py index a3b4f7d8..23fa9f11 100644 --- a/core/urls.py +++ b/core/urls.py @@ -23,6 +23,7 @@ # from django.urls import path, re_path, register_converter +from django.views.generic import RedirectView from core.converters import ( BooleanStringConverter, @@ -68,7 +69,6 @@ from core.views import ( UserGodfathersView, UserListView, UserMiniView, - UserPicturesView, UserPreferencesView, UserStatsView, UserToolsView, @@ -144,7 +144,8 @@ urlpatterns = [ path("user//mini/", UserMiniView.as_view(), name="user_profile_mini"), path("user//", UserView.as_view(), name="user_profile"), path( - "user//pictures/", UserPicturesView.as_view(), name="user_pictures" + "user//pictures/", + RedirectView.as_view(pattern_name="sas:user_pictures", permanent=True), ), path( "user//godfathers/", diff --git a/core/views/user.py b/core/views/user.py index d742a6f5..8e7b092c 100644 --- a/core/views/user.py +++ b/core/views/user.py @@ -200,7 +200,7 @@ class UserTabsMixin(TabedViewMixin): "name": _("Family"), }, { - "url": reverse("core:user_pictures", kwargs={"user_id": user.id}), + "url": reverse("sas:user_pictures", kwargs={"user_id": user.id}), "slug": "pictures", "name": _("Pictures"), }, @@ -297,16 +297,6 @@ class UserView(UserTabsMixin, CanViewMixin, DetailView): 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): user_is_admin = request.user.is_root or request.user.is_board_member if user_id != request.user.id and not user_is_admin: diff --git a/locale/fr/LC_MESSAGES/django.po b/locale/fr/LC_MESSAGES/django.po index 25f49724..ea259ce0 100644 --- a/locale/fr/LC_MESSAGES/django.po +++ b/locale/fr/LC_MESSAGES/django.po @@ -6,7 +6,7 @@ msgid "" msgstr "" "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" "Last-Translator: Maréchal \n" @@ -935,6 +935,10 @@ msgstr "rôle" msgid "description" msgstr "description" +#: club/models.py +msgid "past member" +msgstr "ancien membre" + #: club/models.py msgid "Email address" msgstr "Adresse email" @@ -1425,8 +1429,9 @@ msgstr "" #: com/templates/com/macros.jinja #, python-format msgid "" -"This event will take place every week for %(nb)s weeks. If you moderate or delete " -"this event, it will also be moderated (or deleted) for the following weeks." +"This event will take place every week for %(nb)s weeks. If you moderate or " +"delete this event, it will also be moderated (or deleted) for the following " +"weeks." msgstr "" "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é) " @@ -3070,20 +3075,6 @@ msgstr "Éditer les groupes pour %(user_name)s" msgid "User list" 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 msgid "Preferences" msgstr "Préférences" @@ -3336,8 +3327,8 @@ msgstr "Nom d'utilisateur, email, ou numéro de compte AE" #: core/views/forms.py msgid "" -"Profile: you need to be visible on the picture, in order to be recognized (e." -"g. by the barmen)" +"Profile: you need to be visible on the picture, in order to be recognized " +"(e.g. by the barmen)" msgstr "" "Photo de profil: vous devez être visible sur la photo afin d'être reconnu " "(par exemple par les barmen)" @@ -3947,8 +3938,8 @@ msgstr "" #: counter/templates/counter/mails/account_dump.jinja msgid "If you think this was a mistake, please mail us at ae@utbm.fr." msgstr "" -"Si vous pensez qu'il s'agit d'une erreur, veuillez envoyer un mail à ae@utbm." -"fr." +"Si vous pensez qu'il s'agit d'une erreur, veuillez envoyer un mail à " +"ae@utbm.fr." #: counter/templates/counter/mails/account_dump.jinja msgid "" @@ -5217,6 +5208,15 @@ msgstr "SAS" msgid "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 msgid "Upload" msgstr "Envoyer" @@ -5286,6 +5286,15 @@ msgstr "Personne(s)" msgid "Identify users on pictures" 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 msgid "English" msgstr "Anglais" @@ -6022,6 +6031,3 @@ msgstr "Vous ne pouvez plus écrire de commentaires, la date est passée." #, python-format msgid "Maximum characters: %(max_length)s" msgstr "Nombre de caractères max: %(max_length)s" - -#~ msgid "past member" -#~ msgstr "ancien membre" diff --git a/sas/api.py b/sas/api.py index 96bafb87..11355de5 100644 --- a/sas/api.py +++ b/sas/api.py @@ -104,7 +104,7 @@ class PicturesController(ControllerBase): viewed=False, type="NEW_PICTURES", defaults={ - "url": reverse("core:user_pictures", kwargs={"user_id": u.id}) + "url": reverse("sas:user_pictures", kwargs={"user_id": u.id}) }, ) diff --git a/sas/schemas.py b/sas/schemas.py index 5e049858..d606219b 100644 --- a/sas/schemas.py +++ b/sas/schemas.py @@ -39,6 +39,8 @@ class PictureSchema(ModelSchema): compressed_url: str thumb_url: str album: str + report_url: str + edit_url: str @staticmethod def resolve_sas_url(obj: Picture) -> str: @@ -56,6 +58,14 @@ class PictureSchema(ModelSchema): def resolve_thumb_url(obj: Picture) -> str: 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): picture: NonNegativeInt diff --git a/sas/static/bundled/sas/album-index.js b/sas/static/bundled/sas/album-index.js deleted file mode 100644 index f09fa6b2..00000000 --- a/sas/static/bundled/sas/album-index.js +++ /dev/null @@ -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); - }, - })); - }); -}; diff --git a/sas/static/bundled/sas/album-index.ts b/sas/static/bundled/sas/album-index.ts new file mode 100644 index 00000000..ff0976d9 --- /dev/null +++ b/sas/static/bundled/sas/album-index.ts @@ -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); + }, + })); +}); diff --git a/sas/static/bundled/sas/pictures-download-index.ts b/sas/static/bundled/sas/pictures-download-index.ts new file mode 100644 index 00000000..21ee9989 --- /dev/null +++ b/sas/static/bundled/sas/pictures-download-index.ts @@ -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; + }, + })); +}); diff --git a/sas/static/bundled/sas/user/pictures-index.ts b/sas/static/bundled/sas/user/pictures-index.ts new file mode 100644 index 00000000..d3cd83ae --- /dev/null +++ b/sas/static/bundled/sas/user/pictures-index.ts @@ -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, + + 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, picture: PictureSchema) => { + if (!acc[picture.album]) { + acc[picture.album] = []; + } + acc[picture.album].push(picture); + return acc; + }, + {}, + ); + this.loading = false; + }, + })); +}); diff --git a/sas/static/sas/css/album.scss b/sas/static/sas/css/album.scss index fdc317c2..cec61de8 100644 --- a/sas/static/sas/css/album.scss +++ b/sas/static/sas/css/album.scss @@ -20,8 +20,8 @@ main { flex-wrap: wrap; gap: 5px; - > a, - > input { + >a, + >input { padding: 0.4em; margin: 0.1em; font-size: 1.2em; @@ -46,14 +46,14 @@ main { display: flex; flex-direction: column; - > .inputs { + >.inputs { align-items: flex-end; display: flex; flex-direction: row; flex-wrap: wrap; gap: 10px; - > p { + >p { box-sizing: border-box; max-width: 300px; width: 100%; @@ -62,7 +62,7 @@ main { max-width: 100%; } - > input { + >input { box-sizing: border-box; max-width: 100%; width: 100%; @@ -72,8 +72,8 @@ main { } } - > div > input, - > input { + >div>input, + >input { box-sizing: border-box; height: 40px; width: 100%; @@ -84,12 +84,12 @@ main { } } - > div { + >div { width: 100%; max-width: 300px; } - > input[type=submit]:hover { + >input[type=submit]:hover { background-color: #287fb8; color: white; } @@ -100,27 +100,27 @@ main { .clipboard { margin-top: 10px; padding: 10px; - background-color: rgba(0,0,0,.1); + background-color: rgba(0, 0, 0, .1); border-radius: 10px; } .photos, .albums { 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; display: flex; flex-direction: row; flex-wrap: wrap; gap: 5px; - > div { + >div { background: rgba(0, 0, 0, .5); cursor: not-allowed; } - > div, - > a { + >div, + >a { box-sizing: border-box; position: relative; height: 128px; @@ -138,7 +138,7 @@ main { background: rgba(0, 0, 0, .5); } - > input[type=checkbox] { + >input[type=checkbox] { position: absolute; top: 0; right: 0; @@ -149,8 +149,8 @@ main { cursor: pointer; } - > .photo, - > .album { + >.photo, + >.album { box-sizing: border-box; background-color: #333333; background-size: contain; @@ -166,25 +166,32 @@ main { border: 1px solid rgba(0, 0, 0, .3); + >img { + object-position: top bottom; + object-fit: contain; + height: 100%; + width: 100% + } + @media (max-width: 500px) { width: 100%; height: 100%; } - &:hover > .text { + &:hover>.text { background-color: rgba(0, 0, 0, .5); } - &:hover > .overlay { + &:hover>.overlay { -webkit-backdrop-filter: blur(2px); backdrop-filter: blur(2px); - ~ .text { + ~.text { background-color: transparent; } } - > .text { + >.text { position: absolute; box-sizing: border-box; top: 0; @@ -201,7 +208,7 @@ main { color: white; } - > .overlay { + >.overlay { position: absolute; width: 100%; height: 100%; @@ -227,14 +234,14 @@ main { } } - > .album > div { + >.album>div { background: rgba(0, 0, 0, .5); background: linear-gradient(0deg, rgba(0, 0, 0, .5) 0%, rgba(0, 0, 0, 0) 100%); text-align: left; word-break: break-word; } - > .photo > .text { + >.photo>.text { align-items: center; padding-bottom: 30px; } diff --git a/sas/templates/sas/album.jinja b/sas/templates/sas/album.jinja index 70aa79df..172e81ab 100644 --- a/sas/templates/sas/album.jinja +++ b/sas/templates/sas/album.jinja @@ -1,12 +1,14 @@ {% extends "core/base.jinja" %} {% from 'core/macros.jinja' import paginate_alpine %} +{% from "sas/macros.jinja" import download_button %} {%- block additional_css -%} {%- endblock -%} {%- block additional_js -%} - + + {%- endblock -%} {% block title %} @@ -27,7 +29,6 @@ {% if is_sas_admin %}
{% csrf_token %} -

{{ album.get_display_name() }}

@@ -63,16 +64,22 @@
{% endif %} -
+
+ + {{ download_button(_("Download album")) }} +

{% trans %}Pictures{% endtrans %}

-