Merge pull request #868 from ae-utbm/delete-picture-confirm-button

Delete picture confirm button
This commit is contained in:
thomas girod 2024-10-14 14:12:50 +02:00 committed by GitHub
commit 496ad7ce9b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 740 additions and 315 deletions

View File

@ -333,9 +333,18 @@ a:not(.button) {
border: #fc8181 1px solid; border: #fc8181 1px solid;
} }
.alert-title {
margin-top: 0;
}
.alert-main { .alert-main {
flex: 2; flex: 2;
} }
.alert-aside {
display: flex;
flex-direction: column;
}
} }
.tool_bar { .tool_bar {

View File

@ -148,7 +148,6 @@ import type {
GroupedDataFormat, GroupedDataFormat,
LoadingData, LoadingData,
Options, Options,
PlainObject,
} from "select2"; } from "select2";
import "select2/dist/css/select2.css"; import "select2/dist/css/select2.css";
@ -181,7 +180,7 @@ interface Select2Options {
* Create a new select2 with sith presets * Create a new select2 with sith presets
*/ */
export function sithSelect2(options: Select2Options) { export function sithSelect2(options: Select2Options) {
const elem: PlainObject = $(options.element); const elem = $(options.element as HTMLInputElement);
return elem.select2({ return elem.select2({
theme: elem[0].multiple ? "classic" : "default", theme: elem[0].multiple ? "classic" : "default",
minimumInputLength: 2, minimumInputLength: 2,

View File

@ -6,7 +6,7 @@
msgid "" msgid ""
msgstr "" msgstr ""
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2024-10-11 09:58+0200\n" "POT-Creation-Date: 2024-10-14 00:46+0200\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"
@ -129,7 +129,7 @@ msgstr "classeur"
#: accounting/models.py:273 core/models.py:959 core/models.py:1479 #: accounting/models.py:273 core/models.py:959 core/models.py:1479
#: core/models.py:1524 core/models.py:1553 core/models.py:1577 #: core/models.py:1524 core/models.py:1553 core/models.py:1577
#: counter/models.py:664 counter/models.py:768 counter/models.py:980 #: counter/models.py:664 counter/models.py:768 counter/models.py:980
#: eboutic/models.py:57 eboutic/models.py:189 forum/models.py:311 #: eboutic/models.py:57 eboutic/models.py:193 forum/models.py:311
#: forum/models.py:412 #: forum/models.py:412
msgid "date" msgid "date"
msgstr "date" msgstr "date"
@ -148,7 +148,7 @@ msgstr "méthode de paiement"
msgid "cheque number" msgid "cheque number"
msgstr "numéro de chèque" msgstr "numéro de chèque"
#: accounting/models.py:286 eboutic/models.py:287 #: accounting/models.py:286 eboutic/models.py:291
msgid "invoice" msgid "invoice"
msgstr "facture" msgstr "facture"
@ -210,7 +210,7 @@ msgstr "Utilisateur"
msgid "Club" msgid "Club"
msgstr "Club" msgstr "Club"
#: accounting/models.py:322 core/views/user.py:281 #: accounting/models.py:322 core/views/user.py:283
msgid "Account" msgid "Account"
msgstr "Compte" msgstr "Compte"
@ -378,14 +378,14 @@ msgstr "Compte en banque : "
#: launderette/views.py:217 pedagogy/templates/pedagogy/guide.jinja:99 #: launderette/views.py:217 pedagogy/templates/pedagogy/guide.jinja:99
#: pedagogy/templates/pedagogy/guide.jinja:114 #: pedagogy/templates/pedagogy/guide.jinja:114
#: pedagogy/templates/pedagogy/uv_detail.jinja:189 #: pedagogy/templates/pedagogy/uv_detail.jinja:189
#: sas/templates/sas/album.jinja:32 sas/templates/sas/moderation.jinja:18 #: sas/templates/sas/album.jinja:36 sas/templates/sas/moderation.jinja:18
#: sas/templates/sas/picture.jinja:50 trombi/templates/trombi/detail.jinja:35 #: sas/templates/sas/picture.jinja:69 trombi/templates/trombi/detail.jinja:35
#: trombi/templates/trombi/edit_profile.jinja:35 #: trombi/templates/trombi/edit_profile.jinja:35
msgid "Delete" msgid "Delete"
msgstr "Supprimer" msgstr "Supprimer"
#: accounting/templates/accounting/bank_account_details.jinja:18 #: accounting/templates/accounting/bank_account_details.jinja:18
#: club/views.py:79 core/views/user.py:200 sas/templates/sas/picture.jinja:72 #: club/views.py:79 core/views/user.py:202 sas/templates/sas/picture.jinja:89
msgid "Infos" msgid "Infos"
msgstr "Infos" msgstr "Infos"
@ -419,7 +419,7 @@ msgstr "Nouveau compte club"
#: com/templates/com/weekmail.jinja:61 core/templates/core/file.jinja:38 #: com/templates/com/weekmail.jinja:61 core/templates/core/file.jinja:38
#: core/templates/core/group_list.jinja:24 core/templates/core/page.jinja:35 #: core/templates/core/group_list.jinja:24 core/templates/core/page.jinja:35
#: core/templates/core/poster_list.jinja:40 #: core/templates/core/poster_list.jinja:40
#: core/templates/core/user_tools.jinja:71 core/views/user.py:230 #: core/templates/core/user_tools.jinja:71 core/views/user.py:232
#: counter/templates/counter/cash_summary_list.jinja:53 #: counter/templates/counter/cash_summary_list.jinja:53
#: counter/templates/counter/counter_list.jinja:17 #: counter/templates/counter/counter_list.jinja:17
#: counter/templates/counter/counter_list.jinja:33 #: counter/templates/counter/counter_list.jinja:33
@ -430,7 +430,7 @@ msgstr "Nouveau compte club"
#: pedagogy/templates/pedagogy/guide.jinja:98 #: pedagogy/templates/pedagogy/guide.jinja:98
#: pedagogy/templates/pedagogy/guide.jinja:113 #: pedagogy/templates/pedagogy/guide.jinja:113
#: pedagogy/templates/pedagogy/uv_detail.jinja:188 #: pedagogy/templates/pedagogy/uv_detail.jinja:188
#: sas/templates/sas/album.jinja:31 trombi/templates/trombi/detail.jinja:9 #: sas/templates/sas/album.jinja:35 trombi/templates/trombi/detail.jinja:9
#: trombi/templates/trombi/edit_profile.jinja:34 #: trombi/templates/trombi/edit_profile.jinja:34
msgid "Edit" msgid "Edit"
msgstr "Éditer" msgstr "Éditer"
@ -616,7 +616,7 @@ msgstr "No"
#: counter/templates/counter/last_ops.jinja:20 #: counter/templates/counter/last_ops.jinja:20
#: counter/templates/counter/last_ops.jinja:45 #: counter/templates/counter/last_ops.jinja:45
#: counter/templates/counter/refilling_list.jinja:16 #: counter/templates/counter/refilling_list.jinja:16
#: rootplace/templates/rootplace/logs.jinja:12 sas/views.py:310 #: rootplace/templates/rootplace/logs.jinja:12 sas/forms.py:90
#: trombi/templates/trombi/user_profile.jinja:40 #: trombi/templates/trombi/user_profile.jinja:40
msgid "Date" msgid "Date"
msgstr "Date" msgstr "Date"
@ -650,7 +650,7 @@ msgid "Done"
msgstr "Effectuées" msgstr "Effectuées"
#: accounting/templates/accounting/journal_details.jinja:41 #: accounting/templates/accounting/journal_details.jinja:41
#: counter/templates/counter/cash_summary_list.jinja:37 counter/views.py:944 #: counter/templates/counter/cash_summary_list.jinja:37 counter/views.py:962
#: pedagogy/templates/pedagogy/moderation.jinja:13 #: pedagogy/templates/pedagogy/moderation.jinja:13
#: pedagogy/templates/pedagogy/uv_detail.jinja:142 #: pedagogy/templates/pedagogy/uv_detail.jinja:142
#: trombi/templates/trombi/comment.jinja:4 #: trombi/templates/trombi/comment.jinja:4
@ -967,15 +967,15 @@ msgstr "Date de fin"
#: club/forms.py:160 club/templates/club/club_sellings.jinja:49 #: club/forms.py:160 club/templates/club/club_sellings.jinja:49
#: core/templates/core/user_account_detail.jinja:17 #: core/templates/core/user_account_detail.jinja:17
#: core/templates/core/user_account_detail.jinja:56 #: core/templates/core/user_account_detail.jinja:56
#: counter/templates/counter/cash_summary_list.jinja:33 counter/views.py:141 #: counter/templates/counter/cash_summary_list.jinja:33 counter/views.py:143
msgid "Counter" msgid "Counter"
msgstr "Comptoir" msgstr "Comptoir"
#: club/forms.py:167 counter/views.py:688 #: club/forms.py:167 counter/views.py:690
msgid "Products" msgid "Products"
msgstr "Produits" msgstr "Produits"
#: club/forms.py:172 counter/views.py:693 #: club/forms.py:172 counter/views.py:695
msgid "Archived products" msgid "Archived products"
msgstr "Produits archivés" msgstr "Produits archivés"
@ -997,7 +997,7 @@ msgstr "Vous ne pouvez pas ajouter deux fois le même utilisateur"
msgid "You should specify a role" msgid "You should specify a role"
msgstr "Vous devez choisir un rôle" msgstr "Vous devez choisir un rôle"
#: club/forms.py:283 sas/views.py:117 sas/views.py:241 #: club/forms.py:283 sas/views.py:58 sas/views.py:177
msgid "You do not have the permission to do that" msgid "You do not have the permission to do that"
msgstr "Vous n'avez pas la permission de faire cela" msgstr "Vous n'avez pas la permission de faire cela"
@ -1046,7 +1046,7 @@ msgid "A club with that unix_name already exists"
msgstr "Un club avec ce nom UNIX existe déjà." msgstr "Un club avec ce nom UNIX existe déjà."
#: club/models.py:337 counter/models.py:935 counter/models.py:971 #: club/models.py:337 counter/models.py:935 counter/models.py:971
#: eboutic/models.py:53 eboutic/models.py:185 election/models.py:183 #: eboutic/models.py:53 eboutic/models.py:189 election/models.py:183
#: launderette/models.py:136 launderette/models.py:198 sas/models.py:274 #: launderette/models.py:136 launderette/models.py:198 sas/models.py:274
#: trombi/models.py:206 #: trombi/models.py:206
msgid "user" msgid "user"
@ -1373,8 +1373,8 @@ msgstr "Anciens membres"
msgid "History" msgid "History"
msgstr "Historique" msgstr "Historique"
#: club/views.py:116 core/templates/core/base.jinja:107 core/views/user.py:223 #: club/views.py:116 core/templates/core/base.jinja:104 core/views/user.py:225
#: sas/templates/sas/picture.jinja:91 trombi/views.py:61 #: sas/templates/sas/picture.jinja:108 trombi/views.py:61
msgid "Tools" msgid "Tools"
msgstr "Outils" msgstr "Outils"
@ -1517,7 +1517,7 @@ msgstr "Administration des mailing listes"
#: com/templates/com/news_detail.jinja:39 #: com/templates/com/news_detail.jinja:39
#: core/templates/core/file_detail.jinja:65 #: core/templates/core/file_detail.jinja:65
#: core/templates/core/file_moderation.jinja:23 #: core/templates/core/file_moderation.jinja:23
#: sas/templates/sas/moderation.jinja:17 sas/templates/sas/picture.jinja:47 #: sas/templates/sas/moderation.jinja:17 sas/templates/sas/picture.jinja:66
msgid "Moderate" msgid "Moderate"
msgstr "Modérer" msgstr "Modérer"
@ -1613,7 +1613,7 @@ msgstr "Résumé"
#: com/templates/com/news_admin_list.jinja:252 #: com/templates/com/news_admin_list.jinja:252
#: com/templates/com/news_admin_list.jinja:289 #: com/templates/com/news_admin_list.jinja:289
#: com/templates/com/weekmail.jinja:17 com/templates/com/weekmail.jinja:46 #: com/templates/com/weekmail.jinja:17 com/templates/com/weekmail.jinja:46
#: forum/templates/forum/forum.jinja:55 #: forum/templates/forum/forum.jinja:55 sas/models.py:298
msgid "Author" msgid "Author"
msgstr "Auteur" msgstr "Auteur"
@ -1659,7 +1659,7 @@ msgid "Calls to moderate"
msgstr "Appels à modérer" msgstr "Appels à modérer"
#: com/templates/com/news_admin_list.jinja:242 #: com/templates/com/news_admin_list.jinja:242
#: core/templates/core/base.jinja:222 #: core/templates/core/base.jinja:219
msgid "Events" msgid "Events"
msgstr "Événements" msgstr "Événements"
@ -2246,7 +2246,7 @@ msgstr "avoir une notification pour chaque click"
msgid "get a notification for every refilling" msgid "get a notification for every refilling"
msgstr "avoir une notification pour chaque rechargement" msgstr "avoir une notification pour chaque rechargement"
#: core/models.py:914 sas/views.py:309 #: core/models.py:914 sas/forms.py:89
msgid "file name" msgid "file name"
msgstr "nom du fichier" msgstr "nom du fichier"
@ -2388,7 +2388,7 @@ msgstr "403, Non autorisé"
msgid "404, Not Found" msgid "404, Not Found"
msgstr "404. Non trouvé" msgstr "404. Non trouvé"
#: core/templates/core/500.jinja:11 #: core/templates/core/500.jinja:9
msgid "500, Server Error" msgid "500, Server Error"
msgstr "500, Erreur Serveur" msgstr "500, Erreur Serveur"
@ -2396,18 +2396,18 @@ msgstr "500, Erreur Serveur"
msgid "Welcome!" msgid "Welcome!"
msgstr "Bienvenue !" msgstr "Bienvenue !"
#: core/templates/core/base.jinja:59 core/templates/core/login.jinja:8 #: core/templates/core/base.jinja:56 core/templates/core/login.jinja:8
#: core/templates/core/login.jinja:18 core/templates/core/login.jinja:51 #: core/templates/core/login.jinja:18 core/templates/core/login.jinja:51
#: core/templates/core/password_reset_complete.jinja:5 #: core/templates/core/password_reset_complete.jinja:5
msgid "Login" msgid "Login"
msgstr "Connexion" msgstr "Connexion"
#: core/templates/core/base.jinja:60 core/templates/core/register.jinja:7 #: core/templates/core/base.jinja:57 core/templates/core/register.jinja:7
#: core/templates/core/register.jinja:16 core/templates/core/register.jinja:22 #: core/templates/core/register.jinja:16 core/templates/core/register.jinja:22
msgid "Register" msgid "Register"
msgstr "Inscription" msgstr "Inscription"
#: core/templates/core/base.jinja:66 core/templates/core/base.jinja:67 #: core/templates/core/base.jinja:63 core/templates/core/base.jinja:64
#: forum/templates/forum/macros.jinja:179 #: forum/templates/forum/macros.jinja:179
#: forum/templates/forum/macros.jinja:183 #: forum/templates/forum/macros.jinja:183
#: matmat/templates/matmat/search_form.jinja:39 #: matmat/templates/matmat/search_form.jinja:39
@ -2416,52 +2416,52 @@ msgstr "Inscription"
msgid "Search" msgid "Search"
msgstr "Recherche" msgstr "Recherche"
#: core/templates/core/base.jinja:108 #: core/templates/core/base.jinja:105
msgid "Logout" msgid "Logout"
msgstr "Déconnexion" msgstr "Déconnexion"
#: core/templates/core/base.jinja:156 #: core/templates/core/base.jinja:153
msgid "You do not have any unread notification" msgid "You do not have any unread notification"
msgstr "Vous n'avez aucune notification non lue" msgstr "Vous n'avez aucune notification non lue"
#: core/templates/core/base.jinja:161 #: core/templates/core/base.jinja:158
msgid "View more" msgid "View more"
msgstr "Voir plus" msgstr "Voir plus"
#: core/templates/core/base.jinja:164 #: core/templates/core/base.jinja:161
#: forum/templates/forum/last_unread.jinja:21 #: forum/templates/forum/last_unread.jinja:21
msgid "Mark all as read" msgid "Mark all as read"
msgstr "Marquer tout comme lu" msgstr "Marquer tout comme lu"
#: core/templates/core/base.jinja:212 #: core/templates/core/base.jinja:209
msgid "Main" msgid "Main"
msgstr "Accueil" msgstr "Accueil"
#: core/templates/core/base.jinja:214 #: core/templates/core/base.jinja:211
msgid "Associations & Clubs" msgid "Associations & Clubs"
msgstr "Associations & Clubs" msgstr "Associations & Clubs"
#: core/templates/core/base.jinja:216 #: core/templates/core/base.jinja:213
msgid "AE" msgid "AE"
msgstr "L'AE" msgstr "L'AE"
#: core/templates/core/base.jinja:217 #: core/templates/core/base.jinja:214
msgid "AE's clubs" msgid "AE's clubs"
msgstr "Les clubs de L'AE" msgstr "Les clubs de L'AE"
#: core/templates/core/base.jinja:218 #: core/templates/core/base.jinja:215
msgid "Others UTBM's Associations" msgid "Others UTBM's Associations"
msgstr "Les autres associations de l'UTBM" msgstr "Les autres associations de l'UTBM"
#: core/templates/core/base.jinja:224 core/templates/core/user_tools.jinja:172 #: core/templates/core/base.jinja:221 core/templates/core/user_tools.jinja:172
msgid "Elections" msgid "Elections"
msgstr "Élections" msgstr "Élections"
#: core/templates/core/base.jinja:225 #: core/templates/core/base.jinja:222
msgid "Big event" msgid "Big event"
msgstr "Grandes Activités" msgstr "Grandes Activités"
#: core/templates/core/base.jinja:228 #: core/templates/core/base.jinja:225
#: forum/templates/forum/favorite_topics.jinja:18 #: forum/templates/forum/favorite_topics.jinja:18
#: forum/templates/forum/last_unread.jinja:18 #: forum/templates/forum/last_unread.jinja:18
#: forum/templates/forum/macros.jinja:90 forum/templates/forum/main.jinja:6 #: forum/templates/forum/macros.jinja:90 forum/templates/forum/main.jinja:6
@ -2470,11 +2470,11 @@ msgstr "Grandes Activités"
msgid "Forum" msgid "Forum"
msgstr "Forum" msgstr "Forum"
#: core/templates/core/base.jinja:229 #: core/templates/core/base.jinja:226
msgid "Gallery" msgid "Gallery"
msgstr "Photos" msgstr "Photos"
#: core/templates/core/base.jinja:230 counter/models.py:466 #: core/templates/core/base.jinja:227 counter/models.py:466
#: counter/templates/counter/counter_list.jinja:11 #: counter/templates/counter/counter_list.jinja:11
#: eboutic/templates/eboutic/eboutic_main.jinja:4 #: eboutic/templates/eboutic/eboutic_main.jinja:4
#: eboutic/templates/eboutic/eboutic_main.jinja:22 #: eboutic/templates/eboutic/eboutic_main.jinja:22
@ -2484,75 +2484,75 @@ msgstr "Photos"
msgid "Eboutic" msgid "Eboutic"
msgstr "Eboutic" msgstr "Eboutic"
#: core/templates/core/base.jinja:232 #: core/templates/core/base.jinja:229
msgid "Services" msgid "Services"
msgstr "Services" msgstr "Services"
#: core/templates/core/base.jinja:234 #: core/templates/core/base.jinja:231
msgid "Matmatronch" msgid "Matmatronch"
msgstr "Matmatronch" msgstr "Matmatronch"
#: core/templates/core/base.jinja:235 launderette/models.py:38 #: core/templates/core/base.jinja:232 launderette/models.py:38
#: launderette/templates/launderette/launderette_book.jinja:5 #: launderette/templates/launderette/launderette_book.jinja:5
#: launderette/templates/launderette/launderette_book_choose.jinja:4 #: launderette/templates/launderette/launderette_book_choose.jinja:4
#: launderette/templates/launderette/launderette_main.jinja:4 #: launderette/templates/launderette/launderette_main.jinja:4
msgid "Launderette" msgid "Launderette"
msgstr "Laverie" msgstr "Laverie"
#: core/templates/core/base.jinja:236 core/templates/core/file.jinja:20 #: core/templates/core/base.jinja:233 core/templates/core/file.jinja:20
#: core/views/files.py:116 #: core/views/files.py:116
msgid "Files" msgid "Files"
msgstr "Fichiers" msgstr "Fichiers"
#: core/templates/core/base.jinja:237 core/templates/core/user_tools.jinja:163 #: core/templates/core/base.jinja:234 core/templates/core/user_tools.jinja:163
msgid "Pedagogy" msgid "Pedagogy"
msgstr "Pédagogie" msgstr "Pédagogie"
#: core/templates/core/base.jinja:241 #: core/templates/core/base.jinja:238
msgid "My Benefits" msgid "My Benefits"
msgstr "Mes Avantages" msgstr "Mes Avantages"
#: core/templates/core/base.jinja:243 #: core/templates/core/base.jinja:240
msgid "Sponsors" msgid "Sponsors"
msgstr "Partenaires" msgstr "Partenaires"
#: core/templates/core/base.jinja:244 #: core/templates/core/base.jinja:241
msgid "Subscriber benefits" msgid "Subscriber benefits"
msgstr "Les avantages cotisants" msgstr "Les avantages cotisants"
#: core/templates/core/base.jinja:248 #: core/templates/core/base.jinja:245
msgid "Help" msgid "Help"
msgstr "Aide" msgstr "Aide"
#: core/templates/core/base.jinja:250 #: core/templates/core/base.jinja:247
msgid "FAQ" msgid "FAQ"
msgstr "FAQ" msgstr "FAQ"
#: core/templates/core/base.jinja:251 core/templates/core/base.jinja:291 #: core/templates/core/base.jinja:248 core/templates/core/base.jinja:288
msgid "Contacts" msgid "Contacts"
msgstr "Contacts" msgstr "Contacts"
#: core/templates/core/base.jinja:252 #: core/templates/core/base.jinja:249
msgid "Wiki" msgid "Wiki"
msgstr "Wiki" msgstr "Wiki"
#: core/templates/core/base.jinja:292 #: core/templates/core/base.jinja:289
msgid "Legal notices" msgid "Legal notices"
msgstr "Mentions légales" msgstr "Mentions légales"
#: core/templates/core/base.jinja:293 #: core/templates/core/base.jinja:290
msgid "Intellectual property" msgid "Intellectual property"
msgstr "Propriété intellectuelle" msgstr "Propriété intellectuelle"
#: core/templates/core/base.jinja:294 #: core/templates/core/base.jinja:291
msgid "Help & Documentation" msgid "Help & Documentation"
msgstr "Aide & Documentation" msgstr "Aide & Documentation"
#: core/templates/core/base.jinja:295 #: core/templates/core/base.jinja:292
msgid "R&D" msgid "R&D"
msgstr "R&D" msgstr "R&D"
#: core/templates/core/base.jinja:298 #: core/templates/core/base.jinja:295
msgid "Site created by the IT Department of the AE" msgid "Site created by the IT Department of the AE"
msgstr "Site réalisé par le Pôle Informatique de l'AE" msgstr "Site réalisé par le Pôle Informatique de l'AE"
@ -2582,6 +2582,7 @@ msgstr "Confirmation"
#: core/templates/core/delete_confirm.jinja:20 #: core/templates/core/delete_confirm.jinja:20
#: core/templates/core/file_delete_confirm.jinja:14 #: core/templates/core/file_delete_confirm.jinja:14
#: counter/templates/counter/counter_click.jinja:121 #: counter/templates/counter/counter_click.jinja:121
#: sas/templates/sas/ask_picture_removal.jinja:20
msgid "Cancel" msgid "Cancel"
msgstr "Annuler" msgstr "Annuler"
@ -2614,24 +2615,24 @@ msgstr "Propriétés"
#: core/templates/core/file_detail.jinja:13 #: core/templates/core/file_detail.jinja:13
#: core/templates/core/file_moderation.jinja:20 #: core/templates/core/file_moderation.jinja:20
#: sas/templates/sas/picture.jinja:84 #: sas/templates/sas/picture.jinja:101
msgid "Owner: " msgid "Owner: "
msgstr "Propriétaire : " msgstr "Propriétaire : "
#: core/templates/core/file_detail.jinja:26 sas/templates/sas/album.jinja:46 #: core/templates/core/file_detail.jinja:26 sas/templates/sas/album.jinja:50
#: sas/templates/sas/main.jinja:49 #: sas/templates/sas/main.jinja:49
msgid "Clear clipboard" msgid "Clear clipboard"
msgstr "Vider le presse-papier" msgstr "Vider le presse-papier"
#: core/templates/core/file_detail.jinja:27 sas/templates/sas/album.jinja:33 #: core/templates/core/file_detail.jinja:27 sas/templates/sas/album.jinja:37
msgid "Cut" msgid "Cut"
msgstr "Couper" msgstr "Couper"
#: core/templates/core/file_detail.jinja:28 sas/templates/sas/album.jinja:34 #: core/templates/core/file_detail.jinja:28 sas/templates/sas/album.jinja:38
msgid "Paste" msgid "Paste"
msgstr "Coller" msgstr "Coller"
#: core/templates/core/file_detail.jinja:31 sas/templates/sas/album.jinja:40 #: core/templates/core/file_detail.jinja:31 sas/templates/sas/album.jinja:44
#: sas/templates/sas/main.jinja:43 #: sas/templates/sas/main.jinja:43
msgid "Clipboard: " msgid "Clipboard: "
msgstr "Presse-papier : " msgstr "Presse-papier : "
@ -2642,7 +2643,7 @@ msgstr "Nom réel : "
#: core/templates/core/file_detail.jinja:54 #: core/templates/core/file_detail.jinja:54
#: core/templates/core/file_moderation.jinja:21 #: core/templates/core/file_moderation.jinja:21
#: sas/templates/sas/picture.jinja:75 #: sas/templates/sas/picture.jinja:92
msgid "Date: " msgid "Date: "
msgstr "Date : " msgstr "Date : "
@ -2998,7 +2999,7 @@ msgstr "Résultat de la recherche"
msgid "Users" msgid "Users"
msgstr "Utilisateurs" msgstr "Utilisateurs"
#: core/templates/core/search.jinja:20 core/views/user.py:245 #: core/templates/core/search.jinja:20 core/views/user.py:247
msgid "Clubs" msgid "Clubs"
msgstr "Clubs" msgstr "Clubs"
@ -3039,11 +3040,11 @@ msgid "Eboutic invoices"
msgstr "Facture eboutic" msgstr "Facture eboutic"
#: core/templates/core/user_account.jinja:54 #: core/templates/core/user_account.jinja:54
#: core/templates/core/user_tools.jinja:58 counter/views.py:713 #: core/templates/core/user_tools.jinja:58 counter/views.py:715
msgid "Etickets" msgid "Etickets"
msgstr "Etickets" msgstr "Etickets"
#: core/templates/core/user_account.jinja:69 core/views/user.py:638 #: core/templates/core/user_account.jinja:69 core/views/user.py:640
msgid "User has no account" msgid "User has no account"
msgstr "L'utilisateur n'a pas de compte" msgstr "L'utilisateur n'a pas de compte"
@ -3265,13 +3266,13 @@ msgstr "Photos de %(user_name)s"
msgid "Download all my pictures" msgid "Download all my pictures"
msgstr "Télécharger toutes mes photos" msgstr "Télécharger toutes mes photos"
#: core/templates/core/user_pictures.jinja:45 sas/templates/sas/album.jinja:74 #: core/templates/core/user_pictures.jinja:45 sas/templates/sas/album.jinja:78
#: sas/templates/sas/macros.jinja:16 #: sas/templates/sas/macros.jinja:16
msgid "To be moderated" msgid "To be moderated"
msgstr "A modérer" msgstr "A modérer"
#: core/templates/core/user_preferences.jinja:8 #: core/templates/core/user_preferences.jinja:8
#: core/templates/core/user_preferences.jinja:13 core/views/user.py:237 #: core/templates/core/user_preferences.jinja:13 core/views/user.py:239
msgid "Preferences" msgid "Preferences"
msgstr "Préférences" msgstr "Préférences"
@ -3346,7 +3347,7 @@ msgstr "Outils utilisateurs"
msgid "Sith management" msgid "Sith management"
msgstr "Gestion de Sith" msgstr "Gestion de Sith"
#: core/templates/core/user_tools.jinja:21 core/views/user.py:253 #: core/templates/core/user_tools.jinja:21 core/views/user.py:255
msgid "Groups" msgid "Groups"
msgstr "Groupes" msgstr "Groupes"
@ -3375,7 +3376,7 @@ msgid "Subscription stats"
msgstr "Statistiques de cotisation" msgstr "Statistiques de cotisation"
#: core/templates/core/user_tools.jinja:48 counter/forms.py:164 #: core/templates/core/user_tools.jinja:48 counter/forms.py:164
#: counter/views.py:683 #: counter/views.py:685
msgid "Counters" msgid "Counters"
msgstr "Comptoirs" msgstr "Comptoirs"
@ -3392,16 +3393,16 @@ msgid "Product types management"
msgstr "Gestion des types de produit" msgstr "Gestion des types de produit"
#: core/templates/core/user_tools.jinja:56 #: core/templates/core/user_tools.jinja:56
#: counter/templates/counter/cash_summary_list.jinja:23 counter/views.py:703 #: counter/templates/counter/cash_summary_list.jinja:23 counter/views.py:705
msgid "Cash register summaries" msgid "Cash register summaries"
msgstr "Relevés de caisse" msgstr "Relevés de caisse"
#: core/templates/core/user_tools.jinja:57 #: core/templates/core/user_tools.jinja:57
#: counter/templates/counter/invoices_call.jinja:4 counter/views.py:708 #: counter/templates/counter/invoices_call.jinja:4 counter/views.py:710
msgid "Invoices call" msgid "Invoices call"
msgstr "Appels à facture" msgstr "Appels à facture"
#: core/templates/core/user_tools.jinja:72 core/views/user.py:272 #: core/templates/core/user_tools.jinja:72 core/views/user.py:274
#: counter/templates/counter/counter_list.jinja:18 #: counter/templates/counter/counter_list.jinja:18
#: counter/templates/counter/counter_list.jinja:34 #: counter/templates/counter/counter_list.jinja:34
#: counter/templates/counter/counter_list.jinja:50 #: counter/templates/counter/counter_list.jinja:50
@ -3496,12 +3497,12 @@ msgid "Error creating folder %(folder_name)s: %(msg)s"
msgstr "Erreur de création du dossier %(folder_name)s : %(msg)s" msgstr "Erreur de création du dossier %(folder_name)s : %(msg)s"
#: core/views/files.py:153 core/views/forms.py:277 core/views/forms.py:284 #: core/views/files.py:153 core/views/forms.py:277 core/views/forms.py:284
#: sas/views.py:81 #: sas/forms.py:60
#, python-format #, python-format
msgid "Error uploading file %(file_name)s: %(msg)s" msgid "Error uploading file %(file_name)s: %(msg)s"
msgstr "Erreur d'envoi du fichier %(file_name)s : %(msg)s" msgstr "Erreur d'envoi du fichier %(file_name)s : %(msg)s"
#: core/views/files.py:228 sas/views.py:313 #: core/views/files.py:228 sas/forms.py:93
msgid "Apply rights recursively" msgid "Apply rights recursively"
msgstr "Appliquer les droits récursivement" msgstr "Appliquer les droits récursivement"
@ -3589,21 +3590,21 @@ msgstr "Utilisateurs à retirer du groupe"
msgid "Users to add to group" msgid "Users to add to group"
msgstr "Utilisateurs à ajouter au groupe" msgstr "Utilisateurs à ajouter au groupe"
#: core/views/user.py:182 #: core/views/user.py:184
msgid "We couldn't verify that this email actually exists" msgid "We couldn't verify that this email actually exists"
msgstr "Nous n'avons pas réussi à vérifier que cette adresse mail existe." msgstr "Nous n'avons pas réussi à vérifier que cette adresse mail existe."
#: core/views/user.py:205 #: core/views/user.py:207
msgid "Family" msgid "Family"
msgstr "Famille" msgstr "Famille"
#: core/views/user.py:210 sas/templates/sas/album.jinja:63 #: core/views/user.py:212 sas/templates/sas/album.jinja:67
#: trombi/templates/trombi/export.jinja:25 #: trombi/templates/trombi/export.jinja:25
#: trombi/templates/trombi/user_profile.jinja:11 #: trombi/templates/trombi/user_profile.jinja:11
msgid "Pictures" msgid "Pictures"
msgstr "Photos" msgstr "Photos"
#: core/views/user.py:218 #: core/views/user.py:220
msgid "Galaxy" msgid "Galaxy"
msgstr "Galaxie" msgstr "Galaxie"
@ -3652,7 +3653,7 @@ msgstr "client"
msgid "customers" msgid "customers"
msgstr "clients" msgstr "clients"
#: counter/models.py:74 counter/views.py:265 #: counter/models.py:74 counter/views.py:267
msgid "Not enough money" msgid "Not enough money"
msgstr "Solde insuffisant" msgstr "Solde insuffisant"
@ -3780,11 +3781,11 @@ msgstr "est validé"
msgid "refilling" msgid "refilling"
msgstr "rechargement" msgstr "rechargement"
#: counter/models.py:752 eboutic/models.py:245 #: counter/models.py:752 eboutic/models.py:249
msgid "unit price" msgid "unit price"
msgstr "prix unitaire" msgstr "prix unitaire"
#: counter/models.py:753 counter/models.py:1057 eboutic/models.py:246 #: counter/models.py:753 counter/models.py:1057 eboutic/models.py:250
msgid "quantity" msgid "quantity"
msgstr "quantité" msgstr "quantité"
@ -3970,7 +3971,7 @@ msgstr "Liste des relevés de caisse"
msgid "Theoric sums" msgid "Theoric sums"
msgstr "Sommes théoriques" msgstr "Sommes théoriques"
#: counter/templates/counter/cash_summary_list.jinja:36 counter/views.py:945 #: counter/templates/counter/cash_summary_list.jinja:36 counter/views.py:963
msgid "Emptied" msgid "Emptied"
msgstr "Coffre vidé" msgstr "Coffre vidé"
@ -3996,7 +3997,7 @@ msgstr "Ce n'est pas un UID de carte étudiante valide"
#: counter/templates/counter/invoices_call.jinja:16 #: counter/templates/counter/invoices_call.jinja:16
#: launderette/templates/launderette/launderette_admin.jinja:35 #: launderette/templates/launderette/launderette_admin.jinja:35
#: launderette/templates/launderette/launderette_click.jinja:13 #: launderette/templates/launderette/launderette_click.jinja:13
#: sas/templates/sas/picture.jinja:141 #: sas/templates/sas/picture.jinja:160
#: subscription/templates/subscription/stats.jinja:19 #: subscription/templates/subscription/stats.jinja:19
msgid "Go" msgid "Go"
msgstr "Valider" msgstr "Valider"
@ -4127,7 +4128,7 @@ msgid "%(counter_name)s last operations"
msgstr "Dernières opérations sur %(counter_name)s" msgstr "Dernières opérations sur %(counter_name)s"
#: counter/templates/counter/product_list.jinja:4 #: counter/templates/counter/product_list.jinja:4
#: counter/templates/counter/product_list.jinja:12 #: counter/templates/counter/product_list.jinja:11
msgid "Product list" msgid "Product list"
msgstr "Liste des produits" msgstr "Liste des produits"
@ -4135,11 +4136,11 @@ msgstr "Liste des produits"
msgid "New product" msgid "New product"
msgstr "Nouveau produit" msgstr "Nouveau produit"
#: counter/templates/counter/product_list.jinja:21 #: counter/templates/counter/product_list.jinja:13
msgid "Uncategorized" msgid "Uncategorized"
msgstr "Sans catégorie" msgstr "Sans catégorie"
#: counter/templates/counter/product_list.jinja:28 #: counter/templates/counter/product_list.jinja:20
msgid "There is no products in this website." msgid "There is no products in this website."
msgstr "Il n'y a pas de produits dans ce site web." msgstr "Il n'y a pas de produits dans ce site web."
@ -4196,101 +4197,101 @@ msgstr "Temps"
msgid "Top 100 barman %(counter_name)s (all semesters)" msgid "Top 100 barman %(counter_name)s (all semesters)"
msgstr "Top 100 barman %(counter_name)s (tous les semestres)" msgstr "Top 100 barman %(counter_name)s (tous les semestres)"
#: counter/views.py:151 #: counter/views.py:153
msgid "Cash summary" msgid "Cash summary"
msgstr "Relevé de caisse" msgstr "Relevé de caisse"
#: counter/views.py:160 #: counter/views.py:162
msgid "Last operations" msgid "Last operations"
msgstr "Dernières opérations" msgstr "Dernières opérations"
#: counter/views.py:207 #: counter/views.py:209
msgid "Bad credentials" msgid "Bad credentials"
msgstr "Mauvais identifiants" msgstr "Mauvais identifiants"
#: counter/views.py:209 #: counter/views.py:211
msgid "User is not barman" msgid "User is not barman"
msgstr "L'utilisateur n'est pas barman." msgstr "L'utilisateur n'est pas barman."
#: counter/views.py:214 #: counter/views.py:216
msgid "Bad location, someone is already logged in somewhere else" msgid "Bad location, someone is already logged in somewhere else"
msgstr "Mauvais comptoir, quelqu'un est déjà connecté ailleurs" msgstr "Mauvais comptoir, quelqu'un est déjà connecté ailleurs"
#: counter/views.py:256 #: counter/views.py:258
msgid "Too young for that product" msgid "Too young for that product"
msgstr "Trop jeune pour ce produit" msgstr "Trop jeune pour ce produit"
#: counter/views.py:259 #: counter/views.py:261
msgid "Not allowed for that product" msgid "Not allowed for that product"
msgstr "Non autorisé pour ce produit" msgstr "Non autorisé pour ce produit"
#: counter/views.py:262 #: counter/views.py:264
msgid "No date of birth provided" msgid "No date of birth provided"
msgstr "Pas de date de naissance renseignée" msgstr "Pas de date de naissance renseignée"
#: counter/views.py:551 #: counter/views.py:553
msgid "You have not enough money to buy all the basket" msgid "You have not enough money to buy all the basket"
msgstr "Vous n'avez pas assez d'argent pour acheter le panier" msgstr "Vous n'avez pas assez d'argent pour acheter le panier"
#: counter/views.py:678 #: counter/views.py:680
msgid "Counter administration" msgid "Counter administration"
msgstr "Administration des comptoirs" msgstr "Administration des comptoirs"
#: counter/views.py:698 #: counter/views.py:700
msgid "Product types" msgid "Product types"
msgstr "Types de produit" msgstr "Types de produit"
#: counter/views.py:902 #: counter/views.py:920
msgid "10 cents" msgid "10 cents"
msgstr "10 centimes" msgstr "10 centimes"
#: counter/views.py:903 #: counter/views.py:921
msgid "20 cents" msgid "20 cents"
msgstr "20 centimes" msgstr "20 centimes"
#: counter/views.py:904 #: counter/views.py:922
msgid "50 cents" msgid "50 cents"
msgstr "50 centimes" msgstr "50 centimes"
#: counter/views.py:905 #: counter/views.py:923
msgid "1 euro" msgid "1 euro"
msgstr "1 €" msgstr "1 €"
#: counter/views.py:906 #: counter/views.py:924
msgid "2 euros" msgid "2 euros"
msgstr "2 €" msgstr "2 €"
#: counter/views.py:907 #: counter/views.py:925
msgid "5 euros" msgid "5 euros"
msgstr "5 €" msgstr "5 €"
#: counter/views.py:908 #: counter/views.py:926
msgid "10 euros" msgid "10 euros"
msgstr "10 €" msgstr "10 €"
#: counter/views.py:909 #: counter/views.py:927
msgid "20 euros" msgid "20 euros"
msgstr "20 €" msgstr "20 €"
#: counter/views.py:910 #: counter/views.py:928
msgid "50 euros" msgid "50 euros"
msgstr "50 €" msgstr "50 €"
#: counter/views.py:912 #: counter/views.py:930
msgid "100 euros" msgid "100 euros"
msgstr "100 €" msgstr "100 €"
#: counter/views.py:915 counter/views.py:921 counter/views.py:927 #: counter/views.py:933 counter/views.py:939 counter/views.py:945
#: counter/views.py:933 counter/views.py:939 #: counter/views.py:951 counter/views.py:957
msgid "Check amount" msgid "Check amount"
msgstr "Montant du chèque" msgstr "Montant du chèque"
#: counter/views.py:918 counter/views.py:924 counter/views.py:930 #: counter/views.py:936 counter/views.py:942 counter/views.py:948
#: counter/views.py:936 counter/views.py:942 #: counter/views.py:954 counter/views.py:960
msgid "Check quantity" msgid "Check quantity"
msgstr "Nombre de chèque" msgstr "Nombre de chèque"
#: counter/views.py:1462 #: counter/views.py:1480
msgid "people(s)" msgid "people(s)"
msgstr "personne(s)" msgstr "personne(s)"
@ -4307,27 +4308,27 @@ msgstr "Votre panier est vide"
msgid "%(name)s : this product does not exist or may no longer be available." msgid "%(name)s : this product does not exist or may no longer be available."
msgstr "%(name)s : ce produit n'existe pas ou n'est peut-être plus disponible." msgstr "%(name)s : ce produit n'existe pas ou n'est peut-être plus disponible."
#: eboutic/models.py:190 #: eboutic/models.py:194
msgid "validated" msgid "validated"
msgstr "validé" msgstr "validé"
#: eboutic/models.py:206 #: eboutic/models.py:210
msgid "Invoice already validated" msgid "Invoice already validated"
msgstr "Facture déjà validée" msgstr "Facture déjà validée"
#: eboutic/models.py:242 #: eboutic/models.py:246
msgid "product id" msgid "product id"
msgstr "ID du produit" msgstr "ID du produit"
#: eboutic/models.py:243 #: eboutic/models.py:247
msgid "product name" msgid "product name"
msgstr "nom du produit" msgstr "nom du produit"
#: eboutic/models.py:244 #: eboutic/models.py:248
msgid "product type id" msgid "product type id"
msgstr "id du type du produit" msgstr "id du type du produit"
#: eboutic/models.py:261 #: eboutic/models.py:265
msgid "basket" msgid "basket"
msgstr "panier" msgstr "panier"
@ -5099,7 +5100,7 @@ msgstr "non noté"
msgid "UV comment moderation" msgid "UV comment moderation"
msgstr "Modération des commentaires d'UV" msgstr "Modération des commentaires d'UV"
#: pedagogy/templates/pedagogy/moderation.jinja:14 #: pedagogy/templates/pedagogy/moderation.jinja:14 sas/models.py:309
msgid "Reason" msgid "Reason"
msgstr "Raison" msgstr "Raison"
@ -5267,27 +5268,73 @@ msgstr "Utilisateur qui sera supprimé"
msgid "User to be selected" msgid "User to be selected"
msgstr "Utilisateur à sélectionner" msgstr "Utilisateur à sélectionner"
#: sas/models.py:282 #: sas/forms.py:16
msgid "Add a new album"
msgstr "Ajouter un nouvel album"
#: sas/forms.py:19
msgid "Upload images"
msgstr "Envoyer les images"
#: sas/forms.py:37
#, python-format
msgid "Error creating album %(album)s: %(msg)s"
msgstr "Erreur de création de l'album %(album)s : %(msg)s"
#: sas/forms.py:72 trombi/templates/trombi/detail.jinja:15
msgid "Add user"
msgstr "Ajouter une personne"
#: sas/forms.py:117
msgid "You already requested moderation for this picture."
msgstr "Vous avez déjà déposé une demande de retrait pour cette photo."
#: sas/models.py:280
msgid "picture" msgid "picture"
msgstr "photo" msgstr "photo"
#: sas/templates/sas/album.jinja:9 sas/templates/sas/main.jinja:8 #: sas/models.py:304
#: sas/templates/sas/main.jinja:17 sas/templates/sas/picture.jinja:12 msgid "Picture"
msgstr "Photo"
#: sas/models.py:311
msgid "Why do you want this image to be removed ?"
msgstr "Pourquoi voulez-vous retirer cette image ?"
#: sas/models.py:315
msgid "Picture moderation request"
msgstr "Demande de modération de photo"
#: sas/models.py:316
msgid "Picture moderation requests"
msgstr "Demandes de modération de photo"
#: sas/templates/sas/album.jinja:13
#: sas/templates/sas/ask_picture_removal.jinja:4 sas/templates/sas/main.jinja:8
#: sas/templates/sas/main.jinja:17 sas/templates/sas/picture.jinja:13
msgid "SAS" msgid "SAS"
msgstr "SAS" msgstr "SAS"
#: sas/templates/sas/album.jinja:52 sas/templates/sas/moderation.jinja:10 #: sas/templates/sas/album.jinja:56 sas/templates/sas/moderation.jinja:10
msgid "Albums" msgid "Albums"
msgstr "Albums" msgstr "Albums"
#: sas/templates/sas/album.jinja:96 #: sas/templates/sas/album.jinja:100
msgid "Upload" msgid "Upload"
msgstr "Envoyer" msgstr "Envoyer"
#: sas/templates/sas/album.jinja:103 #: sas/templates/sas/album.jinja:107
msgid "Template generation time: " msgid "Template generation time: "
msgstr "Temps de génération du template : " msgstr "Temps de génération du template : "
#: sas/templates/sas/ask_picture_removal.jinja:9
msgid "Image removal request"
msgstr "Demande de retrait d'image"
#: sas/templates/sas/ask_picture_removal.jinja:25
msgid "Request removal"
msgstr "Demander le retrait"
#: sas/templates/sas/main.jinja:20 #: sas/templates/sas/main.jinja:20
msgid "You must be logged in to see the SAS." msgid "You must be logged in to see the SAS."
msgstr "Vous devez être connecté pour voir les photos." msgstr "Vous devez être connecté pour voir les photos."
@ -5304,11 +5351,11 @@ msgstr "Toutes les catégories"
msgid "SAS moderation" msgid "SAS moderation"
msgstr "Modération du SAS" msgstr "Modération du SAS"
#: sas/templates/sas/picture.jinja:35 #: sas/templates/sas/picture.jinja:36
msgid "Asked for removal" msgid "Asked for removal"
msgstr "Retrait demandé" msgstr "Retrait demandé"
#: sas/templates/sas/picture.jinja:38 #: sas/templates/sas/picture.jinja:39
msgid "" msgid ""
"This picture can be viewed only by root users and by SAS admins. It will be " "This picture can be viewed only by root users and by SAS admins. It will be "
"hidden to other users until it has been moderated." "hidden to other users until it has been moderated."
@ -5317,39 +5364,26 @@ msgstr ""
"SAS. Elle sera cachée pour les autres utilisateurs tant qu'elle ne sera pas " "SAS. Elle sera cachée pour les autres utilisateurs tant qu'elle ne sera pas "
"modérée." "modérée."
#: sas/templates/sas/picture.jinja:95 #: sas/templates/sas/picture.jinja:47
msgid "The following issues have been raised:"
msgstr "Les problèmes suivants ont été remontés :"
#: sas/templates/sas/picture.jinja:112
msgid "HD version" msgid "HD version"
msgstr "Version HD" msgstr "Version HD"
#: sas/templates/sas/picture.jinja:98 #: sas/templates/sas/picture.jinja:116
msgid "Ask for removal" msgid "Ask for removal"
msgstr "Demander le retrait" msgstr "Demander le retrait"
#: sas/templates/sas/picture.jinja:118 sas/templates/sas/picture.jinja:129 #: sas/templates/sas/picture.jinja:137 sas/templates/sas/picture.jinja:148
msgid "Previous picture" msgid "Previous picture"
msgstr "Image précédente" msgstr "Image précédente"
#: sas/templates/sas/picture.jinja:137 #: sas/templates/sas/picture.jinja:156
msgid "People" msgid "People"
msgstr "Personne(s)" msgstr "Personne(s)"
#: sas/views.py:37
msgid "Add a new album"
msgstr "Ajouter un nouvel album"
#: sas/views.py:40
msgid "Upload images"
msgstr "Envoyer les images"
#: sas/views.py:58
#, python-format
msgid "Error creating album %(album)s: %(msg)s"
msgstr "Erreur de création de l'album %(album)s : %(msg)s"
#: sas/views.py:93 trombi/templates/trombi/detail.jinja:15
msgid "Add user"
msgstr "Ajouter une personne"
#: sith/settings.py:255 sith/settings.py:474 #: sith/settings.py:255 sith/settings.py:474
msgid "English" msgid "English"
msgstr "Anglais" msgstr "Anglais"

View File

@ -15,7 +15,7 @@
from django.contrib import admin from django.contrib import admin
from sas.models import Album, PeoplePictureRelation, Picture from sas.models import Album, PeoplePictureRelation, Picture, PictureModerationRequest
@admin.register(Picture) @admin.register(Picture)
@ -31,4 +31,15 @@ class PeoplePictureRelationAdmin(admin.ModelAdmin):
autocomplete_fields = ("picture", "user") autocomplete_fields = ("picture", "user")
admin.site.register(Album) @admin.register(Album)
class AlbumAdmin(admin.ModelAdmin):
list_display = ("name", "parent", "date", "owner", "is_moderated")
search_fields = ("name",)
autocomplete_fields = ("owner", "parent", "edit_groups", "view_groups")
@admin.register(PictureModerationRequest)
class PictureModerationRequestAdmin(admin.ModelAdmin):
list_display = ("author", "picture", "created_at")
search_fields = ("author", "picture")
autocomplete_fields = ("author", "picture")

View File

@ -9,10 +9,17 @@ from ninja_extra.permissions import IsAuthenticated
from ninja_extra.schemas import PaginatedResponseSchema from ninja_extra.schemas import PaginatedResponseSchema
from pydantic import NonNegativeInt from pydantic import NonNegativeInt
from core.api_permissions import CanView, IsOwner from core.api_permissions import CanView, IsInGroup, IsRoot
from core.models import Notification, User from core.models import Notification, User
from sas.models import PeoplePictureRelation, Picture from sas.models import PeoplePictureRelation, Picture
from sas.schemas import IdentifiedUserSchema, PictureFilterSchema, PictureSchema from sas.schemas import (
IdentifiedUserSchema,
ModerationRequestSchema,
PictureFilterSchema,
PictureSchema,
)
IsSasAdmin = IsRoot | IsInGroup(settings.SITH_GROUP_SAS_ADMIN_ID)
@api_controller("/sas/picture") @api_controller("/sas/picture")
@ -85,18 +92,35 @@ class PicturesController(ControllerBase):
}, },
) )
@route.delete("/{picture_id}", permissions=[IsOwner]) @route.delete("/{picture_id}", permissions=[IsSasAdmin])
def delete_picture(self, picture_id: int): def delete_picture(self, picture_id: int):
self.get_object_or_exception(Picture, pk=picture_id).delete() self.get_object_or_exception(Picture, pk=picture_id).delete()
@route.patch("/{picture_id}/moderate", permissions=[IsOwner]) @route.patch(
"/{picture_id}/moderation",
permissions=[IsSasAdmin],
url_name="picture_moderate",
)
def moderate_picture(self, picture_id: int): def moderate_picture(self, picture_id: int):
"""Mark a picture as moderated and remove its pending moderation requests."""
picture = self.get_object_or_exception(Picture, pk=picture_id) picture = self.get_object_or_exception(Picture, pk=picture_id)
picture.moderation_requests.all().delete()
picture.is_moderated = True picture.is_moderated = True
picture.moderator = self.context.request.user picture.moderator = self.context.request.user
picture.asked_for_removal = False picture.asked_for_removal = False
picture.save() picture.save()
@route.get(
"/{picture_id}/moderation",
permissions=[IsSasAdmin],
response=list[ModerationRequestSchema],
url_name="picture_moderation_requests",
)
def fetch_moderation_requests(self, picture_id: int):
"""Fetch the moderation requests issued on this picture."""
picture = self.get_object_or_exception(Picture, pk=picture_id)
return picture.moderation_requests.select_related("author")
@api_controller("/sas/relation", tags="User identification on SAS pictures") @api_controller("/sas/relation", tags="User identification on SAS pictures")
class UsersIdentifiedController(ControllerBase): class UsersIdentifiedController(ControllerBase):

124
sas/forms.py Normal file
View File

@ -0,0 +1,124 @@
from typing import Any
from ajax_select import make_ajax_field
from ajax_select.fields import AutoCompleteSelectMultipleField
from django import forms
from django.utils.translation import gettext_lazy as _
from core.models import User
from core.views import MultipleImageField
from core.views.forms import SelectDate
from sas.models import Album, PeoplePictureRelation, Picture, PictureModerationRequest
class SASForm(forms.Form):
album_name = forms.CharField(
label=_("Add a new album"), max_length=Album.NAME_MAX_LENGTH, required=False
)
images = MultipleImageField(
label=_("Upload images"),
required=False,
)
def process(self, parent, owner, files, *, automodere=False):
try:
if self.cleaned_data["album_name"] != "":
album = Album(
parent=parent,
name=self.cleaned_data["album_name"],
owner=owner,
is_moderated=automodere,
)
album.clean()
album.save()
except Exception as e:
self.add_error(
None,
_("Error creating album %(album)s: %(msg)s")
% {"album": self.cleaned_data["album_name"], "msg": repr(e)},
)
for f in files:
new_file = Picture(
parent=parent,
name=f.name,
file=f,
owner=owner,
mime_type=f.content_type,
size=f.size,
is_folder=False,
is_moderated=automodere,
)
if automodere:
new_file.moderator = owner
try:
new_file.clean()
new_file.generate_thumbnails()
new_file.save()
except Exception as e:
self.add_error(
None,
_("Error uploading file %(file_name)s: %(msg)s")
% {"file_name": f, "msg": repr(e)},
)
class RelationForm(forms.ModelForm):
class Meta:
model = PeoplePictureRelation
fields = ["picture"]
widgets = {"picture": forms.HiddenInput}
users = AutoCompleteSelectMultipleField(
"users", show_help_text=False, help_text="", label=_("Add user"), required=False
)
class PictureEditForm(forms.ModelForm):
class Meta:
model = Picture
fields = ["name", "parent"]
parent = make_ajax_field(Picture, "parent", "files", help_text="")
class AlbumEditForm(forms.ModelForm):
class Meta:
model = Album
fields = ["name", "date", "file", "parent", "edit_groups"]
name = forms.CharField(max_length=Album.NAME_MAX_LENGTH, label=_("file name"))
date = forms.DateField(label=_("Date"), widget=SelectDate, required=True)
parent = make_ajax_field(Album, "parent", "files", help_text="")
edit_groups = make_ajax_field(Album, "edit_groups", "groups", help_text="")
recursive = forms.BooleanField(label=_("Apply rights recursively"), required=False)
class PictureModerationRequestForm(forms.ModelForm):
"""Form to create a PictureModerationRequest.
The form only manages the reason field,
because the author and the picture are set in the view.
"""
class Meta:
model = PictureModerationRequest
fields = ["reason"]
def __init__(self, *args, user: User, picture: Picture, **kwargs):
super().__init__(*args, **kwargs)
self.user = user
self.picture = picture
def clean(self) -> dict[str, Any]:
if PictureModerationRequest.objects.filter(
author=self.user, picture=self.picture
).exists():
raise forms.ValidationError(
_("You already requested moderation for this picture.")
)
return super().clean()
def save(self, *, commit=True) -> PictureModerationRequest:
self.instance.author = self.user
self.instance.picture = self.picture
return super().save(commit)

View File

@ -0,0 +1,68 @@
# Generated by Django 4.2.16 on 2024-10-10 20:44
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
("sas", "0003_sasfile"),
]
operations = [
migrations.CreateModel(
name="PictureModerationRequest",
fields=[
(
"id",
models.AutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("created_at", models.DateTimeField(auto_now_add=True)),
(
"reason",
models.TextField(
default="",
help_text="Why do you want this image to be removed ?",
verbose_name="Reason",
),
),
],
options={
"verbose_name": "Picture moderation request",
"verbose_name_plural": "Picture moderation requests",
},
),
migrations.AddField(
model_name="picturemoderationrequest",
name="author",
field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="moderation_requests",
to=settings.AUTH_USER_MODEL,
verbose_name="Author",
),
),
migrations.AddField(
model_name="picturemoderationrequest",
name="picture",
field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="moderation_requests",
to="sas.picture",
verbose_name="Picture",
),
),
migrations.AddConstraint(
model_name="picturemoderationrequest",
constraint=models.UniqueConstraint(
fields=("author", "picture"), name="one_request_per_user_per_picture"
),
),
]

View File

@ -273,16 +273,12 @@ class PeoplePictureRelation(models.Model):
User, User,
verbose_name=_("user"), verbose_name=_("user"),
related_name="pictures", related_name="pictures",
null=False,
blank=False,
on_delete=models.CASCADE, on_delete=models.CASCADE,
) )
picture = models.ForeignKey( picture = models.ForeignKey(
Picture, Picture,
verbose_name=_("picture"), verbose_name=_("picture"),
related_name="people", related_name="people",
null=False,
blank=False,
on_delete=models.CASCADE, on_delete=models.CASCADE,
) )
@ -290,4 +286,39 @@ class PeoplePictureRelation(models.Model):
unique_together = ["user", "picture"] unique_together = ["user", "picture"]
def __str__(self): def __str__(self):
return self.user.get_display_name() + " - " + str(self.picture) return f"Moderation request by {self.user.get_short_name()} - {self.picture}"
class PictureModerationRequest(models.Model):
"""A request to remove a Picture from the SAS."""
created_at = models.DateTimeField(auto_now_add=True)
author = models.ForeignKey(
User,
verbose_name=_("Author"),
related_name="moderation_requests",
on_delete=models.CASCADE,
)
picture = models.ForeignKey(
Picture,
verbose_name=_("Picture"),
related_name="moderation_requests",
on_delete=models.CASCADE,
)
reason = models.TextField(
verbose_name=_("Reason"),
default="",
help_text=_("Why do you want this image to be removed ?"),
)
class Meta:
verbose_name = _("Picture moderation request")
verbose_name_plural = _("Picture moderation requests")
constraints = [
models.UniqueConstraint(
fields=["author", "picture"], name="one_request_per_user_per_picture"
)
]
def __str__(self):
return f"Moderation request by {self.author.get_short_name()}"

View File

@ -4,8 +4,8 @@ from django.urls import reverse
from ninja import FilterSchema, ModelSchema, Schema from ninja import FilterSchema, ModelSchema, Schema
from pydantic import Field, NonNegativeInt from pydantic import Field, NonNegativeInt
from core.schemas import UserProfileSchema from core.schemas import SimpleUserSchema, UserProfileSchema
from sas.models import Picture from sas.models import Picture, PictureModerationRequest
class PictureFilterSchema(FilterSchema): class PictureFilterSchema(FilterSchema):
@ -52,3 +52,11 @@ class PictureRelationCreationSchema(Schema):
class IdentifiedUserSchema(Schema): class IdentifiedUserSchema(Schema):
id: int id: int
user: UserProfileSchema user: UserProfileSchema
class ModerationRequestSchema(ModelSchema):
author: SimpleUserSchema
class Meta:
model = PictureModerationRequest
fields = ["id", "created_at", "reason"]

View File

@ -11,10 +11,12 @@ import {
type IdentifiedUserSchema, type IdentifiedUserSchema,
type PictureSchema, type PictureSchema,
type PicturesFetchIdentificationsResponse, type PicturesFetchIdentificationsResponse,
type PicturesFetchModerationRequestsResponse,
type PicturesFetchPicturesData, type PicturesFetchPicturesData,
type UserProfileSchema, type UserProfileSchema,
picturesDeletePicture, picturesDeletePicture,
picturesFetchIdentifications, picturesFetchIdentifications,
picturesFetchModerationRequests,
picturesFetchPictures, picturesFetchPictures,
picturesIdentifyUsers, picturesIdentifyUsers,
picturesModeratePicture, picturesModeratePicture,
@ -27,18 +29,20 @@ import {
* able to prefetch its data. * able to prefetch its data.
*/ */
class PictureWithIdentifications { class PictureWithIdentifications {
identifications: PicturesFetchIdentificationsResponse | null = null; identifications: PicturesFetchIdentificationsResponse = null;
imageLoading = false; imageLoading = false;
identificationsLoading = false; identificationsLoading = false;
moderationLoading = false;
id: number; id: number;
// biome-ignore lint/style/useNamingConvention: api is in snake_case // biome-ignore lint/style/useNamingConvention: api is in snake_case
compressed_url: string; compressed_url: string;
moderationRequests: PicturesFetchModerationRequestsResponse = null;
constructor(picture: PictureSchema) { constructor(picture: PictureSchema) {
Object.assign(this, picture); Object.assign(this, picture);
} }
static fromPicture(picture: PictureSchema) { static fromPicture(picture: PictureSchema): PictureWithIdentifications {
return new PictureWithIdentifications(picture); return new PictureWithIdentifications(picture);
} }
@ -46,7 +50,7 @@ class PictureWithIdentifications {
* If not already done, fetch the users identified on this picture and * If not already done, fetch the users identified on this picture and
* populate the identifications field * populate the identifications field
*/ */
async loadIdentifications(options?: { forceReload: boolean }) { async loadIdentifications(options?: { forceReload: boolean }): Promise<void> {
if (this.identificationsLoading) { if (this.identificationsLoading) {
return; // The users are already being fetched. return; // The users are already being fetched.
} }
@ -65,11 +69,29 @@ class PictureWithIdentifications {
this.identificationsLoading = false; this.identificationsLoading = false;
} }
async loadModeration(options?: { forceReload: boolean }): Promise<void> {
if (this.moderationLoading) {
return; // The moderation requests are already being fetched.
}
if (!!this.moderationRequests && !options?.forceReload) {
// The moderation requests are already fetched
// and the user does not want to force the reload
return;
}
this.moderationLoading = true;
this.moderationRequests = (
await picturesFetchModerationRequests({
// biome-ignore lint/style/useNamingConvention: api is in snake_case
path: { picture_id: this.id },
})
).data;
this.moderationLoading = false;
}
/** /**
* Preload the photo and the identifications * Preload the photo and the identifications
* @return {Promise<void>}
*/ */
async preload() { async preload(): Promise<void> {
const img = new Image(); const img = new Image();
img.src = this.compressed_url; img.src = this.compressed_url;
if (!img.complete) { if (!img.complete) {
@ -87,12 +109,12 @@ interface ViewerConfig {
userId: number; userId: number;
/** Url of the current album */ /** Url of the current album */
albumUrl: string; albumUrl: string;
/** Id of the album to displlay */ /** Id of the album to display */
albumId: number; albumId: number;
/** 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: number; userIsSasAdmin: boolean;
} }
/** /**
@ -103,9 +125,8 @@ exportToHtml("loadViewer", (config: ViewerConfig) => {
Alpine.data("picture_viewer", () => ({ 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
* @type PictureWithIdentifications[]
**/ **/
pictures: [], 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
@ -131,14 +152,12 @@ exportToHtml("loadViewer", (config: ViewerConfig) => {
}, },
/** /**
* 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
* @type ?PictureWithIdentifications
**/ **/
nextPicture: null, 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
* @type ?PictureWithIdentifications
**/ **/
previousPicture: null, previousPicture: null as PictureWithIdentifications,
/** /**
* The select2 component used to identify users * The select2 component used to identify users
**/ **/
@ -148,13 +167,11 @@ exportToHtml("loadViewer", (config: ViewerConfig) => {
**/ **/
/** /**
* Error message when a moderation operation fails * Error message when a moderation operation fails
* @type string
**/ **/
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
* @type History
**/ **/
pushstate: History.Push, pushstate: History.Push,
@ -166,7 +183,7 @@ exportToHtml("loadViewer", (config: ViewerConfig) => {
} as PicturesFetchPicturesData) } as PicturesFetchPicturesData)
).map(PictureWithIdentifications.fromPicture); ).map(PictureWithIdentifications.fromPicture);
this.selector = sithSelect2({ this.selector = sithSelect2({
element: $(this.$refs.search) as unknown as HTMLElement, element: this.$refs.search,
dataSource: remoteDataSource(await makeUrl(userSearchUsers), { dataSource: remoteDataSource(await makeUrl(userSearchUsers), {
excluded: () => [ excluded: () => [
...(this.currentPicture.identifications || []).map( ...(this.currentPicture.identifications || []).map(
@ -213,7 +230,7 @@ exportToHtml("loadViewer", (config: ViewerConfig) => {
* and the previous picture, the next picture and * and the previous picture, the next picture and
* the list of identified users are updated. * the list of identified users are updated.
*/ */
async updatePicture() { async updatePicture(): Promise<void> {
const updateArgs = { const updateArgs = {
data: { sasPictureId: this.currentPicture.id }, data: { sasPictureId: this.currentPicture.id },
unused: "", unused: "",
@ -231,16 +248,23 @@ exportToHtml("loadViewer", (config: ViewerConfig) => {
} }
this.moderationError = ""; this.moderationError = "";
const index = this.pictures.indexOf(this.currentPicture); const index: number = this.pictures.indexOf(this.currentPicture);
this.previousPicture = this.pictures[index - 1] || null; this.previousPicture = this.pictures[index - 1] || null;
this.nextPicture = this.pictures[index + 1] || null; this.nextPicture = this.pictures[index + 1] || null;
await this.currentPicture.loadIdentifications();
this.$refs.mainPicture?.addEventListener("load", () => { this.$refs.mainPicture?.addEventListener("load", () => {
// once the current picture is loaded, // once the current picture is loaded,
// start preloading the next and previous pictures // start preloading the next and previous pictures
this.nextPicture?.preload(); this.nextPicture?.preload();
this.previousPicture?.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() { async moderatePicture() {
@ -253,7 +277,7 @@ exportToHtml("loadViewer", (config: ViewerConfig) => {
return; return;
} }
this.currentPicture.is_moderated = true; this.currentPicture.is_moderated = true;
this.currentPicture.askedForRemoval = false; this.currentPicture.asked_for_removal = false;
}, },
async deletePicture() { async deletePicture() {
@ -277,7 +301,7 @@ exportToHtml("loadViewer", (config: ViewerConfig) => {
/** /**
* Send the identification request and update the list of identified users. * Send the identification request and update the list of identified users.
*/ */
async submitIdentification() { async submitIdentification(): Promise<void> {
await picturesIdentifyUsers({ await picturesIdentifyUsers({
path: { path: {
// biome-ignore lint/style/useNamingConvention: api is in snake_case // biome-ignore lint/style/useNamingConvention: api is in snake_case
@ -292,18 +316,15 @@ exportToHtml("loadViewer", (config: ViewerConfig) => {
/** /**
* Check if an identification can be removed by the currently logged user * Check if an identification can be removed by the currently logged user
* @param {PictureIdentification} identification
* @return {boolean}
*/ */
canBeRemoved(identification: IdentifiedUserSchema) { canBeRemoved(identification: IdentifiedUserSchema): boolean {
return config.userIsSasAdmin || identification.user.id === config.userId; return config.userIsSasAdmin || identification.user.id === config.userId;
}, },
/** /**
* Untag a user from the current picture * Untag a user from the current picture
* @param {PictureIdentification} identification
*/ */
async removeIdentification(identification: IdentifiedUserSchema) { async removeIdentification(identification: IdentifiedUserSchema): Promise<void> {
const res = await usersidentifiedDeleteRelation({ const res = await usersidentifiedDeleteRelation({
// biome-ignore lint/style/useNamingConvention: api is in snake_case // biome-ignore lint/style/useNamingConvention: api is in snake_case
path: { relation_id: identification.id }, path: { relation_id: identification.id },

View File

@ -0,0 +1,28 @@
{% extends "core/base.jinja" %}
{% block title %}
{% trans %}SAS{% endtrans %}
{% endblock %}
{% block content %}
<h1>{% trans %}Image removal request{% endtrans %}</h1>
<form action="" method="post">
{% csrf_token %}
{{ form.non_field_errors() }}
<div class="helptext">
{{ form.reason.help_text }}
</div>
{{ form.reason }}
<br/>
<br/>
<a class="clickable btn btn-grey" href="{{ object.get_absolute_url() }}">
{% trans %}Cancel{% endtrans %}
</a>
<input
class="btn btn-blue"
type="submit"
value="{% trans %}Request removal{% endtrans %}"
>
</form>
{% endblock content %}

View File

@ -2,7 +2,7 @@
{%- block additional_css -%} {%- block additional_css -%}
<link rel="stylesheet" href="{{ static('sas/css/picture.scss') }}"> <link rel="stylesheet" href="{{ static('sas/css/picture.scss') }}">
<link rel="stylesheet" href="{{ static('webpack/sas/viewer-index.css') }}" defer> <link rel="stylesheet" href="{{ static('webpack/sas/viewer-index.css') }}">
{%- endblock -%} {%- endblock -%}
{%- block additional_js -%} {%- block additional_js -%}
@ -30,10 +30,10 @@
<br> <br>
<template x-if="!currentPicture.is_moderated"> <template x-if="!currentPicture.is_moderated">
<div class="alert alert-red"> <div class="alert alert-red" @click="console.log(currentPicture)">
<div class="alert-main"> <div class="alert-main">
<template x-if="currentPicture.askedForRemoval"> <template x-if="currentPicture.asked_for_removal">
<span class="important">{% trans %}Asked for removal{% endtrans %}</span> <h3 class="alert-title">{% trans %}Asked for removal{% endtrans %}</h3>
</template> </template>
<p> <p>
{% trans trimmed %} {% trans trimmed %}
@ -41,16 +41,33 @@
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>
<template x-if="currentPicture.asked_for_removal">
<div>
<h5 @click="console.log(currentPicture.moderationRequests)">
{% 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>
<i x-text="Intl.DateTimeFormat(
'{{ LANGUAGE_CODE }}',
{dateStyle: 'long', timeStyle: 'short'}
).format(new Date(req.created_at))"></i>
<blockquote x-text="`> ${req.reason}`"></blockquote>
</div> </div>
<div> </template>
<div> </div>
</template>
</div>
<div class="alert-aside">
<button class="btn btn-blue" @click="moderatePicture()"> <button class="btn btn-blue" @click="moderatePicture()">
{% trans %}Moderate{% endtrans %} {% trans %}Moderate{% endtrans %}
</button> </button>
<button class="btn btn-red" @click.prevent="deletePicture()"> <button class="btn btn-red" @click.prevent="deletePicture()">
{% trans %}Delete{% endtrans %} {% trans %}Delete{% endtrans %}
</button> </button>
</div>
<p x-show="!!moderationError" x-text="moderationError"></p> <p x-show="!!moderationError" x-text="moderationError"></p>
</div> </div>
</div> </div>
@ -58,7 +75,6 @@
<div class="container" id="pict"> <div class="container" id="pict">
<div class="main"> <div class="main">
<div class="photo" :aria-busy="currentPicture.imageLoading"> <div class="photo" :aria-busy="currentPicture.imageLoading">
<img <img
:src="currentPicture.compressed_url" :src="currentPicture.compressed_url"
@ -96,7 +112,9 @@
{% trans %}HD version{% endtrans %} {% trans %}HD version{% endtrans %}
</a> </a>
<br> <br>
<a class="text danger" href="?ask_removal">{% trans %}Ask for removal{% endtrans %}</a> <a class="text danger" :href="`/sas/picture/${currentPicture.id}/report`">
{% trans %}Ask for removal{% endtrans %}
</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="`/sas/picture/${currentPicture.id}/edit/`"><i class="fa-regular fa-pen-to-square edit-action"></i></a>

View File

@ -3,35 +3,45 @@ from django.db import transaction
from django.test import TestCase from django.test import TestCase
from django.urls import reverse from django.urls import reverse
from model_bakery import baker from model_bakery import baker
from model_bakery.recipe import Recipe
from pytest_django.asserts import assertNumQueries from pytest_django.asserts import assertNumQueries
from core.baker_recipes import old_subscriber_user, subscriber_user from core.baker_recipes import old_subscriber_user, subscriber_user
from core.models import RealGroup, User from core.models import RealGroup, SithFile, User
from sas.baker_recipes import picture_recipe from sas.baker_recipes import picture_recipe
from sas.models import Album, PeoplePictureRelation, Picture from sas.models import Album, PeoplePictureRelation, Picture, PictureModerationRequest
class TestSas(TestCase): class TestSas(TestCase):
@classmethod @classmethod
def setUpTestData(cls): def setUpTestData(cls):
Picture.objects.all().delete() sas = SithFile.objects.get(pk=settings.SITH_SAS_ROOT_DIR_ID)
Picture.objects.exclude(id=sas.id).delete()
owner = User.objects.get(username="root") owner = User.objects.get(username="root")
cls.user_a = old_subscriber_user.make() cls.user_a = old_subscriber_user.make()
cls.user_b, cls.user_c = subscriber_user.make(_quantity=2) cls.user_b, cls.user_c = subscriber_user.make(_quantity=2)
picture = picture_recipe.extend(owner=owner) picture = picture_recipe.extend(owner=owner)
cls.album_a = baker.make(Album, is_in_sas=True) cls.album_a = baker.make(Album, is_in_sas=True, parent=sas)
cls.album_b = baker.make(Album, is_in_sas=True) cls.album_b = baker.make(Album, is_in_sas=True, parent=sas)
relation_recipe = Recipe(PeoplePictureRelation)
relations = []
for album in cls.album_a, cls.album_b: for album in cls.album_a, cls.album_b:
pictures = picture.make(parent=album, _quantity=5, _bulk_create=True) pictures = picture.make(parent=album, _quantity=5, _bulk_create=True)
baker.make(PeoplePictureRelation, picture=pictures[1], user=cls.user_a) relations.extend(
baker.make(PeoplePictureRelation, picture=pictures[2], user=cls.user_a) [
baker.make(PeoplePictureRelation, picture=pictures[2], user=cls.user_b) relation_recipe.prepare(picture=pictures[1], user=cls.user_a),
baker.make(PeoplePictureRelation, picture=pictures[3], user=cls.user_b) relation_recipe.prepare(picture=pictures[2], user=cls.user_a),
baker.make(PeoplePictureRelation, picture=pictures[4], user=cls.user_a) relation_recipe.prepare(picture=pictures[2], user=cls.user_b),
baker.make(PeoplePictureRelation, picture=pictures[4], user=cls.user_b) relation_recipe.prepare(picture=pictures[3], user=cls.user_b),
baker.make(PeoplePictureRelation, picture=pictures[4], user=cls.user_c) relation_recipe.prepare(picture=pictures[4], user=cls.user_a),
relation_recipe.prepare(picture=pictures[4], user=cls.user_b),
relation_recipe.prepare(picture=pictures[4], user=cls.user_c),
]
)
PeoplePictureRelation.objects.bulk_create(relations)
class TestPictureSearch(TestSas): class TestPictureSearch(TestSas):
@ -170,3 +180,49 @@ class TestPictureRelation(TestSas):
res = self.client.delete(f"/api/sas/relation/{relation.id}") res = self.client.delete(f"/api/sas/relation/{relation.id}")
assert res.status_code == 404 assert res.status_code == 404
assert PeoplePictureRelation.objects.count() == relation_count assert PeoplePictureRelation.objects.count() == relation_count
class TestPictureModeration(TestSas):
@classmethod
def setUpTestData(cls):
super().setUpTestData()
cls.sas_admin = baker.make(
User, groups=[RealGroup.objects.get(pk=settings.SITH_GROUP_SAS_ADMIN_ID)]
)
cls.picture = Picture.objects.filter(parent=cls.album_a)[0]
cls.picture.is_moderated = False
cls.picture.asked_for_removal = True
cls.picture.save()
cls.url = reverse("api:picture_moderate", kwargs={"picture_id": cls.picture.id})
baker.make(PictureModerationRequest, picture=cls.picture, author=cls.user_a)
def test_moderation_route_forbidden(self):
"""Test that basic users (even if owner) cannot moderate a picture."""
self.picture.owner = self.user_b
for user in baker.make(User), subscriber_user.make(), self.user_b:
self.client.force_login(user)
res = self.client.patch(self.url)
assert res.status_code == 403
def test_moderation_route_authorized(self):
"""Test that sas admins can moderate a picture."""
self.client.force_login(self.sas_admin)
res = self.client.patch(self.url)
assert res.status_code == 200
self.picture.refresh_from_db()
assert self.picture.is_moderated
assert not self.picture.asked_for_removal
assert not self.picture.moderation_requests.exists()
def test_get_moderation_requests(self):
"""Test that fetching moderation requests work."""
url = reverse(
"api:picture_moderation_requests", kwargs={"picture_id": self.picture.id}
)
self.client.force_login(self.sas_admin)
res = self.client.get(url)
assert res.status_code == 200
assert len(res.json()) == 1
assert res.json()[0]["author"]["id"] == self.user_a.id

View File

@ -20,7 +20,7 @@ from django.core.cache import cache
from django.test import Client, TestCase from django.test import Client, TestCase
from django.urls import reverse from django.urls import reverse
from model_bakery import baker from model_bakery import baker
from pytest_django.asserts import assertRedirects from pytest_django.asserts import assertInHTML, assertRedirects
from core.baker_recipes import old_subscriber_user, subscriber_user from core.baker_recipes import old_subscriber_user, subscriber_user
from core.models import RealGroup, User from core.models import RealGroup, User
@ -70,7 +70,9 @@ def test_album_access_non_subscriber(client: Client):
class TestSasModeration(TestCase): class TestSasModeration(TestCase):
@classmethod @classmethod
def setUpTestData(cls): def setUpTestData(cls):
album = baker.make(Album, parent_id=settings.SITH_SAS_ROOT_DIR_ID) album = baker.make(
Album, parent_id=settings.SITH_SAS_ROOT_DIR_ID, is_moderated=True
)
cls.pictures = picture_recipe.make( cls.pictures = picture_recipe.make(
parent=album, _quantity=10, _bulk_create=True parent=album, _quantity=10, _bulk_create=True
) )
@ -82,6 +84,9 @@ class TestSasModeration(TestCase):
) )
cls.simple_user = subscriber_user.make() cls.simple_user = subscriber_user.make()
def setUp(self):
cache.clear()
def test_moderation_page_sas_admin(self): def test_moderation_page_sas_admin(self):
"""Test that a moderator can see the pictures needing moderation.""" """Test that a moderator can see the pictures needing moderation."""
self.client.force_login(self.moderator) self.client.force_login(self.moderator)
@ -132,3 +137,37 @@ class TestSasModeration(TestCase):
) )
assert res.status_code == 403 assert res.status_code == 403
assert Picture.objects.filter(pk=self.to_moderate.id).exists() assert Picture.objects.filter(pk=self.to_moderate.id).exists()
def test_request_moderation_form_access(self):
"""Test that regular can access the form to ask for moderation."""
self.client.force_login(self.simple_user)
res = self.client.get(
reverse(
"sas:picture_ask_removal", kwargs={"picture_id": self.pictures[1].id}
),
)
assert res.status_code == 200
def test_request_moderation_form_submit(self):
"""Test that moderation requests are created."""
self.client.force_login(self.simple_user)
message = "J'aime pas cette photo (ni la Cocarde)."
url = reverse(
"sas:picture_ask_removal", kwargs={"picture_id": self.pictures[1].id}
)
res = self.client.post(url, data={"reason": message})
assertRedirects(
res, reverse("sas:album", kwargs={"album_id": self.pictures[1].parent_id})
)
assert self.pictures[1].moderation_requests.count() == 1
assert self.pictures[1].moderation_requests.first().reason == message
# test that the user cannot ask for moderation twice
res = self.client.post(url, data={"reason": message})
assert res.status_code == 200
assert self.pictures[1].moderation_requests.count() == 1
assertInHTML(
'<ul class="errorlist nonfield"><li>'
"Vous avez déjà déposé une demande de retrait pour cette photo.</li></ul>",
res.content.decode(),
)

View File

@ -34,6 +34,11 @@ urlpatterns = [
PictureEditView.as_view(), PictureEditView.as_view(),
name="picture_edit", name="picture_edit",
), ),
path(
"picture/<int:picture_id>/report",
PictureAskRemovalView.as_view(),
name="picture_ask_removal",
),
path("picture/<int:picture_id>/download/", send_pict, name="download"), path("picture/<int:picture_id>/download/", send_pict, name="download"),
path( path(
"picture/<int:picture_id>/download/compressed/", "picture/<int:picture_id>/download/compressed/",

View File

@ -12,14 +12,12 @@
# OR WITHIN THE LOCAL FILE "LICENSE" # OR WITHIN THE LOCAL FILE "LICENSE"
# #
# #
from typing import Any
from ajax_select import make_ajax_field
from ajax_select.fields import AutoCompleteSelectMultipleField
from django import forms
from django.conf import settings from django.conf import settings
from django.core.exceptions import PermissionDenied from django.core.exceptions import PermissionDenied
from django.http import Http404, HttpResponse from django.http import Http404, HttpResponse, HttpResponseRedirect
from django.shortcuts import get_object_or_404, redirect from django.shortcuts import get_object_or_404
from django.urls import reverse, reverse_lazy from django.urls import reverse, reverse_lazy
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from django.views.generic import DetailView, TemplateView from django.views.generic import DetailView, TemplateView
@ -27,71 +25,14 @@ from django.views.generic.edit import FormMixin, FormView, UpdateView
from core.models import SithFile, User from core.models import SithFile, User
from core.views import CanEditMixin, CanViewMixin from core.views import CanEditMixin, CanViewMixin
from core.views.files import FileView, MultipleImageField, send_file from core.views.files import FileView, send_file
from core.views.forms import SelectDate from sas.forms import (
from sas.models import Album, PeoplePictureRelation, Picture AlbumEditForm,
PictureEditForm,
PictureModerationRequestForm,
class SASForm(forms.Form): SASForm,
album_name = forms.CharField(
label=_("Add a new album"), max_length=Album.NAME_MAX_LENGTH, required=False
)
images = MultipleImageField(
label=_("Upload images"),
required=False,
)
def process(self, parent, owner, files, *, automodere=False):
try:
if self.cleaned_data["album_name"] != "":
album = Album(
parent=parent,
name=self.cleaned_data["album_name"],
owner=owner,
is_moderated=automodere,
)
album.clean()
album.save()
except Exception as e:
self.add_error(
None,
_("Error creating album %(album)s: %(msg)s")
% {"album": self.cleaned_data["album_name"], "msg": repr(e)},
)
for f in files:
new_file = Picture(
parent=parent,
name=f.name,
file=f,
owner=owner,
mime_type=f.content_type,
size=f.size,
is_folder=False,
is_moderated=automodere,
)
if automodere:
new_file.moderator = owner
try:
new_file.clean()
new_file.generate_thumbnails()
new_file.save()
except Exception as e:
self.add_error(
None,
_("Error uploading file %(file_name)s: %(msg)s")
% {"file_name": f, "msg": repr(e)},
)
class RelationForm(forms.ModelForm):
class Meta:
model = PeoplePictureRelation
fields = ["picture"]
widgets = {"picture": forms.HiddenInput}
users = AutoCompleteSelectMultipleField(
"users", show_help_text=False, help_text="", label=_("Add user"), required=False
) )
from sas.models import Album, Picture
class SASMainView(FormView): class SASMainView(FormView):
@ -138,11 +79,6 @@ class PictureView(CanViewMixin, DetailView):
self.object.rotate(270) self.object.rotate(270)
if "rotate_left" in request.GET: if "rotate_left" in request.GET:
self.object.rotate(90) self.object.rotate(90)
if "ask_removal" in request.GET.keys():
self.object.is_moderated = False
self.object.asked_for_removal = True
self.object.save()
return redirect("sas:album", album_id=self.object.parent.id)
return super().get(request, *args, **kwargs) return super().get(request, *args, **kwargs)
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
@ -293,26 +229,6 @@ class ModerationView(TemplateView):
return kwargs return kwargs
class PictureEditForm(forms.ModelForm):
class Meta:
model = Picture
fields = ["name", "parent"]
parent = make_ajax_field(Picture, "parent", "files", help_text="")
class AlbumEditForm(forms.ModelForm):
class Meta:
model = Album
fields = ["name", "date", "file", "parent", "edit_groups"]
name = forms.CharField(max_length=Album.NAME_MAX_LENGTH, label=_("file name"))
date = forms.DateField(label=_("Date"), widget=SelectDate, required=True)
parent = make_ajax_field(Album, "parent", "files", help_text="")
edit_groups = make_ajax_field(Album, "edit_groups", "groups", help_text="")
recursive = forms.BooleanField(label=_("Apply rights recursively"), required=False)
class PictureEditView(CanEditMixin, UpdateView): class PictureEditView(CanEditMixin, UpdateView):
model = Picture model = Picture
form_class = PictureEditForm form_class = PictureEditForm
@ -320,6 +236,41 @@ class PictureEditView(CanEditMixin, UpdateView):
pk_url_kwarg = "picture_id" pk_url_kwarg = "picture_id"
class PictureAskRemovalView(CanViewMixin, DetailView, FormView):
"""View to allow users to ask pictures to be removed."""
model = Picture
template_name = "sas/ask_picture_removal.jinja"
pk_url_kwarg = "picture_id"
form_class = PictureModerationRequestForm
def get_form_kwargs(self) -> dict[str, Any]:
"""Add the user and picture to the form kwargs.
Those are required to create the PictureModerationRequest,
and aren't part of the form itself
(picture is a path parameter, and user is the request user).
"""
return super().get_form_kwargs() | {
"user": self.request.user,
"picture": self.object,
}
def get_success_url(self) -> str:
"""Return the URL to the album containing the picture."""
album = Album.objects.filter(pk=self.object.parent_id).first()
if not album:
return reverse("sas:main")
return album.get_absolute_url()
def form_valid(self, form: PictureModerationRequestForm) -> HttpResponseRedirect:
form.save()
self.object.is_moderated = False
self.object.asked_for_removal = True
self.object.save()
return super().form_valid(form)
class AlbumEditView(CanEditMixin, UpdateView): class AlbumEditView(CanEditMixin, UpdateView):
model = Album model = Album
form_class = AlbumEditForm form_class = AlbumEditForm

View File

@ -1,4 +1,3 @@
// biome-ignore lint/correctness/noUndeclaredDependencies: webpack works with commonjs
const BundleAnalyzerPlugin = require("webpack-bundle-analyzer").BundleAnalyzerPlugin; const BundleAnalyzerPlugin = require("webpack-bundle-analyzer").BundleAnalyzerPlugin;
const config = require("./webpack.config.js"); const config = require("./webpack.config.js");