Merge pull request #775 from ae-utbm/user-pictures-ajax

Render user picture page with ajax to improve performances
This commit is contained in:
thomas girod 2024-08-18 12:40:07 +02:00 committed by GitHub
commit 4036bfd703
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 95 additions and 100 deletions

View File

@ -17,58 +17,54 @@
{% endblock %} {% endblock %}
{% block content %} {% block content %}
<main> <main x-data="user_pictures">
{% if user.id == object.id and albums|length > 0 %} {% if user.id == object.id %}
<div x-data="picture_download" x-cloak> <div x-show="pictures.length > 0" x-cloak>
<button <button
:disabled="in_progress" :disabled="is_downloading"
class="btn btn-blue" class="btn btn-blue"
@click="download_zip()" @click="download_zip()"
> >
<i class="fa fa-download"></i> <i class="fa fa-download"></i>
{% trans %}Download all my pictures{% endtrans %} {% trans %}Download all my pictures{% endtrans %}
</button> </button>
<progress x-ref="progress" x-show="in_progress"></progress> <progress x-ref="progress" x-show="is_downloading"></progress>
</div> </div>
{% endif %} {% endif %}
{% for album, pictures in albums|items %}
<h4>{{ album }}</h4> <template x-for="[album, pictures] in Object.entries(albums)" x-cloak>
<br /> <section>
<div class="photos"> <br />
{% for picture in pictures %} <h4 x-text="album"></h4>
{% if picture.can_be_viewed_by(user) %} <div class="photos">
<a href="{{ url("sas:picture", picture_id=picture.id) }}#pict"> <template x-for="picture in pictures">
<a :href="`/sas/picture/${picture.id}#pict`">
<div <div
class="photo{% if not picture.is_moderated %} not_moderated{% endif %}" class="photo"
style="background-image: url('{% if picture.file %}{{ picture.get_download_thumb_url() }}{% else %}{{ static('core/img/sas.jpg') }}{% endif %}');" :class="{not_moderated: !picture.is_moderated}"
:style="`background-image: url(${picture.thumb_url})`"
> >
{% if not picture.is_moderated %} <template x-if="!picture.is_moderated">
<div class="overlay">&nbsp;</div> <div class="overlay">&nbsp;</div>
<div class="text">{% trans %}To be moderated{% endtrans %}</div> <div class="text">{% trans %}To be moderated{% endtrans %}</div>
{% else %} </template>
<template x-if="picture.is_moderated">
<div class="text">&nbsp;</div> <div class="text">&nbsp;</div>
{% endif %} </template>
</div> </div>
</a> </a>
{% else %} </template>
<div> </div>
<div class="photo"> </section>
<div class="text">{% trans %}Picture Unavailable{% endtrans %}</div> </template>
</div> <div class="photos" :aria-busy="loading"></div>
</div>
{% endif %}
{% endfor %}
</div>
<br>
{% endfor %}
</main> </main>
{% endblock content %} {% endblock content %}
{% block script %} {% block script %}
{{ super() }} {{ super() }}
{% if user.id == object.id %} <script>
<script>
/** /**
* @typedef Picture * @typedef Picture
* @property {number} id * @property {number} id
@ -82,62 +78,74 @@
* @property {string} album * @property {string} album
*/ */
document.addEventListener("alpine:init", () => { document.addEventListener("alpine:init", () => {
Alpine.data("picture_download", () => ({ Alpine.data("user_pictures", () => ({
in_progress: false, is_downloading: false,
loading: true,
pictures: [],
albums: {},
/** async init() {
* @return {Promise<Picture[]>} this.pictures = await this.get_pictures();
*/ this.albums = Object.groupBy(this.pictures, ({album}) => album);
async get_pictures() { this.loading = false;
{# The API forbids to get more than 199 items at once },
from paginated routes.
In order to download all the user pictures, it may be needed
to performs multiple requests #}
const max_per_page = 199;
const url = "{{ url("api:pictures") }}"
+ "?users_identified={{ object.id }}"
+ `&page_size=${max_per_page}`;
let promises = [];
const nb_pages = Math.ceil({{ nb_pictures }} / max_per_page);
for (let i = 1; i <= nb_pages; i++) {
promises.push(
fetch(url + `&page=${i}`).then(res => res.json().then(json => json.results))
);
}
return (await Promise.all(promises)).flat()
},
/**
* @return {Promise<Picture[]>}
*/
async get_pictures() {
{# The API forbids to get more than 199 items at once
from paginated routes.
In order to download all the user pictures, it may be needed
to performs multiple requests #}
const max_per_page = 199;
const url = "{{ url("api:pictures") }}"
+ "?users_identified={{ object.id }}"
+ `&page_size=${max_per_page}`;
async download_zip(){ let first_page = (await ( await fetch(url)).json());
this.in_progress = true; let promises = [first_page.results];
const bar = this.$refs.progress;
bar.value = 0;
const pictures = await this.get_pictures();
bar.max = pictures.length;
const fileHandle = await window.showSaveFilePicker({ const nb_pictures = first_page.count
_preferPolyfill: false, const nb_pages = Math.ceil(nb_pictures / max_per_page);
suggestedName: "{%- trans -%} pictures {%- endtrans -%}.zip",
types: {},
excludeAcceptAllOption: false,
})
const zipWriter = new zip.ZipWriter(await fileHandle.createWritable());
await Promise.all(pictures.map(p => { for (let i = 2; i <= nb_pages; i++) {
const img_name = p.album + "/IMG_" + p.date.replaceAll(/[:\-]/g, "_") + p.name.slice(p.name.lastIndexOf(".")); promises.push(
return zipWriter.add( fetch(url + `&page=${i}`).then(res => res.json().then(json => json.results))
img_name, );
new zip.HttpReader(p.full_size_url),
{level: 9, lastModDate: new Date(p.date), onstart: () => bar.value += 1}
);
}));
await zipWriter.close();
this.in_progress = false;
} }
})) return (await Promise.all(promises)).flat()
}); },
</script>
{% endif %}
async download_zip(){
this.is_downloading = true;
const bar = this.$refs.progress;
bar.value = 0;
bar.max = this.pictures.length;
const fileHandle = await window.showSaveFilePicker({
_preferPolyfill: false,
suggestedName: "{%- trans -%} pictures {%- endtrans -%}.zip",
types: {},
excludeAcceptAllOption: false,
})
const zipWriter = new zip.ZipWriter(await fileHandle.createWritable());
await Promise.all(this.pictures.map(p => {
const img_name = p.album + "/IMG_" + p.date.replaceAll(/[:\-]/g, "_") + p.name.slice(p.name.lastIndexOf("."));
return zipWriter.add(
img_name,
new zip.HttpReader(p.full_size_url),
{level: 9, lastModDate: new Date(p.date), onstart: () => bar.value += 1}
);
}));
await zipWriter.close();
this.is_downloading = false;
}
}))
});
</script>
{% endblock script %} {% endblock script %}

View File

@ -21,7 +21,6 @@
# Place - Suite 330, Boston, MA 02111-1307, USA. # Place - Suite 330, Boston, MA 02111-1307, USA.
# #
# #
import itertools
import logging import logging
# This file contains all the views that concern the user model # This file contains all the views that concern the user model
@ -33,7 +32,6 @@ from django.contrib.auth import login, views
from django.contrib.auth.forms import PasswordChangeForm from django.contrib.auth.forms import PasswordChangeForm
from django.contrib.auth.mixins import LoginRequiredMixin from django.contrib.auth.mixins import LoginRequiredMixin
from django.core.exceptions import PermissionDenied, ValidationError from django.core.exceptions import PermissionDenied, ValidationError
from django.db.models import F
from django.forms import CheckboxSelectMultiple from django.forms import CheckboxSelectMultiple
from django.forms.models import modelform_factory from django.forms.models import modelform_factory
from django.http import Http404, HttpResponse from django.http import Http404, HttpResponse
@ -70,7 +68,6 @@ from core.views.forms import (
UserProfileForm, UserProfileForm,
) )
from counter.forms import StudentCardForm from counter.forms import StudentCardForm
from sas.models import Picture
from subscription.models import Subscription from subscription.models import Subscription
from trombi.views import UserTrombiForm from trombi.views import UserTrombiForm
@ -312,20 +309,6 @@ class UserPicturesView(UserTabsMixin, CanViewMixin, DetailView):
template_name = "core/user_pictures.jinja" template_name = "core/user_pictures.jinja"
current_tab = "pictures" current_tab = "pictures"
def get_context_data(self, **kwargs):
kwargs = super().get_context_data(**kwargs)
pictures = list(
Picture.objects.filter(people__user_id=self.object.id)
.order_by("-parent__date", "-date")
.annotate(album=F("parent__name"))
)
kwargs["nb_pictures"] = len(pictures)
kwargs["albums"] = {
album: list(picts)
for album, picts in itertools.groupby(pictures, lambda i: i.album)
}
return kwargs
def delete_user_godfather(request, user_id, godfather_id, is_father): def delete_user_godfather(request, user_id, godfather_id, is_father):
user_is_admin = request.user.is_root or request.user.is_board_member user_is_admin = request.user.is_root or request.user.is_board_member

View File

@ -64,7 +64,11 @@
<div class="photos" :aria-busy="loading"> <div class="photos" :aria-busy="loading">
<template x-for="picture in pictures.results"> <template x-for="picture in pictures.results">
<a :href="`/sas/picture/${picture.id}#pict`"> <a :href="`/sas/picture/${picture.id}#pict`">
<div class="photo" :style="`background-image: url(${picture.thumb_url})`"> <div
class="photo"
:class="{not_moderated: !picture.is_moderated}"
:style="`background-image: url(${picture.thumb_url})`"
>
<template x-if="!picture.is_moderated"> <template x-if="!picture.is_moderated">
<div class="overlay">&nbsp;</div> <div class="overlay">&nbsp;</div>
<div class="text">{% trans %}To be moderated{% endtrans %}</div> <div class="text">{% trans %}To be moderated{% endtrans %}</div>