+ ```
+
+
+ Args:
+ news: (News | int | string)
+ Either the `News` object to which this alert is related,
+ or its id, or the name of an Alpine which value is its id
+ user: The request.user
+ alpineState: An alpine variable name
+
+ Warning:
+ If you use this macro, you must also include `moderation-alert-index.ts`
+ in your template.
+ #}
+
+
+
+
+ {% trans %}Waiting publication{% endtrans %}
+
+ {% trans trimmed %}
+ This news isn't published and is visible
+ only by its author and the communication admins.
+ {% endtrans %}
+
+
+ {% trans trimmed %}
+ It will stay hidden for other users until it has been published.
+ {% endtrans %}
+
+ {% if user.has_perm("com.moderate_news") %}
+ {# This is an additional query for each non-moderated news,
+ but it will be executed only for admin users, and only one time
+ (if they do their job and moderated news as soon as they see them),
+ so it's still reasonable #}
+
+ {% else %}
+ {% for day, dates_group in news_dates.items() %}
+
+
+
+
{{ day|date('D') }}
+
{{ day|date('d') }}
+
{{ day|date('b') }}
+
+
+
+ {% for date in dates_group %}
+
+ {# if a non published news is in the object list,
+ the logged user is either an admin or the news author #}
+ {{ news_moderation_alert(date.news, user, "newsState") }}
+
- {% for news in object_list.filter(dates__start_date__gte=d,dates__start_date__lte=d+timedelta(days=1)).exclude(dates__end_date__lt=timezone.now()).order_by('dates__start_date') %}
-
-
@@ -255,14 +261,5 @@
{%- endif -%}
{%- endfor -%}
];
- window.addEventListener("DOMContentLoaded", () => {
- loadCounter({
- customerBalance: {{ customer.amount }},
- products: products,
- customerId: {{ customer.pk }},
- formInitial: formInitial,
- cancelUrl: "{{ cancel_url }}",
- });
- });
{% endblock script %}
\ No newline at end of file
diff --git a/counter/tests/test_counter.py b/counter/tests/test_counter.py
index 27ce62bc..d50bb6c4 100644
--- a/counter/tests/test_counter.py
+++ b/counter/tests/test_counter.py
@@ -681,6 +681,42 @@ class TestCounterClick(TestFullClickBase):
-3 - settings.SITH_ECOCUP_LIMIT
)
+ def test_recordings_when_negative(self):
+ self.refill_user(
+ self.customer,
+ self.cons.selling_price * 3 + Decimal(self.beer.selling_price),
+ )
+ self.customer.customer.recorded_products = settings.SITH_ECOCUP_LIMIT * -10
+ self.customer.customer.save()
+ self.login_in_bar(self.barmen)
+ assert (
+ self.submit_basket(
+ self.customer,
+ [BasketItem(self.dcons.id, 1)],
+ ).status_code
+ == 200
+ )
+ assert self.updated_amount(
+ self.customer
+ ) == self.cons.selling_price * 3 + Decimal(self.beer.selling_price)
+ assert (
+ self.submit_basket(
+ self.customer,
+ [BasketItem(self.cons.id, 3)],
+ ).status_code
+ == 302
+ )
+ assert self.updated_amount(self.customer) == Decimal(self.beer.selling_price)
+
+ assert (
+ self.submit_basket(
+ self.customer,
+ [BasketItem(self.beer.id, 1)],
+ ).status_code
+ == 302
+ )
+ assert self.updated_amount(self.customer) == 0
+
class TestCounterStats(TestCase):
@classmethod
diff --git a/counter/views/click.py b/counter/views/click.py
index eb6f8e28..46bf8e62 100644
--- a/counter/views/click.py
+++ b/counter/views/click.py
@@ -126,6 +126,11 @@ class BaseBasketForm(BaseFormSet):
if form.product.is_unrecord_product:
self.total_recordings += form.cleaned_data["quantity"]
+ # We don't want to block an user that have negative recordings
+ # if he isn't recording anything or reducing it's recording count
+ if self.total_recordings <= 0:
+ return
+
if not customer.can_record_more(self.total_recordings):
raise ValidationError(_("This user have reached his recording limit"))
diff --git a/docs/howto/prod.md b/docs/howto/prod.md
index 769f681f..df7c7644 100644
--- a/docs/howto/prod.md
+++ b/docs/howto/prod.md
@@ -2,13 +2,13 @@
Pour connecter l'application à une instance de sentry (ex: https://sentry.io),
il est nécessaire de configurer la variable `SENTRY_DSN`
-dans le fichier `settings_custom.py`.
+dans le fichier `.env`.
Cette variable est composée d'un lien complet vers votre projet sentry.
## Récupérer les statiques
Nous utilisons du SCSS dans le projet.
-En environnement de développement (`DEBUG=True`),
+En environnement de développement (`SITH_DEBUG=true`),
le SCSS est compilé à chaque fois que le fichier est demandé.
Pour la production, le projet considère
que chacun des fichiers est déjà compilé.
diff --git a/docs/tutorial/install-advanced.md b/docs/tutorial/install-advanced.md
index 318166ab..7b4fe493 100644
--- a/docs/tutorial/install-advanced.md
+++ b/docs/tutorial/install-advanced.md
@@ -47,19 +47,19 @@ Commencez par installer les dépendances système :
=== "Debian/Ubuntu"
```bash
- sudo apt install postgresql redis libq-dev nginx
+ sudo apt install postgresql libq-dev nginx
```
=== "Arch Linux"
```bash
- sudo pacman -S postgresql redis nginx
+ sudo pacman -S postgresql nginx
```
=== "macOS"
```bash
- brew install postgresql redis lipbq nginx
+ brew install postgresql lipbq nginx
export PATH="/usr/local/opt/libpq/bin:$PATH"
source ~/.zshrc
```
@@ -77,34 +77,6 @@ uv sync --group prod
C'est parce que ces dépendances compilent certains modules
à l'installation.
-## Configurer Redis
-
-Redis est utilisé comme cache.
-Assurez-vous qu'il tourne :
-
-```bash
-sudo systemctl redis status
-```
-
-Et s'il ne tourne pas, démarrez-le :
-
-```bash
-sudo systemctl start redis
-sudo systemctl enable redis # si vous voulez que redis démarre automatiquement au boot
-```
-
-Puis ajoutez le code suivant à la fin de votre fichier
-`settings_custom.py` :
-
-```python
-CACHES = {
- "default": {
- "BACKEND": "django.core.cache.backends.redis.RedisCache",
- "LOCATION": "redis://127.0.0.1:6379",
- }
-}
-```
-
## Configurer PostgreSQL
PostgreSQL est utilisé comme base de données.
@@ -139,26 +111,19 @@ en étant connecté en tant que postgres :
psql -d sith -c "GRANT ALL PRIVILEGES ON SCHEMA public to sith";
```
-Puis ajoutez le code suivant à la fin de votre
-`settings_custom.py` :
+Puis modifiez votre `.env`.
+Dedans, décommentez l'url de la base de données
+de postgres et commentez l'url de sqlite :
-```python
-DATABASES = {
- "default": {
- "ENGINE": "django.db.backends.postgresql",
- "NAME": "sith",
- "USER": "sith",
- "PASSWORD": "password",
- "HOST": "localhost",
- "PORT": "", # laissez ce champ vide pour que le choix du port soit automatique
- }
-}
+```dotenv
+#DATABASE_URL=sqlite:///db.sqlite3
+DATABASE_URL=postgres://sith:password@localhost:5432/sith
```
Enfin, créez vos données :
```bash
-uv run ./manage.py populate
+uv run ./manage.py setup
```
!!! note
@@ -247,7 +212,7 @@ Puis lancez ou relancez nginx :
sudo systemctl restart nginx
```
-Dans votre `settings_custom.py`, remplacez `DEBUG=True` par `DEBUG=False`.
+Dans votre `.env`, remplacez `SITH_DEBUG=true` par `SITH_DEBUG=false`.
Enfin, démarrez le serveur Django :
@@ -259,7 +224,7 @@ uv run ./manage.py runserver 8001
Et c'est bon, votre reverse-proxy est prêt à tourner devant votre serveur.
Nginx écoutera sur le port 8000.
Toutes les requêtes vers des fichiers statiques et les medias publiques
-seront seront servies directement par nginx.
+seront servies directement par nginx.
Toutes les autres requêtes seront transmises au serveur django.
@@ -273,3 +238,64 @@ un cron pour la mettre à jour au moins une fois par jour.
```bash
python manage.py update_spam_database
```
+
+## Personnaliser l'environnement
+
+Le site utilise beaucoup de variables configurables via l'environnement.
+Cependant, pour des raisons de maintenabilité et de simplicité
+pour les nouveaux développeurs, nous n'avons mis dans le fichier
+`.env.example` que celles qui peuvent nécessiter d'être fréquemment modifiées
+(par exemple, l'url de connexion à la db, ou l'activation du mode debug).
+
+Cependant, il en existe beaucoup d'autres, que vous pouvez trouver
+dans le `settings.py` en recherchant `env.`
+(avec `grep` ou avec un ++ctrl+f++ dans votre éditeur).
+
+Si le besoin de les modifier se présente, c'est chose possible.
+Il suffit de rajouter la paire clef-valeur correspondante dans le `.env`.
+
+!!!tip
+
+ Si vous utilisez nushell,
+ vous pouvez automatiser le processus avec
+ avec le script suivant, qui va parser le `settings.py`
+ pour récupérer toutes les variables d'environnement qui ne sont pas
+ définies dans le .env puis va les rajouter :
+
+ ```nu
+ # si le fichier .env n'existe pas, on le crée
+ if not (".env" | path exists) {
+ cp .env.example .env
+ }
+
+ # puis on récupère les variables d'environnement déjà existantes
+ let existing = open .env
+
+ # on récupère toutes les variables d'environnement utilisées
+ # dans le settings.py qui ne sont pas encore définies dans le .env,
+ # on les convertit dans un format .env,
+ # puis on les ajoute à la fin du .env
+ let regex = '(env\.)(?\w+)\(\s*"(?\w+)"(\s*(, default=)(?.+))?\s*\)';
+ let content = open sith/settings.py;
+ let vars = $content
+ | parse --regex $regex
+ | filter { |i| $i.env_name not-in $existing }
+ | each { |i|
+ let parsed_value = match [$i.method, $i.value] {
+ ["str", "None"] => ""
+ ["bool", $val] => ($val | str downcase)
+ ["list", $val] => ($val | str trim -c '[' | str trim -c ']')
+ ["path", $val] => ($val | str replace 'BASE_DIR / "' $'"(pwd)/')
+ [_, $val] => $val
+ }
+ $"($i.env_name)=($parsed_value)"
+ }
+
+ if ($vars | is-not-empty) {
+ # on ajoute les nouvelles valeurs,
+ # en mettant une ligne vide de séparation avec les anciennes
+ ["", ...$vars] | save --append .env
+ }
+
+ print $"($vars | length) values added to .env"
+ ```
\ No newline at end of file
diff --git a/docs/tutorial/install.md b/docs/tutorial/install.md
index bdd2cfc5..0a621587 100644
--- a/docs/tutorial/install.md
+++ b/docs/tutorial/install.md
@@ -7,6 +7,7 @@ Certaines dépendances sont nécessaires niveau système :
- libjpeg
- zlib1g-dev
- gettext
+- redis
### Installer WSL
@@ -65,8 +66,8 @@ cd /mnt//vos/fichiers/comme/dhab
```bash
sudo apt install curl build-essential libssl-dev \
- libjpeg-dev zlib1g-dev npm libffi-dev pkg-config \
- gettext git
+ libjpeg-dev zlib1g-dev npm libffi-dev pkg-config \
+ gettext git redis
curl -LsSf https://astral.sh/uv/install.sh | sh
```
@@ -75,7 +76,7 @@ cd /mnt//vos/fichiers/comme/dhab
```bash
sudo pacman -Syu # on s'assure que les dépôts et le système sont à jour
- sudo pacman -S uv gcc git gettext pkgconf npm
+ sudo pacman -S uv gcc git gettext pkgconf npm redis
```
=== "macOS"
@@ -84,7 +85,7 @@ cd /mnt//vos/fichiers/comme/dhab
Il est également nécessaire d'avoir installé xcode
```bash
- brew install git uv npm
+ brew install git uv npm redis
# Pour bien configurer gettext
brew link gettext # (suivez bien les instructions supplémentaires affichées)
@@ -99,6 +100,15 @@ cd /mnt//vos/fichiers/comme/dhab
Python ne fait pas parti des dépendances puisqu'il est automatiquement
installé par uv.
+Parmi les dépendances installées se trouve redis (que nous utilisons comme cache).
+Redis est un service qui doit être activé pour être utilisé.
+Pour cela, effectuez les commandes :
+
+```bash
+sudo systemctl start redis
+sudo systemctl enable redis # si vous voulez que redis démarre automatiquement au boot
+```
+
## Finaliser l'installation
Clonez le projet (depuis votre console WSL, si vous utilisez WSL)
@@ -120,20 +130,24 @@ uv run ./manage.py install_xapian
de texte à l'écran.
C'est normal, il ne faut pas avoir peur.
-Maintenant que les dépendances sont installées, nous
-allons créer la base de données, la remplir avec des données de test,
-et compiler les traductions.
-Cependant, avant de faire cela, il est nécessaire de modifier
-la configuration pour signifier que nous sommes en mode développement.
-Pour cela, nous allons créer un fichier `sith/settings_custom.py`
-et l'utiliser pour surcharger les settings de base.
+Une fois les dépendances installées, il faut encore
+mettre en place quelques éléments de configuration,
+qui peuvent varier d'un environnement à l'autre.
+Ces variables sont stockées dans un fichier `.env`.
+Pour le créer, vous pouvez copier le fichier `.env.example` :
```bash
-echo "DEBUG=True" > sith/settings_custom.py
-echo 'SITH_URL = "localhost:8000"' >> sith/settings_custom.py
+cp .env.example .env
```
-Enfin, nous pouvons lancer les commandes suivantes :
+Les variables par défaut contenues dans le fichier `.env`
+devraient convenir pour le développement, sans modification.
+
+Maintenant que les dépendances sont installées
+et la configuration remplie, nous allons pouvoir générer
+des données utiles pendant le développement.
+
+Pour cela, lancez les commandes suivantes :
```bash
# Prépare la base de données
@@ -171,6 +185,30 @@ uv run ./manage.py runserver
[http://localhost:8000/api/docs](http://localhost:8000/api/docs),
une interface swagger, avec toutes les routes de l'API.
+!!! question "Pourquoi l'installation est aussi complexe ?"
+
+ Cette question nous a été posée de nombreuses fois par des personnes
+ essayant d'installer le projet.
+ Il y a en effet un certain nombre d'étapes à suivre,
+ de paquets à installer et de commandes à exécuter.
+
+ Le processus d'installation peut donc sembler complexe.
+
+ En réalité, il est difficile de faire plus simple.
+ En effet, un site web a besoin de beaucoup de composants
+ pour être développé : il lui faut au minimum
+ une base de données, un cache, un bundler Javascript
+ et un interpréteur pour le code du serveur.
+ Pour nos besoin particuliers, nous utilisons également
+ un moteur de recherche full-text.
+
+ Nous avons tenté au maximum de limiter le nombre de dépendances
+ et de sélecionner les plus simples à installer.
+ Cependant, il est impossible de retirer l'intégralité
+ de la complexité du processus.
+ Si vous rencontrez des difficulté lors de l'installation,
+ n'hésitez pas à demander de l'aide.
+
## Générer la documentation
La documentation est automatiquement mise en ligne à chaque envoi de code sur GitHub.
diff --git a/docs/tutorial/structure.md b/docs/tutorial/structure.md
index bc3fed36..aff331d2 100644
--- a/docs/tutorial/structure.md
+++ b/docs/tutorial/structure.md
@@ -72,12 +72,14 @@ sith/
├── .gitattributes
├── .gitignore
├── .mailmap
-├── manage.py (26)
-├── mkdocs.yml (27)
+├── .env (26)
+├── .env.example (27)
+├── manage.py (28)
+├── mkdocs.yml (29)
├── uv.lock
-├── pyproject.toml (28)
-├── .venv/ (29)
-├── .python-version (30)
+├── pyproject.toml (30)
+├── .venv/ (31)
+├── .python-version (32)
└── README.md
```
@@ -121,15 +123,19 @@ sith/
de manière transparente pour l'utilisateur.
24. Fichier de configuration de coverage.
25. Fichier de configuration de direnv.
-26. Fichier généré automatiquement par Django. C'est lui
+26. Contient les variables d'environnement, qui sont susceptibles
+ de varier d'une machine à l'autre.
+27. Contient des valeurs par défaut pour le `.env`
+ pouvant convenir à un environnment de développement local
+28. Fichier généré automatiquement par Django. C'est lui
qui permet d'appeler des commandes de gestion du projet
avec la syntaxe `python ./manage.py `
-27. Le fichier de configuration de la documentation,
+29. Le fichier de configuration de la documentation,
avec ses plugins et sa table des matières.
-28. Le fichier où sont déclarés les dépendances et la configuration
+30. Le fichier où sont déclarés les dépendances et la configuration
de certaines d'entre elles.
-29. Dossier d'environnement virtuel généré par uv
-30. Fichier qui contrôle quel version de python utiliser pour le projet
+31. Dossier d'environnement virtuel généré par uv
+32. Fichier qui contrôle quelle version de python utiliser pour le projet
## L'application principale
@@ -144,10 +150,9 @@ Il est organisé comme suit :
```
sith/
├── settings.py (1)
-├── settings_custom.py (2)
-├── toolbar_debug.py (3)
-├── urls.py (4)
-└── wsgi.py (5)
+├── toolbar_debug.py (2)
+├── urls.py (3)
+└── wsgi.py (4)
```
@@ -155,13 +160,10 @@ sith/
Ce fichier contient les paramètres de configuration du projet.
Par exemple, il contient la liste des applications
installées dans le projet.
-2. Configuration maison pour votre environnement.
- Toute variable que vous définissez dans ce fichier sera prioritaire
- sur la configuration donnée dans `settings.py`.
-3. Configuration de la barre de debug.
+2. Configuration de la barre de debug.
C'est inutilisé en prod, mais c'est très pratique en développement.
-4. Fichier de configuration des urls du projet.
-5. Fichier de configuration pour le serveur WSGI.
+3. Fichier de configuration des urls du projet.
+4. Fichier de configuration pour le serveur WSGI.
WSGI est un protocole de communication entre le serveur
et les applications.
Ce fichier ne vous servira sans doute pas sur un environnement
diff --git a/locale/fr/LC_MESSAGES/django.po b/locale/fr/LC_MESSAGES/django.po
index 0cfef902..19b164df 100644
--- a/locale/fr/LC_MESSAGES/django.po
+++ b/locale/fr/LC_MESSAGES/django.po
@@ -6,7 +6,7 @@
msgid ""
msgstr ""
"Report-Msgid-Bugs-To: \n"
-"POT-Creation-Date: 2025-02-12 15:55+0100\n"
+"POT-Creation-Date: 2025-02-25 16:38+0100\n"
"PO-Revision-Date: 2016-07-18\n"
"Last-Translator: Maréchal \n"
@@ -310,7 +310,7 @@ msgstr "Compte en banque : "
#: accounting/templates/accounting/club_account_details.jinja
#: accounting/templates/accounting/label_list.jinja
#: club/templates/club/club_sellings.jinja club/templates/club/mailing.jinja
-#: com/templates/com/mailing_admin.jinja
+#: com/templates/com/macros.jinja com/templates/com/mailing_admin.jinja
#: com/templates/com/news_admin_list.jinja com/templates/com/poster_edit.jinja
#: com/templates/com/screen_edit.jinja com/templates/com/weekmail.jinja
#: core/templates/core/file_detail.jinja
@@ -812,7 +812,7 @@ msgstr "Nouvelle mailing liste"
msgid "Subscribe"
msgstr "S'abonner"
-#: club/forms.py com/templates/com/news_admin_list.jinja
+#: club/forms.py
msgid "Remove"
msgstr "Retirer"
@@ -1267,7 +1267,7 @@ msgstr "Format : 16:9 | Résolution : 1920x1080"
msgid "Start date"
msgstr "Date de début"
-#: com/forms.py
+#: com/forms.py com/templates/com/macros.jinja
msgid "Weekly event"
msgstr "Événement Hebdomadaire"
@@ -1296,8 +1296,8 @@ msgstr ""
"Combien de fois l'événement doit-il se répéter (en incluant la première fois)"
#: com/forms.py
-msgid "Automoderation"
-msgstr "Automodération"
+msgid "Auto publication"
+msgstr "Publication automatique"
#: com/models.py
msgid "alert message"
@@ -1344,6 +1344,10 @@ msgstr "Le club qui organise l'évènement."
msgid "author"
msgstr "auteur"
+#: com/models.py
+msgid "is published"
+msgstr "est publié"
+
#: com/models.py
msgid "news"
msgstr "nouvelle"
@@ -1408,14 +1412,43 @@ msgstr "temps d'affichage"
msgid "Begin date should be before end date"
msgstr "La date de début doit être avant celle de fin"
+#: com/templates/com/macros.jinja
+msgid "Waiting publication"
+msgstr "En attente de publication"
+
+#: com/templates/com/macros.jinja
+msgid ""
+"This news isn't published and is visible only by its author and the "
+"communication admins."
+msgstr ""
+"Cette nouvelle n'est pas publiée et n'est visible que par son auteur et les "
+"admins communication."
+
+#: com/templates/com/macros.jinja
+msgid "It will stay hidden for other users until it has been published."
+msgstr ""
+"Elle sera cachée pour les autres utilisateurs tant qu'elle ne sera pas "
+"publiée."
+
+#: com/templates/com/macros.jinja com/templates/com/news_admin_list.jinja
+#: com/templates/com/news_detail.jinja
+msgid "Publish"
+msgstr "Publier"
+
+#: com/templates/com/macros.jinja
+msgid "News published"
+msgstr "Nouvelle publiée"
+
+#: com/templates/com/macros.jinja
+msgid "News deleted"
+msgstr "Nouvelle supprimée"
+
#: com/templates/com/mailing_admin.jinja com/views.py
#: core/templates/core/user_tools.jinja
msgid "Mailing lists administration"
msgstr "Administration des mailing listes"
-#: com/templates/com/mailing_admin.jinja
-#: com/templates/com/news_admin_list.jinja com/templates/com/news_detail.jinja
-#: core/templates/core/file_detail.jinja
+#: com/templates/com/mailing_admin.jinja core/templates/core/file_detail.jinja
#: core/templates/core/file_moderation.jinja sas/templates/sas/moderation.jinja
#: sas/templates/sas/picture.jinja
msgid "Moderate"
@@ -1488,6 +1521,10 @@ msgstr "Modérateur"
msgid "Dates"
msgstr "Dates"
+#: com/templates/com/news_admin_list.jinja
+msgid "Unpublish"
+msgstr "Dépublier"
+
#: com/templates/com/news_admin_list.jinja
msgid "Weeklies to moderate"
msgstr "Nouvelles hebdomadaires à modérer"
@@ -1541,6 +1578,17 @@ msgstr "Administrer les news"
msgid "Nothing to come..."
msgstr "Rien à venir..."
+#: com/templates/com/news_list.jinja
+msgid "See more"
+msgstr "Voir plus"
+
+#: com/templates/com/news_list.jinja
+msgid ""
+"It was too short. You already reached the end of the upcoming events list."
+msgstr ""
+"C'était trop court. Vous êtes déjà arrivés à la fin de la liste des "
+"événements à venir."
+
#: com/templates/com/news_list.jinja
msgid "All coming events"
msgstr "Tous les événements à venir"
@@ -1578,14 +1626,6 @@ msgstr "Discord AE"
msgid "Dev Team"
msgstr "Pôle Informatique"
-#: com/templates/com/news_list.jinja
-msgid "Facebook"
-msgstr "Facebook"
-
-#: com/templates/com/news_list.jinja
-msgid "Instagram"
-msgstr "Instagram"
-
#: com/templates/com/news_list.jinja
msgid "Birthdays"
msgstr "Anniversaires"
@@ -1599,6 +1639,10 @@ msgstr "%(age)s ans"
msgid "You need to subscribe to access this content"
msgstr "Vous devez cotiser pour accéder à ce contenu"
+#: com/templates/com/news_list.jinja
+msgid "You cannot access this content"
+msgstr "Vous n'avez pas accès à ce contenu"
+
#: com/templates/com/poster_edit.jinja com/templates/com/poster_list.jinja
msgid "Poster"
msgstr "Affiche"
@@ -3042,20 +3086,6 @@ msgstr "Éditer les groupes pour %(user_name)s"
msgid "User list"
msgstr "Liste d'utilisateurs"
-#: core/templates/core/user_pictures.jinja
-#, python-format
-msgid "%(user_name)s's pictures"
-msgstr "Photos de %(user_name)s"
-
-#: core/templates/core/user_pictures.jinja
-msgid "Download all my pictures"
-msgstr "Télécharger toutes mes photos"
-
-#: core/templates/core/user_pictures.jinja sas/templates/sas/album.jinja
-#: sas/templates/sas/macros.jinja
-msgid "To be moderated"
-msgstr "A modérer"
-
#: core/templates/core/user_preferences.jinja core/views/user.py
msgid "Preferences"
msgstr "Préférences"
@@ -4948,6 +4978,10 @@ msgstr "Département"
msgid "Credit type"
msgstr "Type de crédit"
+#: pedagogy/templates/pedagogy/guide.jinja
+msgid "closed uv"
+msgstr "uv fermée"
+
#: pedagogy/templates/pedagogy/macros.jinja
msgid " not rated "
msgstr "non noté"
@@ -5185,6 +5219,15 @@ msgstr "SAS"
msgid "Albums"
msgstr "Albums"
+#: sas/templates/sas/album.jinja
+msgid "Download album"
+msgstr "Télécharger l'album"
+
+#: sas/templates/sas/album.jinja sas/templates/sas/macros.jinja
+#: sas/templates/sas/user_pictures.jinja
+msgid "To be moderated"
+msgstr "A modérer"
+
#: sas/templates/sas/album.jinja
msgid "Upload"
msgstr "Envoyer"
@@ -5254,6 +5297,15 @@ msgstr "Personne(s)"
msgid "Identify users on pictures"
msgstr "Identifiez les utilisateurs sur les photos"
+#: sas/templates/sas/user_pictures.jinja
+#, python-format
+msgid "%(user_name)s's pictures"
+msgstr "Photos de %(user_name)s"
+
+#: sas/templates/sas/user_pictures.jinja
+msgid "Download all my pictures"
+msgstr "Télécharger toutes mes photos"
+
#: sith/settings.py
msgid "English"
msgstr "Anglais"
diff --git a/locale/fr/LC_MESSAGES/djangojs.po b/locale/fr/LC_MESSAGES/djangojs.po
index 28177988..c222636a 100644
--- a/locale/fr/LC_MESSAGES/djangojs.po
+++ b/locale/fr/LC_MESSAGES/djangojs.po
@@ -7,7 +7,7 @@
msgid ""
msgstr ""
"Report-Msgid-Bugs-To: \n"
-"POT-Creation-Date: 2025-01-08 12:23+0100\n"
+"POT-Creation-Date: 2025-02-25 16:10+0100\n"
"PO-Revision-Date: 2024-09-17 11:54+0200\n"
"Last-Translator: Sli \n"
"Language-Team: AE info \n"
@@ -21,6 +21,27 @@ msgstr ""
msgid "More info"
msgstr "Plus d'informations"
+#: com/static/bundled/com/components/ics-calendar-index.ts
+msgid "Publish"
+msgstr "Publier"
+
+#: com/static/bundled/com/components/ics-calendar-index.ts
+msgid "Unpublish"
+msgstr "Dépublier"
+
+#: com/static/bundled/com/components/ics-calendar-index.ts
+msgid "Delete"
+msgstr "Supprimer"
+
+#: com/static/bundled/com/components/moderation-alert-index.ts
+msgid ""
+"This event will take place every week for %s weeks. If you publish or delete "
+"this event, it will also be published (or deleted) for the following weeks."
+msgstr ""
+"Cet événement se déroulera chaque semaine pendant %s semaines. Si vous "
+"publiez ou supprimez cet événement, il sera également publié (ou supprimé) "
+"pour les semaines suivantes."
+
#: core/static/bundled/core/components/ajax-select-base.ts
msgid "Remove"
msgstr "Retirer"
@@ -125,10 +146,6 @@ msgstr "Montrer plus"
msgid "family_tree.%(extension)s"
msgstr "arbre_genealogique.%(extension)s"
-#: core/static/bundled/user/pictures-index.js
-msgid "pictures.%(extension)s"
-msgstr "photos.%(extension)s"
-
#: core/static/user/js/user_edit.js
#, javascript-format
msgid "captured.%s"
@@ -187,6 +204,10 @@ msgstr "La réorganisation des types de produit a échoué avec le code : %d"
msgid "Incorrect value"
msgstr "Valeur incorrecte"
+#: sas/static/bundled/sas/pictures-download-index.ts
+msgid "pictures.%(extension)s"
+msgstr "photos.%(extension)s"
+
#: sas/static/bundled/sas/viewer-index.ts
msgid "Couldn't moderate picture"
msgstr "Il n'a pas été possible de modérer l'image"
diff --git a/mkdocs.yml b/mkdocs.yml
index f307cb8a..9a7c3114 100644
--- a/mkdocs.yml
+++ b/mkdocs.yml
@@ -157,6 +157,7 @@ markdown_extensions:
- md_in_html
- pymdownx.details
- pymdownx.inlinehilite
+ - pymdownx.keys
- pymdownx.superfences:
custom_fences:
- name: mermaid
diff --git a/pedagogy/api.py b/pedagogy/api.py
index e8d34351..9ad0c3f6 100644
--- a/pedagogy/api.py
+++ b/pedagogy/api.py
@@ -10,13 +10,13 @@ from ninja_extra.pagination import PageNumberPaginationExtra, PaginatedResponseS
from core.auth.api_permissions import HasPerm
from pedagogy.models import UV
from pedagogy.schemas import SimpleUvSchema, UvFilterSchema, UvSchema
-from pedagogy.utbm_api import find_uv
+from pedagogy.utbm_api import UtbmApiClient
@api_controller("/uv")
class UvController(ControllerBase):
@route.get(
- "/{year}/{code}",
+ "/{code}",
permissions=[
# this route will almost always be called in the context
# of a UV creation/edition
@@ -26,10 +26,14 @@ class UvController(ControllerBase):
response=UvSchema,
)
def fetch_from_utbm_api(
- self, year: Annotated[int, Ge(2010)], code: str, lang: Query[str] = "fr"
+ self,
+ code: str,
+ lang: Query[str] = "fr",
+ year: Query[Annotated[int, Ge(2010)] | None] = None,
):
"""Fetch UV data from the UTBM API and returns it after some parsing."""
- res = find_uv(lang, year, code)
+ with UtbmApiClient() as client:
+ res = client.find_uv(lang, code, year)
if res is None:
raise NotFound
return res
@@ -42,4 +46,4 @@ class UvController(ControllerBase):
)
@paginate(PageNumberPaginationExtra, page_size=100)
def fetch_uv_list(self, search: Query[UvFilterSchema]):
- return search.filter(UV.objects.values())
+ return search.filter(UV.objects.order_by("code").values())
diff --git a/pedagogy/management/__init__.py b/pedagogy/management/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/pedagogy/management/commands/__init__.py b/pedagogy/management/commands/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/pedagogy/management/commands/update_uv_guide.py b/pedagogy/management/commands/update_uv_guide.py
new file mode 100644
index 00000000..cf525a1f
--- /dev/null
+++ b/pedagogy/management/commands/update_uv_guide.py
@@ -0,0 +1,37 @@
+from django.conf import settings
+from django.core.management import BaseCommand
+
+from core.models import User
+from pedagogy.models import UV
+from pedagogy.schemas import UvSchema
+from pedagogy.utbm_api import UtbmApiClient
+
+
+class Command(BaseCommand):
+ help = "Update the UV guide"
+
+ def handle(self, *args, **options):
+ seen_uvs: set[int] = set()
+ root_user = User.objects.get(pk=settings.SITH_ROOT_USER_ID)
+ with UtbmApiClient() as client:
+ self.stdout.write(
+ "Fetching UVs from the UTBM API.\n"
+ "This may take a few minutes to complete."
+ )
+ for uv in client.fetch_uvs():
+ db_uv = UV.objects.filter(code=uv.code).first()
+ if db_uv is None:
+ db_uv = UV(code=uv.code, author=root_user)
+ fields = list(UvSchema.model_fields.keys())
+ fields.remove("id")
+ fields.remove("code")
+ for field in fields:
+ setattr(db_uv, field, getattr(uv, field))
+ db_uv.save()
+ # if it's a creation, django will set the id when saving,
+ # so at this point, a db_uv will always have an id
+ seen_uvs.add(db_uv.id)
+ # UVs that are in database but have not been returned by the API
+ # are considered as closed UEs
+ UV.objects.exclude(id__in=seen_uvs).update(semester="CLOSED")
+ self.stdout.write(self.style.SUCCESS("UV guide updated successfully"))
diff --git a/pedagogy/schemas.py b/pedagogy/schemas.py
index 716e9a3c..cbcd9157 100644
--- a/pedagogy/schemas.py
+++ b/pedagogy/schemas.py
@@ -54,11 +54,11 @@ class UtbmFullUvSchema(Schema):
code: str
departement: str = "NA"
- libelle: str
- objectifs: str
- programme: str
- acquisition_competences: str
- acquisition_notions: str
+ libelle: str | None
+ objectifs: str | None
+ programme: str | None
+ acquisition_competences: str | None
+ acquisition_notions: str | None
langue: str
code_langue: str
credits_ects: int
diff --git a/pedagogy/static/pedagogy/css/pedagogy.scss b/pedagogy/static/pedagogy/css/pedagogy.scss
index a4ebb370..51656615 100644
--- a/pedagogy/static/pedagogy/css/pedagogy.scss
+++ b/pedagogy/static/pedagogy/css/pedagogy.scss
@@ -47,11 +47,14 @@ $large-devices: 992px;
}
}
- #dynamic_view {
+ #uv-list {
font-size: 1.1em;
overflow-wrap: break-word;
-
+ .closed td.title {
+ color: lighten($black-color, 10%);
+ font-style: italic;
+ }
td {
text-align: center;
border: none;
diff --git a/pedagogy/templates/pedagogy/guide.jinja b/pedagogy/templates/pedagogy/guide.jinja
index 79b66c24..460fdcc5 100644
--- a/pedagogy/templates/pedagogy/guide.jinja
+++ b/pedagogy/templates/pedagogy/guide.jinja
@@ -85,7 +85,7 @@
-
+
{% trans %}UV{% endtrans %}
@@ -102,11 +102,17 @@
{% endif %}
-
+
-
+
-
+
diff --git a/pedagogy/templates/pedagogy/uv_edit.jinja b/pedagogy/templates/pedagogy/uv_edit.jinja
index 7ab54105..b4869e14 100644
--- a/pedagogy/templates/pedagogy/uv_edit.jinja
+++ b/pedagogy/templates/pedagogy/uv_edit.jinja
@@ -46,12 +46,7 @@
const codeInput = document.querySelector('input[name="code"]')
autofillBtn.addEventListener('click', () => {
- const today = new Date()
- let year = today.getFullYear()
- if (today.getMonth() < 7) { // student year starts in september
- year--
- }
- const url = `/api/uv/${year}/${codeInput.value}`;
+ const url = `/api/uv/${codeInput.value}`;
deleteQuickNotifs()
$.ajax({
@@ -70,7 +65,7 @@
.filter(([elem, _]) => !!elem) // skip non-existing DOM elements
.forEach(([elem, val]) => { // write the value in the form field
if (elem.tagName === 'TEXTAREA') {
- // MD editor text input
+ // MD editor text input
elem.parentNode.querySelector('.CodeMirror').CodeMirror.setValue(val);
} else {
elem.value = val;
diff --git a/pedagogy/utbm_api.py b/pedagogy/utbm_api.py
index fdd6d1fb..dd7ec933 100644
--- a/pedagogy/utbm_api.py
+++ b/pedagogy/utbm_api.py
@@ -1,32 +1,96 @@
"""Set of functions to interact with the UTBM UV api."""
-import urllib
+from typing import Iterator
+import requests
from django.conf import settings
+from django.utils.functional import cached_property
from pedagogy.schemas import ShortUvList, UtbmFullUvSchema, UtbmShortUvSchema, UvSchema
-def find_uv(lang, year, code) -> UvSchema | None:
- """Find an UV from the UTBM API."""
- # query the UV list
- base_url = settings.SITH_PEDAGOGY_UTBM_API
- uvs_url = f"{base_url}/uvs/{lang}/{year}"
- response = urllib.request.urlopen(uvs_url)
- uvs: list[UtbmShortUvSchema] = ShortUvList.validate_json(response.read())
+class UtbmApiClient(requests.Session):
+ """A wrapper around `requests.Session` to perform requests to the UTBM UV API."""
- short_uv = next((uv for uv in uvs if uv.code == code), None)
- if short_uv is None:
- return None
+ BASE_URL = settings.SITH_PEDAGOGY_UTBM_API
+ _cache = {"short_uvs": {}}
- # get detailed information about the UV
- uv_url = f"{base_url}/uv/{lang}/{year}/{code}/{short_uv.code_formation}"
- response = urllib.request.urlopen(uv_url)
- full_uv = UtbmFullUvSchema.model_validate_json(response.read())
- return _make_clean_uv(short_uv, full_uv)
+ @cached_property
+ def current_year(self) -> int:
+ """Fetch from the API the latest existing year"""
+ url = f"{self.BASE_URL}/guides/fr"
+ response = self.get(url)
+ return response.json()[-1]["annee"]
+
+ def fetch_short_uvs(
+ self, lang: str = "fr", year: int | None = None
+ ) -> list[UtbmShortUvSchema]:
+ """Get the list of UVs in their short format from the UTBM API"""
+ if year is None:
+ year = self.current_year
+ if lang not in self._cache["short_uvs"]:
+ self._cache["short_uvs"][lang] = {}
+ if year not in self._cache["short_uvs"][lang]:
+ url = f"{self.BASE_URL}/uvs/{lang}/{year}"
+ response = self.get(url)
+ uvs = ShortUvList.validate_json(response.content)
+ self._cache["short_uvs"][lang][year] = uvs
+ return self._cache["short_uvs"][lang][year]
+
+ def fetch_uvs(
+ self, lang: str = "fr", year: int | None = None
+ ) -> Iterator[UvSchema]:
+ """Fetch all UVs from the UTBM API, parsed in a format that we can use.
+
+ Warning:
+ We need infos from the full uv schema, and the UTBM UV API
+ has no route to get all of them at once.
+ We must do one request per UV (for a total of around 730 UVs),
+ which takes a lot of time.
+ Hopefully, there seems to be no rate-limit, so an error
+ in the middle of the process isn't likely to occur.
+ """
+ if year is None:
+ year = self.current_year
+ shorts_uvs = self.fetch_short_uvs(lang, year)
+ # When UVs are common to multiple branches (like most HUMA)
+ # the UTBM API duplicates them for every branch.
+ # We have no way in our db to link a UV to multiple formations,
+ # so we just create a single UV, which formation is the one
+ # of the first UV found in the list.
+ # For example, if we have CC01 (TC), CC01 (IMSI) and CC01 (EDIM),
+ # we will only keep CC01 (TC).
+ unique_short_uvs = {}
+ for uv in shorts_uvs:
+ if uv.code not in unique_short_uvs:
+ unique_short_uvs[uv.code] = uv
+ for uv in unique_short_uvs.values():
+ uv_url = f"{self.BASE_URL}/uv/{lang}/{year}/{uv.code}/{uv.code_formation}"
+ response = requests.get(uv_url)
+ full_uv = UtbmFullUvSchema.model_validate_json(response.content)
+ yield make_clean_uv(uv, full_uv)
+
+ def find_uv(self, lang: str, code: str, year: int | None = None) -> UvSchema | None:
+ """Find an UV from the UTBM API."""
+ # query the UV list
+ if not year:
+ year = self.current_year
+ # the UTBM API has no way to fetch a single short uv,
+ # and short uvs contain infos that we need and are not
+ # in the full uv schema, so we must fetch everything.
+ short_uvs = self.fetch_short_uvs(lang, year)
+ short_uv = next((uv for uv in short_uvs if uv.code == code), None)
+ if short_uv is None:
+ return None
+
+ # get detailed information about the UV
+ uv_url = f"{self.BASE_URL}/uv/{lang}/{year}/{code}/{short_uv.code_formation}"
+ response = requests.get(uv_url)
+ full_uv = UtbmFullUvSchema.model_validate_json(response.content)
+ return make_clean_uv(short_uv, full_uv)
-def _make_clean_uv(short_uv: UtbmShortUvSchema, full_uv: UtbmFullUvSchema) -> UvSchema:
+def make_clean_uv(short_uv: UtbmShortUvSchema, full_uv: UtbmFullUvSchema) -> UvSchema:
"""Cleans the data up so that it corresponds to our data representation.
Some of the needed information are in the short uv schema, some
@@ -61,9 +125,9 @@ def _make_clean_uv(short_uv: UtbmShortUvSchema, full_uv: UtbmFullUvSchema) -> Uv
semester = "CLOSED"
return UvSchema(
- title=full_uv.libelle,
+ title=full_uv.libelle or "",
code=full_uv.code,
- credit_type=short_uv.code_categorie,
+ credit_type=short_uv.code_categorie or "FREE",
semester=semester,
language=short_uv.code_langue.upper(),
credits=full_uv.credits_ects,
@@ -74,8 +138,8 @@ def _make_clean_uv(short_uv: UtbmShortUvSchema, full_uv: UtbmFullUvSchema) -> Uv
hours_TE=next((i.nbh for i in full_uv.activites if i.code == "TE"), 0) // 60,
hours_CM=next((i.nbh for i in full_uv.activites if i.code == "CM"), 0) // 60,
manager=full_uv.respo_automne or full_uv.respo_printemps or "",
- objectives=full_uv.objectifs,
- program=full_uv.programme,
- skills=full_uv.acquisition_competences,
- key_concepts=full_uv.acquisition_notions,
+ objectives=full_uv.objectifs or "",
+ program=full_uv.programme or "",
+ skills=full_uv.acquisition_competences or "",
+ key_concepts=full_uv.acquisition_notions or "",
)
diff --git a/pyproject.toml b/pyproject.toml
index 3e2cdf0f..a4d16abc 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -44,6 +44,9 @@ dependencies = [
"django-honeypot<2.0.0,>=1.2.1",
"pydantic-extra-types<3.0.0,>=2.10.1",
"ical<9.0.0,>=8.3.0",
+ "redis[hiredis]<6.0.0,>=5.2.0",
+ "environs[django]<15.0.0,>=14.1.0",
+ "requests>=2.32.3",
]
[project.urls]
@@ -53,7 +56,6 @@ documentation = "https://sith-ae.readthedocs.io/"
[dependency-groups]
prod = [
"psycopg[c]<4.0.0,>=3.2.3",
- "redis[hiredis]<6.0.0,>=5.2.0",
]
dev = [
"django-debug-toolbar<5.0.0,>=4.4.6",
diff --git a/sas/api.py b/sas/api.py
index 96bafb87..11355de5 100644
--- a/sas/api.py
+++ b/sas/api.py
@@ -104,7 +104,7 @@ class PicturesController(ControllerBase):
viewed=False,
type="NEW_PICTURES",
defaults={
- "url": reverse("core:user_pictures", kwargs={"user_id": u.id})
+ "url": reverse("sas:user_pictures", kwargs={"user_id": u.id})
},
)
diff --git a/sas/schemas.py b/sas/schemas.py
index 5e049858..d606219b 100644
--- a/sas/schemas.py
+++ b/sas/schemas.py
@@ -39,6 +39,8 @@ class PictureSchema(ModelSchema):
compressed_url: str
thumb_url: str
album: str
+ report_url: str
+ edit_url: str
@staticmethod
def resolve_sas_url(obj: Picture) -> str:
@@ -56,6 +58,14 @@ class PictureSchema(ModelSchema):
def resolve_thumb_url(obj: Picture) -> str:
return obj.get_download_thumb_url()
+ @staticmethod
+ def resolve_report_url(obj: Picture) -> str:
+ return reverse("sas:picture_ask_removal", kwargs={"picture_id": obj.id})
+
+ @staticmethod
+ def resolve_edit_url(obj: Picture) -> str:
+ return reverse("sas:picture_edit", kwargs={"picture_id": obj.id})
+
class PictureRelationCreationSchema(Schema):
picture: NonNegativeInt
diff --git a/sas/static/bundled/sas/album-index.js b/sas/static/bundled/sas/album-index.js
deleted file mode 100644
index f09fa6b2..00000000
--- a/sas/static/bundled/sas/album-index.js
+++ /dev/null
@@ -1,59 +0,0 @@
-import { History, initialUrlParams, updateQueryString } from "#core:utils/history";
-import { picturesFetchPictures } from "#openapi";
-
-/**
- * @typedef AlbumConfig
- * @property {number} albumId id of the album to visualize
- * @property {number} maxPageSize maximum number of elements to show on a page
- **/
-
-/**
- * Create a family graph of an user
- * @param {AlbumConfig} config
- **/
-window.loadAlbum = (config) => {
- document.addEventListener("alpine:init", () => {
- Alpine.data("pictures", () => ({
- pictures: {},
- page: Number.parseInt(initialUrlParams.get("page")) || 1,
- pushstate: History.Push /* Used to avoid pushing a state on a back action */,
- loading: false,
-
- async init() {
- await this.fetchPictures();
- this.$watch("page", () => {
- updateQueryString("page", this.page === 1 ? null : this.page, this.pushstate);
- this.pushstate = History.Push;
- this.fetchPictures();
- });
-
- window.addEventListener("popstate", () => {
- this.pushstate = History.Replace;
- this.page =
- Number.parseInt(new URLSearchParams(window.location.search).get("page")) ||
- 1;
- });
- },
-
- async fetchPictures() {
- this.loading = true;
- this.pictures = (
- await picturesFetchPictures({
- query: {
- // biome-ignore lint/style/useNamingConvention: API is in snake_case
- album_id: config.albumId,
- page: this.page,
- // biome-ignore lint/style/useNamingConvention: API is in snake_case
- page_size: config.maxPageSize,
- },
- })
- ).data;
- this.loading = false;
- },
-
- nbPages() {
- return Math.ceil(this.pictures.count / config.maxPageSize);
- },
- }));
- });
-};
diff --git a/sas/static/bundled/sas/album-index.ts b/sas/static/bundled/sas/album-index.ts
new file mode 100644
index 00000000..ff0976d9
--- /dev/null
+++ b/sas/static/bundled/sas/album-index.ts
@@ -0,0 +1,58 @@
+import { paginated } from "#core:utils/api";
+import { History, initialUrlParams, updateQueryString } from "#core:utils/history";
+import {
+ type PictureSchema,
+ type PicturesFetchPicturesData,
+ picturesFetchPictures,
+} from "#openapi";
+
+interface AlbumConfig {
+ albumId: number;
+ maxPageSize: number;
+}
+
+document.addEventListener("alpine:init", () => {
+ Alpine.data("pictures", (config: AlbumConfig) => ({
+ pictures: [] as PictureSchema[],
+ page: Number.parseInt(initialUrlParams.get("page")) || 1,
+ pushstate: History.Push /* Used to avoid pushing a state on a back action */,
+ loading: false,
+
+ async init() {
+ await this.fetchPictures();
+ this.$watch("page", () => {
+ updateQueryString("page", this.page === 1 ? null : this.page, this.pushstate);
+ this.pushstate = History.Push;
+ });
+
+ window.addEventListener("popstate", () => {
+ this.pushstate = History.Replace;
+ this.page =
+ Number.parseInt(new URLSearchParams(window.location.search).get("page")) || 1;
+ });
+ this.config = config;
+ },
+
+ getPage(page: number) {
+ return this.pictures.slice(
+ (page - 1) * config.maxPageSize,
+ config.maxPageSize * page,
+ );
+ },
+
+ async fetchPictures() {
+ this.loading = true;
+ this.pictures = await paginated(picturesFetchPictures, {
+ query: {
+ // biome-ignore lint/style/useNamingConvention: API is in snake_case
+ album_id: config.albumId,
+ } as PicturesFetchPicturesData["query"],
+ });
+ this.loading = false;
+ },
+
+ nbPages() {
+ return Math.ceil(this.pictures.length / config.maxPageSize);
+ },
+ }));
+});
diff --git a/sas/static/bundled/sas/pictures-download-index.ts b/sas/static/bundled/sas/pictures-download-index.ts
new file mode 100644
index 00000000..21ee9989
--- /dev/null
+++ b/sas/static/bundled/sas/pictures-download-index.ts
@@ -0,0 +1,46 @@
+import { HttpReader, ZipWriter } from "@zip.js/zip.js";
+import { showSaveFilePicker } from "native-file-system-adapter";
+import type { PictureSchema } from "#openapi";
+
+document.addEventListener("alpine:init", () => {
+ Alpine.data("pictures_download", () => ({
+ isDownloading: false,
+
+ async downloadZip() {
+ this.isDownloading = true;
+ const bar = this.$refs.progress;
+ bar.value = 0;
+ bar.max = this.pictures.length;
+
+ const incrementProgressBar = (_total: number): undefined => {
+ bar.value++;
+ return undefined;
+ };
+
+ const fileHandle = await showSaveFilePicker({
+ _preferPolyfill: false,
+ suggestedName: interpolate(
+ gettext("pictures.%(extension)s"),
+ { extension: "zip" },
+ true,
+ ),
+ excludeAcceptAllOption: false,
+ });
+ const zipWriter = new ZipWriter(await fileHandle.createWritable());
+
+ await Promise.all(
+ this.pictures.map((p: PictureSchema) => {
+ const imgName = `${p.album}/IMG_${p.date.replace(/[:\-]/g, "_")}${p.name.slice(p.name.lastIndexOf("."))}`;
+ return zipWriter.add(imgName, new HttpReader(p.full_size_url), {
+ level: 9,
+ lastModDate: new Date(p.date),
+ onstart: incrementProgressBar,
+ });
+ }),
+ );
+
+ await zipWriter.close();
+ this.isDownloading = false;
+ },
+ }));
+});
diff --git a/sas/static/bundled/sas/user/pictures-index.ts b/sas/static/bundled/sas/user/pictures-index.ts
new file mode 100644
index 00000000..d3cd83ae
--- /dev/null
+++ b/sas/static/bundled/sas/user/pictures-index.ts
@@ -0,0 +1,39 @@
+import { paginated } from "#core:utils/api";
+import {
+ type PictureSchema,
+ type PicturesFetchPicturesData,
+ picturesFetchPictures,
+} from "#openapi";
+
+interface PagePictureConfig {
+ userId: number;
+}
+
+document.addEventListener("alpine:init", () => {
+ Alpine.data("user_pictures", (config: PagePictureConfig) => ({
+ loading: true,
+ pictures: [] as PictureSchema[],
+ albums: {} as Record,
+
+ async init() {
+ this.pictures = await paginated(picturesFetchPictures, {
+ query: {
+ // biome-ignore lint/style/useNamingConvention: from python api
+ users_identified: [config.userId],
+ } as PicturesFetchPicturesData["query"],
+ });
+
+ this.albums = this.pictures.reduce(
+ (acc: Record, picture: PictureSchema) => {
+ if (!acc[picture.album]) {
+ acc[picture.album] = [];
+ }
+ acc[picture.album].push(picture);
+ return acc;
+ },
+ {},
+ );
+ this.loading = false;
+ },
+ }));
+});
diff --git a/sas/static/sas/css/album.scss b/sas/static/sas/css/album.scss
index fdc317c2..cec61de8 100644
--- a/sas/static/sas/css/album.scss
+++ b/sas/static/sas/css/album.scss
@@ -20,8 +20,8 @@ main {
flex-wrap: wrap;
gap: 5px;
- > a,
- > input {
+ >a,
+ >input {
padding: 0.4em;
margin: 0.1em;
font-size: 1.2em;
@@ -46,14 +46,14 @@ main {
display: flex;
flex-direction: column;
- > .inputs {
+ >.inputs {
align-items: flex-end;
display: flex;
flex-direction: row;
flex-wrap: wrap;
gap: 10px;
- > p {
+ >p {
box-sizing: border-box;
max-width: 300px;
width: 100%;
@@ -62,7 +62,7 @@ main {
max-width: 100%;
}
- > input {
+ >input {
box-sizing: border-box;
max-width: 100%;
width: 100%;
@@ -72,8 +72,8 @@ main {
}
}
- > div > input,
- > input {
+ >div>input,
+ >input {
box-sizing: border-box;
height: 40px;
width: 100%;
@@ -84,12 +84,12 @@ main {
}
}
- > div {
+ >div {
width: 100%;
max-width: 300px;
}
- > input[type=submit]:hover {
+ >input[type=submit]:hover {
background-color: #287fb8;
color: white;
}
@@ -100,27 +100,27 @@ main {
.clipboard {
margin-top: 10px;
padding: 10px;
- background-color: rgba(0,0,0,.1);
+ background-color: rgba(0, 0, 0, .1);
border-radius: 10px;
}
.photos,
.albums {
margin: 20px;
- min-height: 50px; // To contain the aria-busy loading wheel, even if empty
+ min-height: 50px; // To contain the aria-busy loading wheel, even if empty
box-sizing: border-box;
display: flex;
flex-direction: row;
flex-wrap: wrap;
gap: 5px;
- > div {
+ >div {
background: rgba(0, 0, 0, .5);
cursor: not-allowed;
}
- > div,
- > a {
+ >div,
+ >a {
box-sizing: border-box;
position: relative;
height: 128px;
@@ -138,7 +138,7 @@ main {
background: rgba(0, 0, 0, .5);
}
- > input[type=checkbox] {
+ >input[type=checkbox] {
position: absolute;
top: 0;
right: 0;
@@ -149,8 +149,8 @@ main {
cursor: pointer;
}
- > .photo,
- > .album {
+ >.photo,
+ >.album {
box-sizing: border-box;
background-color: #333333;
background-size: contain;
@@ -166,25 +166,32 @@ main {
border: 1px solid rgba(0, 0, 0, .3);
+ >img {
+ object-position: top bottom;
+ object-fit: contain;
+ height: 100%;
+ width: 100%
+ }
+
@media (max-width: 500px) {
width: 100%;
height: 100%;
}
- &:hover > .text {
+ &:hover>.text {
background-color: rgba(0, 0, 0, .5);
}
- &:hover > .overlay {
+ &:hover>.overlay {
-webkit-backdrop-filter: blur(2px);
backdrop-filter: blur(2px);
- ~ .text {
+ ~.text {
background-color: transparent;
}
}
- > .text {
+ >.text {
position: absolute;
box-sizing: border-box;
top: 0;
@@ -201,7 +208,7 @@ main {
color: white;
}
- > .overlay {
+ >.overlay {
position: absolute;
width: 100%;
height: 100%;
@@ -227,14 +234,14 @@ main {
}
}
- > .album > div {
+ >.album>div {
background: rgba(0, 0, 0, .5);
background: linear-gradient(0deg, rgba(0, 0, 0, .5) 0%, rgba(0, 0, 0, 0) 100%);
text-align: left;
word-break: break-word;
}
- > .photo > .text {
+ >.photo>.text {
align-items: center;
padding-bottom: 30px;
}
diff --git a/sas/templates/sas/album.jinja b/sas/templates/sas/album.jinja
index 70aa79df..172e81ab 100644
--- a/sas/templates/sas/album.jinja
+++ b/sas/templates/sas/album.jinja
@@ -1,12 +1,14 @@
{% extends "core/base.jinja" %}
{% from 'core/macros.jinja' import paginate_alpine %}
+{% from "sas/macros.jinja" import download_button %}
{%- block additional_css -%}
{%- endblock -%}
{%- block additional_js -%}
-
+
+
{%- endblock -%}
{% block title %}
@@ -27,7 +29,6 @@
{% if is_sas_admin %}