mirror of
https://github.com/ae-utbm/sith.git
synced 2024-11-01 03:48:04 +00:00
Merge pull request #868 from ae-utbm/delete-picture-confirm-button
Delete picture confirm button
This commit is contained in:
commit
496ad7ce9b
@ -333,9 +333,18 @@ a:not(.button) {
|
||||
border: #fc8181 1px solid;
|
||||
}
|
||||
|
||||
.alert-title {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.alert-main {
|
||||
flex: 2;
|
||||
}
|
||||
|
||||
.alert-aside {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
.tool_bar {
|
||||
|
@ -148,7 +148,6 @@ import type {
|
||||
GroupedDataFormat,
|
||||
LoadingData,
|
||||
Options,
|
||||
PlainObject,
|
||||
} from "select2";
|
||||
import "select2/dist/css/select2.css";
|
||||
|
||||
@ -181,7 +180,7 @@ interface Select2Options {
|
||||
* Create a new select2 with sith presets
|
||||
*/
|
||||
export function sithSelect2(options: Select2Options) {
|
||||
const elem: PlainObject = $(options.element);
|
||||
const elem = $(options.element as HTMLInputElement);
|
||||
return elem.select2({
|
||||
theme: elem[0].multiple ? "classic" : "default",
|
||||
minimumInputLength: 2,
|
||||
|
@ -6,7 +6,7 @@
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"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"
|
||||
"Last-Translator: Maréchal <thomas.girod@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
|
||||
#: core/models.py:1524 core/models.py:1553 core/models.py:1577
|
||||
#: 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
|
||||
msgid "date"
|
||||
msgstr "date"
|
||||
@ -148,7 +148,7 @@ msgstr "méthode de paiement"
|
||||
msgid "cheque number"
|
||||
msgstr "numéro de chèque"
|
||||
|
||||
#: accounting/models.py:286 eboutic/models.py:287
|
||||
#: accounting/models.py:286 eboutic/models.py:291
|
||||
msgid "invoice"
|
||||
msgstr "facture"
|
||||
|
||||
@ -210,7 +210,7 @@ msgstr "Utilisateur"
|
||||
msgid "Club"
|
||||
msgstr "Club"
|
||||
|
||||
#: accounting/models.py:322 core/views/user.py:281
|
||||
#: accounting/models.py:322 core/views/user.py:283
|
||||
msgid "Account"
|
||||
msgstr "Compte"
|
||||
|
||||
@ -378,14 +378,14 @@ msgstr "Compte en banque : "
|
||||
#: launderette/views.py:217 pedagogy/templates/pedagogy/guide.jinja:99
|
||||
#: pedagogy/templates/pedagogy/guide.jinja:114
|
||||
#: pedagogy/templates/pedagogy/uv_detail.jinja:189
|
||||
#: sas/templates/sas/album.jinja:32 sas/templates/sas/moderation.jinja:18
|
||||
#: sas/templates/sas/picture.jinja:50 trombi/templates/trombi/detail.jinja:35
|
||||
#: sas/templates/sas/album.jinja:36 sas/templates/sas/moderation.jinja:18
|
||||
#: sas/templates/sas/picture.jinja:69 trombi/templates/trombi/detail.jinja:35
|
||||
#: trombi/templates/trombi/edit_profile.jinja:35
|
||||
msgid "Delete"
|
||||
msgstr "Supprimer"
|
||||
|
||||
#: 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"
|
||||
msgstr "Infos"
|
||||
|
||||
@ -419,7 +419,7 @@ msgstr "Nouveau compte club"
|
||||
#: 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/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/counter_list.jinja:17
|
||||
#: 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:113
|
||||
#: 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
|
||||
msgid "Edit"
|
||||
msgstr "Éditer"
|
||||
@ -616,7 +616,7 @@ msgstr "No"
|
||||
#: counter/templates/counter/last_ops.jinja:20
|
||||
#: counter/templates/counter/last_ops.jinja:45
|
||||
#: 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
|
||||
msgid "Date"
|
||||
msgstr "Date"
|
||||
@ -650,7 +650,7 @@ msgid "Done"
|
||||
msgstr "Effectuées"
|
||||
|
||||
#: 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/uv_detail.jinja:142
|
||||
#: 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
|
||||
#: core/templates/core/user_account_detail.jinja:17
|
||||
#: 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"
|
||||
msgstr "Comptoir"
|
||||
|
||||
#: club/forms.py:167 counter/views.py:688
|
||||
#: club/forms.py:167 counter/views.py:690
|
||||
msgid "Products"
|
||||
msgstr "Produits"
|
||||
|
||||
#: club/forms.py:172 counter/views.py:693
|
||||
#: club/forms.py:172 counter/views.py:695
|
||||
msgid "Archived products"
|
||||
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"
|
||||
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"
|
||||
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à."
|
||||
|
||||
#: 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
|
||||
#: trombi/models.py:206
|
||||
msgid "user"
|
||||
@ -1373,8 +1373,8 @@ msgstr "Anciens membres"
|
||||
msgid "History"
|
||||
msgstr "Historique"
|
||||
|
||||
#: club/views.py:116 core/templates/core/base.jinja:107 core/views/user.py:223
|
||||
#: sas/templates/sas/picture.jinja:91 trombi/views.py:61
|
||||
#: club/views.py:116 core/templates/core/base.jinja:104 core/views/user.py:225
|
||||
#: sas/templates/sas/picture.jinja:108 trombi/views.py:61
|
||||
msgid "Tools"
|
||||
msgstr "Outils"
|
||||
|
||||
@ -1517,7 +1517,7 @@ msgstr "Administration des mailing listes"
|
||||
#: com/templates/com/news_detail.jinja:39
|
||||
#: core/templates/core/file_detail.jinja:65
|
||||
#: 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"
|
||||
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:289
|
||||
#: 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"
|
||||
msgstr "Auteur"
|
||||
|
||||
@ -1659,7 +1659,7 @@ msgid "Calls to moderate"
|
||||
msgstr "Appels à modérer"
|
||||
|
||||
#: com/templates/com/news_admin_list.jinja:242
|
||||
#: core/templates/core/base.jinja:222
|
||||
#: core/templates/core/base.jinja:219
|
||||
msgid "Events"
|
||||
msgstr "Événements"
|
||||
|
||||
@ -2246,7 +2246,7 @@ msgstr "avoir une notification pour chaque click"
|
||||
msgid "get a notification for every refilling"
|
||||
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"
|
||||
msgstr "nom du fichier"
|
||||
|
||||
@ -2388,7 +2388,7 @@ msgstr "403, Non autorisé"
|
||||
msgid "404, Not Found"
|
||||
msgstr "404. Non trouvé"
|
||||
|
||||
#: core/templates/core/500.jinja:11
|
||||
#: core/templates/core/500.jinja:9
|
||||
msgid "500, Server Error"
|
||||
msgstr "500, Erreur Serveur"
|
||||
|
||||
@ -2396,18 +2396,18 @@ msgstr "500, Erreur Serveur"
|
||||
msgid "Welcome!"
|
||||
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/password_reset_complete.jinja:5
|
||||
msgid "Login"
|
||||
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
|
||||
msgid "Register"
|
||||
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:183
|
||||
#: matmat/templates/matmat/search_form.jinja:39
|
||||
@ -2416,52 +2416,52 @@ msgstr "Inscription"
|
||||
msgid "Search"
|
||||
msgstr "Recherche"
|
||||
|
||||
#: core/templates/core/base.jinja:108
|
||||
#: core/templates/core/base.jinja:105
|
||||
msgid "Logout"
|
||||
msgstr "Déconnexion"
|
||||
|
||||
#: core/templates/core/base.jinja:156
|
||||
#: core/templates/core/base.jinja:153
|
||||
msgid "You do not have any unread notification"
|
||||
msgstr "Vous n'avez aucune notification non lue"
|
||||
|
||||
#: core/templates/core/base.jinja:161
|
||||
#: core/templates/core/base.jinja:158
|
||||
msgid "View more"
|
||||
msgstr "Voir plus"
|
||||
|
||||
#: core/templates/core/base.jinja:164
|
||||
#: core/templates/core/base.jinja:161
|
||||
#: forum/templates/forum/last_unread.jinja:21
|
||||
msgid "Mark all as read"
|
||||
msgstr "Marquer tout comme lu"
|
||||
|
||||
#: core/templates/core/base.jinja:212
|
||||
#: core/templates/core/base.jinja:209
|
||||
msgid "Main"
|
||||
msgstr "Accueil"
|
||||
|
||||
#: core/templates/core/base.jinja:214
|
||||
#: core/templates/core/base.jinja:211
|
||||
msgid "Associations & Clubs"
|
||||
msgstr "Associations & Clubs"
|
||||
|
||||
#: core/templates/core/base.jinja:216
|
||||
#: core/templates/core/base.jinja:213
|
||||
msgid "AE"
|
||||
msgstr "L'AE"
|
||||
|
||||
#: core/templates/core/base.jinja:217
|
||||
#: core/templates/core/base.jinja:214
|
||||
msgid "AE's clubs"
|
||||
msgstr "Les clubs de L'AE"
|
||||
|
||||
#: core/templates/core/base.jinja:218
|
||||
#: core/templates/core/base.jinja:215
|
||||
msgid "Others UTBM's Associations"
|
||||
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"
|
||||
msgstr "Élections"
|
||||
|
||||
#: core/templates/core/base.jinja:225
|
||||
#: core/templates/core/base.jinja:222
|
||||
msgid "Big event"
|
||||
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/last_unread.jinja:18
|
||||
#: forum/templates/forum/macros.jinja:90 forum/templates/forum/main.jinja:6
|
||||
@ -2470,11 +2470,11 @@ msgstr "Grandes Activités"
|
||||
msgid "Forum"
|
||||
msgstr "Forum"
|
||||
|
||||
#: core/templates/core/base.jinja:229
|
||||
#: core/templates/core/base.jinja:226
|
||||
msgid "Gallery"
|
||||
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
|
||||
#: eboutic/templates/eboutic/eboutic_main.jinja:4
|
||||
#: eboutic/templates/eboutic/eboutic_main.jinja:22
|
||||
@ -2484,75 +2484,75 @@ msgstr "Photos"
|
||||
msgid "Eboutic"
|
||||
msgstr "Eboutic"
|
||||
|
||||
#: core/templates/core/base.jinja:232
|
||||
#: core/templates/core/base.jinja:229
|
||||
msgid "Services"
|
||||
msgstr "Services"
|
||||
|
||||
#: core/templates/core/base.jinja:234
|
||||
#: core/templates/core/base.jinja:231
|
||||
msgid "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_choose.jinja:4
|
||||
#: launderette/templates/launderette/launderette_main.jinja:4
|
||||
msgid "Launderette"
|
||||
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
|
||||
msgid "Files"
|
||||
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"
|
||||
msgstr "Pédagogie"
|
||||
|
||||
#: core/templates/core/base.jinja:241
|
||||
#: core/templates/core/base.jinja:238
|
||||
msgid "My Benefits"
|
||||
msgstr "Mes Avantages"
|
||||
|
||||
#: core/templates/core/base.jinja:243
|
||||
#: core/templates/core/base.jinja:240
|
||||
msgid "Sponsors"
|
||||
msgstr "Partenaires"
|
||||
|
||||
#: core/templates/core/base.jinja:244
|
||||
#: core/templates/core/base.jinja:241
|
||||
msgid "Subscriber benefits"
|
||||
msgstr "Les avantages cotisants"
|
||||
|
||||
#: core/templates/core/base.jinja:248
|
||||
#: core/templates/core/base.jinja:245
|
||||
msgid "Help"
|
||||
msgstr "Aide"
|
||||
|
||||
#: core/templates/core/base.jinja:250
|
||||
#: core/templates/core/base.jinja:247
|
||||
msgid "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"
|
||||
msgstr "Contacts"
|
||||
|
||||
#: core/templates/core/base.jinja:252
|
||||
#: core/templates/core/base.jinja:249
|
||||
msgid "Wiki"
|
||||
msgstr "Wiki"
|
||||
|
||||
#: core/templates/core/base.jinja:292
|
||||
#: core/templates/core/base.jinja:289
|
||||
msgid "Legal notices"
|
||||
msgstr "Mentions légales"
|
||||
|
||||
#: core/templates/core/base.jinja:293
|
||||
#: core/templates/core/base.jinja:290
|
||||
msgid "Intellectual property"
|
||||
msgstr "Propriété intellectuelle"
|
||||
|
||||
#: core/templates/core/base.jinja:294
|
||||
#: core/templates/core/base.jinja:291
|
||||
msgid "Help & Documentation"
|
||||
msgstr "Aide & Documentation"
|
||||
|
||||
#: core/templates/core/base.jinja:295
|
||||
#: core/templates/core/base.jinja:292
|
||||
msgid "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"
|
||||
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/file_delete_confirm.jinja:14
|
||||
#: counter/templates/counter/counter_click.jinja:121
|
||||
#: sas/templates/sas/ask_picture_removal.jinja:20
|
||||
msgid "Cancel"
|
||||
msgstr "Annuler"
|
||||
|
||||
@ -2614,24 +2615,24 @@ msgstr "Propriétés"
|
||||
|
||||
#: core/templates/core/file_detail.jinja:13
|
||||
#: core/templates/core/file_moderation.jinja:20
|
||||
#: sas/templates/sas/picture.jinja:84
|
||||
#: sas/templates/sas/picture.jinja:101
|
||||
msgid "Owner: "
|
||||
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
|
||||
msgid "Clear clipboard"
|
||||
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"
|
||||
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"
|
||||
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
|
||||
msgid "Clipboard: "
|
||||
msgstr "Presse-papier : "
|
||||
@ -2642,7 +2643,7 @@ msgstr "Nom réel : "
|
||||
|
||||
#: core/templates/core/file_detail.jinja:54
|
||||
#: core/templates/core/file_moderation.jinja:21
|
||||
#: sas/templates/sas/picture.jinja:75
|
||||
#: sas/templates/sas/picture.jinja:92
|
||||
msgid "Date: "
|
||||
msgstr "Date : "
|
||||
|
||||
@ -2998,7 +2999,7 @@ msgstr "Résultat de la recherche"
|
||||
msgid "Users"
|
||||
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"
|
||||
msgstr "Clubs"
|
||||
|
||||
@ -3039,11 +3040,11 @@ msgid "Eboutic invoices"
|
||||
msgstr "Facture eboutic"
|
||||
|
||||
#: 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"
|
||||
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"
|
||||
msgstr "L'utilisateur n'a pas de compte"
|
||||
|
||||
@ -3265,13 +3266,13 @@ msgstr "Photos de %(user_name)s"
|
||||
msgid "Download all my pictures"
|
||||
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
|
||||
msgid "To be moderated"
|
||||
msgstr "A modérer"
|
||||
|
||||
#: 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"
|
||||
msgstr "Préférences"
|
||||
|
||||
@ -3346,7 +3347,7 @@ msgstr "Outils utilisateurs"
|
||||
msgid "Sith management"
|
||||
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"
|
||||
msgstr "Groupes"
|
||||
|
||||
@ -3375,7 +3376,7 @@ msgid "Subscription stats"
|
||||
msgstr "Statistiques de cotisation"
|
||||
|
||||
#: core/templates/core/user_tools.jinja:48 counter/forms.py:164
|
||||
#: counter/views.py:683
|
||||
#: counter/views.py:685
|
||||
msgid "Counters"
|
||||
msgstr "Comptoirs"
|
||||
|
||||
@ -3392,16 +3393,16 @@ msgid "Product types management"
|
||||
msgstr "Gestion des types de produit"
|
||||
|
||||
#: 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"
|
||||
msgstr "Relevés de caisse"
|
||||
|
||||
#: 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"
|
||||
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:34
|
||||
#: 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"
|
||||
|
||||
#: 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
|
||||
msgid "Error uploading file %(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"
|
||||
msgstr "Appliquer les droits récursivement"
|
||||
|
||||
@ -3589,21 +3590,21 @@ msgstr "Utilisateurs à retirer du groupe"
|
||||
msgid "Users to add to group"
|
||||
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"
|
||||
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"
|
||||
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/user_profile.jinja:11
|
||||
msgid "Pictures"
|
||||
msgstr "Photos"
|
||||
|
||||
#: core/views/user.py:218
|
||||
#: core/views/user.py:220
|
||||
msgid "Galaxy"
|
||||
msgstr "Galaxie"
|
||||
|
||||
@ -3652,7 +3653,7 @@ msgstr "client"
|
||||
msgid "customers"
|
||||
msgstr "clients"
|
||||
|
||||
#: counter/models.py:74 counter/views.py:265
|
||||
#: counter/models.py:74 counter/views.py:267
|
||||
msgid "Not enough money"
|
||||
msgstr "Solde insuffisant"
|
||||
|
||||
@ -3780,11 +3781,11 @@ msgstr "est validé"
|
||||
msgid "refilling"
|
||||
msgstr "rechargement"
|
||||
|
||||
#: counter/models.py:752 eboutic/models.py:245
|
||||
#: counter/models.py:752 eboutic/models.py:249
|
||||
msgid "unit price"
|
||||
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"
|
||||
msgstr "quantité"
|
||||
|
||||
@ -3970,7 +3971,7 @@ msgstr "Liste des relevés de caisse"
|
||||
msgid "Theoric sums"
|
||||
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"
|
||||
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
|
||||
#: launderette/templates/launderette/launderette_admin.jinja:35
|
||||
#: 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
|
||||
msgid "Go"
|
||||
msgstr "Valider"
|
||||
@ -4127,7 +4128,7 @@ msgid "%(counter_name)s last operations"
|
||||
msgstr "Dernières opérations sur %(counter_name)s"
|
||||
|
||||
#: counter/templates/counter/product_list.jinja:4
|
||||
#: counter/templates/counter/product_list.jinja:12
|
||||
#: counter/templates/counter/product_list.jinja:11
|
||||
msgid "Product list"
|
||||
msgstr "Liste des produits"
|
||||
|
||||
@ -4135,11 +4136,11 @@ msgstr "Liste des produits"
|
||||
msgid "New product"
|
||||
msgstr "Nouveau produit"
|
||||
|
||||
#: counter/templates/counter/product_list.jinja:21
|
||||
#: counter/templates/counter/product_list.jinja:13
|
||||
msgid "Uncategorized"
|
||||
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."
|
||||
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)"
|
||||
msgstr "Top 100 barman %(counter_name)s (tous les semestres)"
|
||||
|
||||
#: counter/views.py:151
|
||||
#: counter/views.py:153
|
||||
msgid "Cash summary"
|
||||
msgstr "Relevé de caisse"
|
||||
|
||||
#: counter/views.py:160
|
||||
#: counter/views.py:162
|
||||
msgid "Last operations"
|
||||
msgstr "Dernières opérations"
|
||||
|
||||
#: counter/views.py:207
|
||||
#: counter/views.py:209
|
||||
msgid "Bad credentials"
|
||||
msgstr "Mauvais identifiants"
|
||||
|
||||
#: counter/views.py:209
|
||||
#: counter/views.py:211
|
||||
msgid "User is not 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"
|
||||
msgstr "Mauvais comptoir, quelqu'un est déjà connecté ailleurs"
|
||||
|
||||
#: counter/views.py:256
|
||||
#: counter/views.py:258
|
||||
msgid "Too young for that product"
|
||||
msgstr "Trop jeune pour ce produit"
|
||||
|
||||
#: counter/views.py:259
|
||||
#: counter/views.py:261
|
||||
msgid "Not allowed for that product"
|
||||
msgstr "Non autorisé pour ce produit"
|
||||
|
||||
#: counter/views.py:262
|
||||
#: counter/views.py:264
|
||||
msgid "No date of birth provided"
|
||||
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"
|
||||
msgstr "Vous n'avez pas assez d'argent pour acheter le panier"
|
||||
|
||||
#: counter/views.py:678
|
||||
#: counter/views.py:680
|
||||
msgid "Counter administration"
|
||||
msgstr "Administration des comptoirs"
|
||||
|
||||
#: counter/views.py:698
|
||||
#: counter/views.py:700
|
||||
msgid "Product types"
|
||||
msgstr "Types de produit"
|
||||
|
||||
#: counter/views.py:902
|
||||
#: counter/views.py:920
|
||||
msgid "10 cents"
|
||||
msgstr "10 centimes"
|
||||
|
||||
#: counter/views.py:903
|
||||
#: counter/views.py:921
|
||||
msgid "20 cents"
|
||||
msgstr "20 centimes"
|
||||
|
||||
#: counter/views.py:904
|
||||
#: counter/views.py:922
|
||||
msgid "50 cents"
|
||||
msgstr "50 centimes"
|
||||
|
||||
#: counter/views.py:905
|
||||
#: counter/views.py:923
|
||||
msgid "1 euro"
|
||||
msgstr "1 €"
|
||||
|
||||
#: counter/views.py:906
|
||||
#: counter/views.py:924
|
||||
msgid "2 euros"
|
||||
msgstr "2 €"
|
||||
|
||||
#: counter/views.py:907
|
||||
#: counter/views.py:925
|
||||
msgid "5 euros"
|
||||
msgstr "5 €"
|
||||
|
||||
#: counter/views.py:908
|
||||
#: counter/views.py:926
|
||||
msgid "10 euros"
|
||||
msgstr "10 €"
|
||||
|
||||
#: counter/views.py:909
|
||||
#: counter/views.py:927
|
||||
msgid "20 euros"
|
||||
msgstr "20 €"
|
||||
|
||||
#: counter/views.py:910
|
||||
#: counter/views.py:928
|
||||
msgid "50 euros"
|
||||
msgstr "50 €"
|
||||
|
||||
#: counter/views.py:912
|
||||
#: counter/views.py:930
|
||||
msgid "100 euros"
|
||||
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:933 counter/views.py:939 counter/views.py:945
|
||||
#: counter/views.py:951 counter/views.py:957
|
||||
msgid "Check amount"
|
||||
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:936 counter/views.py:942 counter/views.py:948
|
||||
#: counter/views.py:954 counter/views.py:960
|
||||
msgid "Check quantity"
|
||||
msgstr "Nombre de chèque"
|
||||
|
||||
#: counter/views.py:1462
|
||||
#: counter/views.py:1480
|
||||
msgid "people(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."
|
||||
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"
|
||||
msgstr "validé"
|
||||
|
||||
#: eboutic/models.py:206
|
||||
#: eboutic/models.py:210
|
||||
msgid "Invoice already validated"
|
||||
msgstr "Facture déjà validée"
|
||||
|
||||
#: eboutic/models.py:242
|
||||
#: eboutic/models.py:246
|
||||
msgid "product id"
|
||||
msgstr "ID du produit"
|
||||
|
||||
#: eboutic/models.py:243
|
||||
#: eboutic/models.py:247
|
||||
msgid "product name"
|
||||
msgstr "nom du produit"
|
||||
|
||||
#: eboutic/models.py:244
|
||||
#: eboutic/models.py:248
|
||||
msgid "product type id"
|
||||
msgstr "id du type du produit"
|
||||
|
||||
#: eboutic/models.py:261
|
||||
#: eboutic/models.py:265
|
||||
msgid "basket"
|
||||
msgstr "panier"
|
||||
|
||||
@ -5099,7 +5100,7 @@ msgstr "non noté"
|
||||
msgid "UV comment moderation"
|
||||
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"
|
||||
msgstr "Raison"
|
||||
|
||||
@ -5267,27 +5268,73 @@ msgstr "Utilisateur qui sera supprimé"
|
||||
msgid "User to be selected"
|
||||
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"
|
||||
msgstr "photo"
|
||||
|
||||
#: sas/templates/sas/album.jinja:9 sas/templates/sas/main.jinja:8
|
||||
#: sas/templates/sas/main.jinja:17 sas/templates/sas/picture.jinja:12
|
||||
#: sas/models.py:304
|
||||
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"
|
||||
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"
|
||||
msgstr "Albums"
|
||||
|
||||
#: sas/templates/sas/album.jinja:96
|
||||
#: sas/templates/sas/album.jinja:100
|
||||
msgid "Upload"
|
||||
msgstr "Envoyer"
|
||||
|
||||
#: sas/templates/sas/album.jinja:103
|
||||
#: sas/templates/sas/album.jinja:107
|
||||
msgid "Template generation time: "
|
||||
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
|
||||
msgid "You must be logged in to see the SAS."
|
||||
msgstr "Vous devez être connecté pour voir les photos."
|
||||
@ -5304,11 +5351,11 @@ msgstr "Toutes les catégories"
|
||||
msgid "SAS moderation"
|
||||
msgstr "Modération du SAS"
|
||||
|
||||
#: sas/templates/sas/picture.jinja:35
|
||||
#: sas/templates/sas/picture.jinja:36
|
||||
msgid "Asked for removal"
|
||||
msgstr "Retrait demandé"
|
||||
|
||||
#: sas/templates/sas/picture.jinja:38
|
||||
#: sas/templates/sas/picture.jinja:39
|
||||
msgid ""
|
||||
"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."
|
||||
@ -5317,39 +5364,26 @@ msgstr ""
|
||||
"SAS. Elle sera cachée pour les autres utilisateurs tant qu'elle ne sera pas "
|
||||
"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"
|
||||
msgstr "Version HD"
|
||||
|
||||
#: sas/templates/sas/picture.jinja:98
|
||||
#: sas/templates/sas/picture.jinja:116
|
||||
msgid "Ask for removal"
|
||||
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"
|
||||
msgstr "Image précédente"
|
||||
|
||||
#: sas/templates/sas/picture.jinja:137
|
||||
#: sas/templates/sas/picture.jinja:156
|
||||
msgid "People"
|
||||
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
|
||||
msgid "English"
|
||||
msgstr "Anglais"
|
||||
|
15
sas/admin.py
15
sas/admin.py
@ -15,7 +15,7 @@
|
||||
|
||||
from django.contrib import admin
|
||||
|
||||
from sas.models import Album, PeoplePictureRelation, Picture
|
||||
from sas.models import Album, PeoplePictureRelation, Picture, PictureModerationRequest
|
||||
|
||||
|
||||
@admin.register(Picture)
|
||||
@ -31,4 +31,15 @@ class PeoplePictureRelationAdmin(admin.ModelAdmin):
|
||||
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")
|
||||
|
32
sas/api.py
32
sas/api.py
@ -9,10 +9,17 @@ from ninja_extra.permissions import IsAuthenticated
|
||||
from ninja_extra.schemas import PaginatedResponseSchema
|
||||
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 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")
|
||||
@ -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):
|
||||
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):
|
||||
"""Mark a picture as moderated and remove its pending moderation requests."""
|
||||
picture = self.get_object_or_exception(Picture, pk=picture_id)
|
||||
picture.moderation_requests.all().delete()
|
||||
picture.is_moderated = True
|
||||
picture.moderator = self.context.request.user
|
||||
picture.asked_for_removal = False
|
||||
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")
|
||||
class UsersIdentifiedController(ControllerBase):
|
||||
|
124
sas/forms.py
Normal file
124
sas/forms.py
Normal 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)
|
68
sas/migrations/0004_picturemoderationrequest_and_more.py
Normal file
68
sas/migrations/0004_picturemoderationrequest_and_more.py
Normal 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"
|
||||
),
|
||||
),
|
||||
]
|
@ -273,16 +273,12 @@ class PeoplePictureRelation(models.Model):
|
||||
User,
|
||||
verbose_name=_("user"),
|
||||
related_name="pictures",
|
||||
null=False,
|
||||
blank=False,
|
||||
on_delete=models.CASCADE,
|
||||
)
|
||||
picture = models.ForeignKey(
|
||||
Picture,
|
||||
verbose_name=_("picture"),
|
||||
related_name="people",
|
||||
null=False,
|
||||
blank=False,
|
||||
on_delete=models.CASCADE,
|
||||
)
|
||||
|
||||
@ -290,4 +286,39 @@ class PeoplePictureRelation(models.Model):
|
||||
unique_together = ["user", "picture"]
|
||||
|
||||
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()}"
|
||||
|
@ -4,8 +4,8 @@ from django.urls import reverse
|
||||
from ninja import FilterSchema, ModelSchema, Schema
|
||||
from pydantic import Field, NonNegativeInt
|
||||
|
||||
from core.schemas import UserProfileSchema
|
||||
from sas.models import Picture
|
||||
from core.schemas import SimpleUserSchema, UserProfileSchema
|
||||
from sas.models import Picture, PictureModerationRequest
|
||||
|
||||
|
||||
class PictureFilterSchema(FilterSchema):
|
||||
@ -52,3 +52,11 @@ class PictureRelationCreationSchema(Schema):
|
||||
class IdentifiedUserSchema(Schema):
|
||||
id: int
|
||||
user: UserProfileSchema
|
||||
|
||||
|
||||
class ModerationRequestSchema(ModelSchema):
|
||||
author: SimpleUserSchema
|
||||
|
||||
class Meta:
|
||||
model = PictureModerationRequest
|
||||
fields = ["id", "created_at", "reason"]
|
||||
|
@ -11,10 +11,12 @@ import {
|
||||
type IdentifiedUserSchema,
|
||||
type PictureSchema,
|
||||
type PicturesFetchIdentificationsResponse,
|
||||
type PicturesFetchModerationRequestsResponse,
|
||||
type PicturesFetchPicturesData,
|
||||
type UserProfileSchema,
|
||||
picturesDeletePicture,
|
||||
picturesFetchIdentifications,
|
||||
picturesFetchModerationRequests,
|
||||
picturesFetchPictures,
|
||||
picturesIdentifyUsers,
|
||||
picturesModeratePicture,
|
||||
@ -27,18 +29,20 @@ import {
|
||||
* able to prefetch its data.
|
||||
*/
|
||||
class PictureWithIdentifications {
|
||||
identifications: PicturesFetchIdentificationsResponse | null = null;
|
||||
identifications: PicturesFetchIdentificationsResponse = null;
|
||||
imageLoading = false;
|
||||
identificationsLoading = false;
|
||||
moderationLoading = false;
|
||||
id: number;
|
||||
// biome-ignore lint/style/useNamingConvention: api is in snake_case
|
||||
compressed_url: string;
|
||||
moderationRequests: PicturesFetchModerationRequestsResponse = null;
|
||||
|
||||
constructor(picture: PictureSchema) {
|
||||
Object.assign(this, picture);
|
||||
}
|
||||
|
||||
static fromPicture(picture: PictureSchema) {
|
||||
static fromPicture(picture: PictureSchema): PictureWithIdentifications {
|
||||
return new PictureWithIdentifications(picture);
|
||||
}
|
||||
|
||||
@ -46,7 +50,7 @@ class PictureWithIdentifications {
|
||||
* If not already done, fetch the users identified on this picture and
|
||||
* populate the identifications field
|
||||
*/
|
||||
async loadIdentifications(options?: { forceReload: boolean }) {
|
||||
async loadIdentifications(options?: { forceReload: boolean }): Promise<void> {
|
||||
if (this.identificationsLoading) {
|
||||
return; // The users are already being fetched.
|
||||
}
|
||||
@ -65,11 +69,29 @@ class PictureWithIdentifications {
|
||||
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
|
||||
* @return {Promise<void>}
|
||||
*/
|
||||
async preload() {
|
||||
async preload(): Promise<void> {
|
||||
const img = new Image();
|
||||
img.src = this.compressed_url;
|
||||
if (!img.complete) {
|
||||
@ -87,12 +109,12 @@ interface ViewerConfig {
|
||||
userId: number;
|
||||
/** Url of the current album */
|
||||
albumUrl: string;
|
||||
/** Id of the album to displlay */
|
||||
/** Id of the album to display */
|
||||
albumId: number;
|
||||
/** id of the first picture to load on the page */
|
||||
firstPictureId: number;
|
||||
/** if the user is sas admin */
|
||||
userIsSasAdmin: number;
|
||||
userIsSasAdmin: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -103,9 +125,8 @@ exportToHtml("loadViewer", (config: ViewerConfig) => {
|
||||
Alpine.data("picture_viewer", () => ({
|
||||
/**
|
||||
* All the pictures that can be displayed on this picture viewer
|
||||
* @type PictureWithIdentifications[]
|
||||
**/
|
||||
pictures: [],
|
||||
pictures: [] as PictureWithIdentifications[],
|
||||
/**
|
||||
* The currently displayed picture
|
||||
* 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
|
||||
* @type ?PictureWithIdentifications
|
||||
**/
|
||||
nextPicture: null,
|
||||
nextPicture: null as PictureWithIdentifications,
|
||||
/**
|
||||
* 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
|
||||
**/
|
||||
@ -148,13 +167,11 @@ exportToHtml("loadViewer", (config: ViewerConfig) => {
|
||||
**/
|
||||
/**
|
||||
* Error message when a moderation operation fails
|
||||
* @type string
|
||||
**/
|
||||
moderationError: "",
|
||||
/**
|
||||
* Method of pushing new url to the browser history
|
||||
* Used by popstate event and always reset to it's default value when used
|
||||
* @type History
|
||||
**/
|
||||
pushstate: History.Push,
|
||||
|
||||
@ -166,7 +183,7 @@ exportToHtml("loadViewer", (config: ViewerConfig) => {
|
||||
} as PicturesFetchPicturesData)
|
||||
).map(PictureWithIdentifications.fromPicture);
|
||||
this.selector = sithSelect2({
|
||||
element: $(this.$refs.search) as unknown as HTMLElement,
|
||||
element: this.$refs.search,
|
||||
dataSource: remoteDataSource(await makeUrl(userSearchUsers), {
|
||||
excluded: () => [
|
||||
...(this.currentPicture.identifications || []).map(
|
||||
@ -213,7 +230,7 @@ exportToHtml("loadViewer", (config: ViewerConfig) => {
|
||||
* and the previous picture, the next picture and
|
||||
* the list of identified users are updated.
|
||||
*/
|
||||
async updatePicture() {
|
||||
async updatePicture(): Promise<void> {
|
||||
const updateArgs = {
|
||||
data: { sasPictureId: this.currentPicture.id },
|
||||
unused: "",
|
||||
@ -231,16 +248,23 @@ exportToHtml("loadViewer", (config: ViewerConfig) => {
|
||||
}
|
||||
|
||||
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.nextPicture = this.pictures[index + 1] || null;
|
||||
await this.currentPicture.loadIdentifications();
|
||||
this.$refs.mainPicture?.addEventListener("load", () => {
|
||||
// once the current picture is loaded,
|
||||
// start preloading the next and previous pictures
|
||||
this.nextPicture?.preload();
|
||||
this.previousPicture?.preload();
|
||||
});
|
||||
if (this.currentPicture.asked_for_removal && config.userIsSasAdmin) {
|
||||
await Promise.all([
|
||||
this.currentPicture.loadIdentifications(),
|
||||
this.currentPicture.loadModeration(),
|
||||
]);
|
||||
} else {
|
||||
await this.currentPicture.loadIdentifications();
|
||||
}
|
||||
},
|
||||
|
||||
async moderatePicture() {
|
||||
@ -253,7 +277,7 @@ exportToHtml("loadViewer", (config: ViewerConfig) => {
|
||||
return;
|
||||
}
|
||||
this.currentPicture.is_moderated = true;
|
||||
this.currentPicture.askedForRemoval = false;
|
||||
this.currentPicture.asked_for_removal = false;
|
||||
},
|
||||
|
||||
async deletePicture() {
|
||||
@ -277,7 +301,7 @@ exportToHtml("loadViewer", (config: ViewerConfig) => {
|
||||
/**
|
||||
* Send the identification request and update the list of identified users.
|
||||
*/
|
||||
async submitIdentification() {
|
||||
async submitIdentification(): Promise<void> {
|
||||
await picturesIdentifyUsers({
|
||||
path: {
|
||||
// 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
|
||||
* @param {PictureIdentification} identification
|
||||
* @return {boolean}
|
||||
*/
|
||||
canBeRemoved(identification: IdentifiedUserSchema) {
|
||||
canBeRemoved(identification: IdentifiedUserSchema): boolean {
|
||||
return config.userIsSasAdmin || identification.user.id === config.userId;
|
||||
},
|
||||
|
||||
/**
|
||||
* Untag a user from the current picture
|
||||
* @param {PictureIdentification} identification
|
||||
*/
|
||||
async removeIdentification(identification: IdentifiedUserSchema) {
|
||||
async removeIdentification(identification: IdentifiedUserSchema): Promise<void> {
|
||||
const res = await usersidentifiedDeleteRelation({
|
||||
// biome-ignore lint/style/useNamingConvention: api is in snake_case
|
||||
path: { relation_id: identification.id },
|
||||
|
28
sas/templates/sas/ask_picture_removal.jinja
Normal file
28
sas/templates/sas/ask_picture_removal.jinja
Normal 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 %}
|
@ -2,7 +2,7 @@
|
||||
|
||||
{%- block additional_css -%}
|
||||
<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 -%}
|
||||
|
||||
{%- block additional_js -%}
|
||||
@ -30,10 +30,10 @@
|
||||
<br>
|
||||
|
||||
<template x-if="!currentPicture.is_moderated">
|
||||
<div class="alert alert-red">
|
||||
<div class="alert alert-red" @click="console.log(currentPicture)">
|
||||
<div class="alert-main">
|
||||
<template x-if="currentPicture.askedForRemoval">
|
||||
<span class="important">{% trans %}Asked for removal{% endtrans %}</span>
|
||||
<template x-if="currentPicture.asked_for_removal">
|
||||
<h3 class="alert-title">{% trans %}Asked for removal{% endtrans %}</h3>
|
||||
</template>
|
||||
<p>
|
||||
{% trans trimmed %}
|
||||
@ -41,16 +41,33 @@
|
||||
It will be hidden to other users until it has been moderated.
|
||||
{% endtrans %}
|
||||
</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>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<div>
|
||||
<div>
|
||||
<button class="btn btn-blue" @click="moderatePicture()">
|
||||
{% trans %}Moderate{% endtrans %}
|
||||
</button>
|
||||
<button class="btn btn-red" @click.prevent="deletePicture()">
|
||||
{% trans %}Delete{% endtrans %}
|
||||
</button>
|
||||
</div>
|
||||
<div class="alert-aside">
|
||||
<button class="btn btn-blue" @click="moderatePicture()">
|
||||
{% trans %}Moderate{% endtrans %}
|
||||
</button>
|
||||
<button class="btn btn-red" @click.prevent="deletePicture()">
|
||||
{% trans %}Delete{% endtrans %}
|
||||
</button>
|
||||
<p x-show="!!moderationError" x-text="moderationError"></p>
|
||||
</div>
|
||||
</div>
|
||||
@ -58,7 +75,6 @@
|
||||
|
||||
<div class="container" id="pict">
|
||||
<div class="main">
|
||||
|
||||
<div class="photo" :aria-busy="currentPicture.imageLoading">
|
||||
<img
|
||||
:src="currentPicture.compressed_url"
|
||||
@ -96,7 +112,9 @@
|
||||
{% trans %}HD version{% endtrans %}
|
||||
</a>
|
||||
<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 class="buttons">
|
||||
<a class="button" :href="`/sas/picture/${currentPicture.id}/edit/`"><i class="fa-regular fa-pen-to-square edit-action"></i></a>
|
||||
|
@ -3,35 +3,45 @@ from django.db import transaction
|
||||
from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
from model_bakery import baker
|
||||
from model_bakery.recipe import Recipe
|
||||
from pytest_django.asserts import assertNumQueries
|
||||
|
||||
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.models import Album, PeoplePictureRelation, Picture
|
||||
from sas.models import Album, PeoplePictureRelation, Picture, PictureModerationRequest
|
||||
|
||||
|
||||
class TestSas(TestCase):
|
||||
@classmethod
|
||||
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")
|
||||
|
||||
cls.user_a = old_subscriber_user.make()
|
||||
cls.user_b, cls.user_c = subscriber_user.make(_quantity=2)
|
||||
|
||||
picture = picture_recipe.extend(owner=owner)
|
||||
cls.album_a = baker.make(Album, is_in_sas=True)
|
||||
cls.album_b = 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, parent=sas)
|
||||
relation_recipe = Recipe(PeoplePictureRelation)
|
||||
relations = []
|
||||
for album in cls.album_a, cls.album_b:
|
||||
pictures = picture.make(parent=album, _quantity=5, _bulk_create=True)
|
||||
baker.make(PeoplePictureRelation, picture=pictures[1], user=cls.user_a)
|
||||
baker.make(PeoplePictureRelation, picture=pictures[2], user=cls.user_a)
|
||||
baker.make(PeoplePictureRelation, picture=pictures[2], user=cls.user_b)
|
||||
baker.make(PeoplePictureRelation, picture=pictures[3], user=cls.user_b)
|
||||
baker.make(PeoplePictureRelation, picture=pictures[4], user=cls.user_a)
|
||||
baker.make(PeoplePictureRelation, picture=pictures[4], user=cls.user_b)
|
||||
baker.make(PeoplePictureRelation, picture=pictures[4], user=cls.user_c)
|
||||
relations.extend(
|
||||
[
|
||||
relation_recipe.prepare(picture=pictures[1], user=cls.user_a),
|
||||
relation_recipe.prepare(picture=pictures[2], user=cls.user_a),
|
||||
relation_recipe.prepare(picture=pictures[2], user=cls.user_b),
|
||||
relation_recipe.prepare(picture=pictures[3], user=cls.user_b),
|
||||
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):
|
||||
@ -170,3 +180,49 @@ class TestPictureRelation(TestSas):
|
||||
res = self.client.delete(f"/api/sas/relation/{relation.id}")
|
||||
assert res.status_code == 404
|
||||
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
|
||||
|
@ -20,7 +20,7 @@ from django.core.cache import cache
|
||||
from django.test import Client, TestCase
|
||||
from django.urls import reverse
|
||||
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.models import RealGroup, User
|
||||
@ -70,7 +70,9 @@ def test_album_access_non_subscriber(client: Client):
|
||||
class TestSasModeration(TestCase):
|
||||
@classmethod
|
||||
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(
|
||||
parent=album, _quantity=10, _bulk_create=True
|
||||
)
|
||||
@ -82,6 +84,9 @@ class TestSasModeration(TestCase):
|
||||
)
|
||||
cls.simple_user = subscriber_user.make()
|
||||
|
||||
def setUp(self):
|
||||
cache.clear()
|
||||
|
||||
def test_moderation_page_sas_admin(self):
|
||||
"""Test that a moderator can see the pictures needing moderation."""
|
||||
self.client.force_login(self.moderator)
|
||||
@ -132,3 +137,37 @@ class TestSasModeration(TestCase):
|
||||
)
|
||||
assert res.status_code == 403
|
||||
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(),
|
||||
)
|
||||
|
@ -34,6 +34,11 @@ urlpatterns = [
|
||||
PictureEditView.as_view(),
|
||||
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/compressed/",
|
||||
|
141
sas/views.py
141
sas/views.py
@ -12,14 +12,12 @@
|
||||
# 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.core.exceptions import PermissionDenied
|
||||
from django.http import Http404, HttpResponse
|
||||
from django.shortcuts import get_object_or_404, redirect
|
||||
from django.http import Http404, HttpResponse, HttpResponseRedirect
|
||||
from django.shortcuts import get_object_or_404
|
||||
from django.urls import reverse, reverse_lazy
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
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.views import CanEditMixin, CanViewMixin
|
||||
from core.views.files import FileView, MultipleImageField, send_file
|
||||
from core.views.forms import SelectDate
|
||||
from sas.models import Album, PeoplePictureRelation, Picture
|
||||
|
||||
|
||||
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
|
||||
)
|
||||
from core.views.files import FileView, send_file
|
||||
from sas.forms import (
|
||||
AlbumEditForm,
|
||||
PictureEditForm,
|
||||
PictureModerationRequestForm,
|
||||
SASForm,
|
||||
)
|
||||
from sas.models import Album, Picture
|
||||
|
||||
|
||||
class SASMainView(FormView):
|
||||
@ -138,11 +79,6 @@ class PictureView(CanViewMixin, DetailView):
|
||||
self.object.rotate(270)
|
||||
if "rotate_left" in request.GET:
|
||||
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)
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
@ -293,26 +229,6 @@ class ModerationView(TemplateView):
|
||||
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):
|
||||
model = Picture
|
||||
form_class = PictureEditForm
|
||||
@ -320,6 +236,41 @@ class PictureEditView(CanEditMixin, UpdateView):
|
||||
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):
|
||||
model = Album
|
||||
form_class = AlbumEditForm
|
||||
|
@ -1,4 +1,3 @@
|
||||
// biome-ignore lint/correctness/noUndeclaredDependencies: webpack works with commonjs
|
||||
const BundleAnalyzerPlugin = require("webpack-bundle-analyzer").BundleAnalyzerPlugin;
|
||||
const config = require("./webpack.config.js");
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user