Merge pull request #769 from ae-utbm/query-sas

Sas picture selection
This commit is contained in:
thomas girod 2024-08-09 12:11:16 +02:00 committed by GitHub
commit d1cbb765c0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
19 changed files with 426 additions and 308 deletions

View File

@ -982,7 +982,7 @@ class SithFile(models.Model):
return True
if self.is_in_sas and user.is_in_group(pk=settings.SITH_GROUP_SAS_ADMIN_ID):
return True
return user.id == self.owner.id
return user.id == self.owner_id
def can_be_viewed_by(self, user):
if hasattr(self, "profile_of"):

View File

@ -65,3 +65,21 @@ function display_notif() {
function getCSRFToken() {
return $("[name=csrfmiddlewaretoken]").val();
}
const initialUrlParams = new URLSearchParams(window.location.search);
function update_query_string(key, value) {
const url = new URL(window.location.href);
if (!value) {
// If the value is null, undefined or empty => delete it
url.searchParams.delete(key)
} else if (Array.isArray(value)) {
url.searchParams.delete(key)
value.forEach((v) => url.searchParams.append(key, v))
} else {
url.searchParams.set(key, value);
}
history.pushState(null, document.title, url.toString());
}

View File

@ -3,6 +3,7 @@
.pagination {
text-align: center;
gap: 10px;
margin: 30px;
button {
background-color: $secondary-neutral-light-color;

View File

@ -93,6 +93,32 @@ a:not(.button) {
}
}
[aria-busy] {
--loading-size: 50px;
--loading-stroke: 5px;
--loading-duration: 1s;
position: relative;
}
[aria-busy]:after {
content: '';
position: absolute;
top: 50%;
left: 50%;
width: var(--loading-size);
height: var(--loading-size);
margin-top: calc(var(--loading-size) / 2 * -1);
margin-left: calc(var(--loading-size) / 2 * -1);
border: var(--loading-stroke) solid rgba(0, 0, 0, .15);
border-radius: 50%;
border-top-color: rgba(0, 0, 0, 0.5);
animation: rotate calc(var(--loading-duration)) linear infinite;
}
@keyframes rotate {
100% { transform: rotate(360deg); }
}
.ib {
display: inline-block;
padding: 1px;

View File

@ -102,20 +102,10 @@ main {
border-radius: 10px;
}
.paginator {
display: flex;
justify-content: center;
gap: 10px;
width: -moz-fit-content;
width: fit-content;
background-color: rgba(0,0,0,.1);
border-radius: 10px;
padding: 10px;
margin: 10px 0 10px auto;
}
.photos,
.albums {
margin: 20px;
min-height: 50px; // To contain the aria-busy loading wheel, even if empty
box-sizing: border-box;
display: flex;
flex-direction: row;
@ -161,17 +151,13 @@ main {
> .album {
box-sizing: border-box;
background-color: #333333;
background-size: cover;
background-size: contain;
background-repeat: no-repeat;
background-position: center center;
width: calc(16 / 9 * 128px);
height: 128px;
&.vertical {
background-size: contain;
}
margin: 0;
padding: 0;
box-shadow: none;

View File

@ -23,7 +23,7 @@
<button
:disabled="in_progress"
class="btn btn-blue"
@click="download('{{ url("api:pictures") }}?users_identified={{ object.id }}')"
@click="download_zip()"
>
<i class="fa fa-download"></i>
{% trans %}Download all my pictures{% endtrans %}
@ -86,13 +86,34 @@
Alpine.data("picture_download", () => ({
in_progress: false,
async download(url) {
/**
* @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 = 1;
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()
},
async download_zip(){
this.in_progress = true;
const bar = this.$refs.progress;
bar.value = 0;
/** @type Picture[] */
const pictures = await (await fetch(url)).json();
const pictures = await this.get_pictures();
bar.max = pictures.length;
const fileHandle = await window.showSaveFilePicker({

View File

@ -96,10 +96,7 @@ def get_semester_code(d: Optional[date] = None) -> str:
def scale_dimension(width, height, long_edge):
if width > height:
ratio = long_edge * 1.0 / width
else:
ratio = long_edge * 1.0 / height
ratio = long_edge / max(width, height)
return int(width * ratio), int(height * ratio)
@ -107,8 +104,8 @@ def resize_image(im, edge, img_format):
(w, h) = im.size
(width, height) = scale_dimension(w, h, long_edge=edge)
content = BytesIO()
# use the lanczos filter for antialiasing
im = im.resize((width, height), Resampling.LANCZOS)
# use the lanczos filter for antialiasing and discard the alpha channel
im = im.resize((width, height), Resampling.LANCZOS).convert("RGB")
try:
im.save(
fp=content,

View File

@ -319,6 +319,7 @@ class UserPicturesView(UserTabsMixin, CanViewMixin, DetailView):
.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)

View File

@ -96,7 +96,7 @@
{% endif %}
</tr>
</thead>
<tbody id="dynamic_view_content">
<tbody id="dynamic_view_content" :aria-busy="loading">
<template x-for="uv in uvs.results" :key="uv.id">
<tr @click="window.location.href = `/pedagogy/uv/${uv.id}`" class="clickable">
<td><a :href="`/pedagogy/uv/${uv.id}`" x-text="uv.code"></a></td>
@ -126,22 +126,6 @@
</nav>
</div>
<script>
const initialUrlParams = new URLSearchParams(window.location.search);
function update_query_string(key, value) {
const url = new URL(window.location.href);
if (!value) {
{# If the value is null, undefined or empty => delete it #}
url.searchParams.delete(key)
} else if (Array.isArray(value)) {
url.searchParams.delete(key)
value.forEach((v) => url.searchParams.append(key, v))
} else {
url.searchParams.set(key, value);
}
history.pushState(null, document.title, url.toString());
}
{#
How does this work :
@ -156,6 +140,7 @@
document.addEventListener("alpine:init", () => {
Alpine.data("uv_search", () => ({
uvs: [],
loading: false,
page: parseInt(initialUrlParams.get("page")) || page_default,
page_size: parseInt(initialUrlParams.get("page_size")) || page_size_default,
search: initialUrlParams.get("search") || "",
@ -187,8 +172,10 @@
},
async fetch_data() {
this.loading = true;
const url = "{{ url("api:fetch_uvs") }}" + window.location.search;
this.uvs = await (await fetch(url)).json();
this.loading = false;
},
max_page() {

View File

@ -1,9 +1,11 @@
from django.conf import settings
from django.db.models import F
from ninja import Query
from ninja_extra import ControllerBase, api_controller, route
from ninja_extra import ControllerBase, api_controller, paginate, route
from ninja_extra.exceptions import PermissionDenied
from ninja_extra.pagination import PageNumberPaginationExtra
from ninja_extra.permissions import IsAuthenticated
from ninja_extra.schemas import PaginatedResponseSchema
from pydantic import NonNegativeInt
from core.models import User
@ -15,10 +17,11 @@ from sas.schemas import PictureFilterSchema, PictureSchema
class PicturesController(ControllerBase):
@route.get(
"",
response=list[PictureSchema],
response=PaginatedResponseSchema[PictureSchema],
permissions=[IsAuthenticated],
url_name="pictures",
)
@paginate(PageNumberPaginationExtra, page_size=100)
def fetch_pictures(self, filters: Query[PictureFilterSchema]):
"""Find pictures viewable by the user corresponding to the given filters.
@ -38,23 +41,12 @@ class PicturesController(ControllerBase):
cf. https://ae.utbm.fr/user/32663/pictures/)
"""
user: User = self.context.request.user
if not user.is_subscribed and filters.users_identified != {user.id}:
# User can view any moderated picture if he/she is subscribed.
# If not, he/she can view only the one he/she has been identified on
raise PermissionDenied
pictures = list(
filters.filter(
Picture.objects.filter(is_moderated=True, asked_for_removal=False)
)
return (
filters.filter(Picture.objects.viewable_by(user))
.distinct()
.order_by("-date")
.order_by("-parent__date", "date")
.annotate(album=F("parent__name"))
)
for picture in pictures:
picture.full_size_url = picture.get_download_url()
picture.compressed_url = picture.get_download_compressed_url()
picture.thumb_url = picture.get_download_thumb_url()
return pictures
@api_controller("/sas/relation", tags="User identification on SAS pictures")

View File

@ -13,11 +13,14 @@
#
#
from __future__ import annotations
from io import BytesIO
from django.conf import settings
from django.core.cache import cache
from django.db import models
from django.db.models import Exists, OuterRef
from django.urls import reverse
from django.utils import timezone
from django.utils.translation import gettext_lazy as _
@ -27,21 +30,60 @@ from core.models import SithFile, User
from core.utils import exif_auto_rotate, resize_image
class SasFile(SithFile):
"""Proxy model for any file in the SAS.
May be used to have logic that should be shared by both
[Picture][sas.models.Picture] and [Album][sas.models.Album].
"""
class Meta:
proxy = True
def can_be_viewed_by(self, user):
if user.is_anonymous:
return False
cache_key = (
f"sas:{self._meta.model_name}_viewable_by_{user.id}_in_{self.parent_id}"
)
viewable: list[int] | None = cache.get(cache_key)
if viewable is None:
viewable = list(
self.__class__.objects.filter(parent_id=self.parent_id)
.viewable_by(user)
.values_list("pk", flat=True)
)
cache.set(cache_key, viewable, timeout=10)
return self.id in viewable
def can_be_edited_by(self, user):
return user.is_root or user.is_in_group(pk=settings.SITH_GROUP_SAS_ADMIN_ID)
class PictureQuerySet(models.QuerySet):
def viewable_by(self, user: User) -> PictureQuerySet:
"""Filter the pictures that this user can view.
Warnings:
Calling this queryset method may add several additional requests.
"""
if user.is_root or user.is_in_group(pk=settings.SITH_GROUP_SAS_ADMIN_ID):
return self.all()
if user.was_subscribed:
return self.filter(is_moderated=True)
return self.filter(people__user_id=user.id, is_moderated=True)
class SASPictureManager(models.Manager):
def get_queryset(self):
return super().get_queryset().filter(is_in_sas=True, is_folder=False)
class SASAlbumManager(models.Manager):
def get_queryset(self):
return super().get_queryset().filter(is_in_sas=True, is_folder=True)
class Picture(SithFile):
class Picture(SasFile):
class Meta:
proxy = True
objects = SASPictureManager()
objects = SASPictureManager.from_queryset(PictureQuerySet)()
@property
def is_vertical(self):
@ -50,29 +92,6 @@ class Picture(SithFile):
(w, h) = im.size
return (w / h) < 1
def can_be_edited_by(self, user):
perm = cache.get("%d_can_edit_pictures" % (user.id), None)
if perm is None:
perm = user.is_root or user.is_in_group(pk=settings.SITH_GROUP_SAS_ADMIN_ID)
cache.set("%d_can_edit_pictures" % (user.id), perm, timeout=4)
return perm
def can_be_viewed_by(self, user):
# SAS pictures are visible to old subscribers
# Result is cached 4s for this user
if user.is_anonymous:
return False
perm = cache.get("%d_can_view_pictures" % (user.id), False)
if not perm:
perm = user.was_subscribed
cache.set("%d_can_view_pictures" % (user.id), perm, timeout=4)
return (perm and self.is_moderated and self.is_in_sas) or self.can_be_edited_by(
user
)
def get_download_url(self):
return reverse("sas:download", kwargs={"picture_id": self.id})
@ -124,48 +143,53 @@ class Picture(SithFile):
def get_next(self):
if self.is_moderated:
return (
self.parent.children.filter(
is_moderated=True,
asked_for_removal=False,
is_folder=False,
id__gt=self.id,
)
.order_by("id")
.first()
pictures_qs = self.parent.children.filter(
is_moderated=True,
asked_for_removal=False,
is_folder=False,
id__gt=self.id,
)
else:
return (
Picture.objects.filter(id__gt=self.id, is_moderated=False)
.order_by("id")
.first()
)
pictures_qs = Picture.objects.filter(id__gt=self.id, is_moderated=False)
return pictures_qs.order_by("id").first()
def get_previous(self):
if self.is_moderated:
return (
self.parent.children.filter(
is_moderated=True,
asked_for_removal=False,
is_folder=False,
id__lt=self.id,
)
.order_by("id")
.last()
pictures_qs = self.parent.children.filter(
is_moderated=True,
asked_for_removal=False,
is_folder=False,
id__lt=self.id,
)
else:
return (
Picture.objects.filter(id__lt=self.id, is_moderated=False)
.order_by("-id")
.first()
)
pictures_qs = Picture.objects.filter(id__lt=self.id, is_moderated=False)
return pictures_qs.order_by("-id").first()
class Album(SithFile):
class AlbumQuerySet(models.QuerySet):
def viewable_by(self, user: User) -> PictureQuerySet:
"""Filter the albums that this user can view.
Warnings:
Calling this queryset method may add several additional requests.
"""
if user.is_root or user.is_in_group(pk=settings.SITH_GROUP_SAS_ADMIN_ID):
return self.all()
return self.filter(
Exists(Picture.objects.filter(parent_id=OuterRef("pk")).viewable_by(user))
)
class SASAlbumManager(models.Manager):
def get_queryset(self):
return super().get_queryset().filter(is_in_sas=True, is_folder=True)
class Album(SasFile):
class Meta:
proxy = True
objects = SASAlbumManager()
objects = SASAlbumManager.from_queryset(AlbumQuerySet)()
@property
def children_pictures(self):
@ -175,15 +199,6 @@ class Album(SithFile):
def children_albums(self):
return Album.objects.filter(parent=self)
def can_be_edited_by(self, user):
return user.is_in_group(pk=settings.SITH_GROUP_SAS_ADMIN_ID)
def can_be_viewed_by(self, user):
# file = SithFile.objects.filter(id=self.id).first()
return self.can_be_edited_by(user) or (
self.is_in_sas and self.is_moderated and user.was_subscribed
) # or user.can_view(file)
def get_absolute_url(self):
return reverse("sas:album", kwargs={"album_id": self.id})

View File

@ -3,7 +3,6 @@ from datetime import datetime
from ninja import FilterSchema, ModelSchema, Schema
from pydantic import Field, NonNegativeInt
from core.schemas import SimpleUserSchema
from sas.models import PeoplePictureRelation, Picture
@ -17,14 +16,25 @@ class PictureFilterSchema(FilterSchema):
class PictureSchema(ModelSchema):
class Meta:
model = Picture
fields = ["id", "name", "date", "size"]
fields = ["id", "name", "date", "size", "is_moderated"]
author: SimpleUserSchema = Field(validation_alias="owner")
full_size_url: str
compressed_url: str
thumb_url: str
album: str
@staticmethod
def resolve_full_size_url(obj: Picture) -> str:
return obj.get_download_url()
@staticmethod
def resolve_compressed_url(obj: Picture) -> str:
return obj.get_download_compressed_url()
@staticmethod
def resolve_thumb_url(obj: Picture) -> str:
return obj.get_download_thumb_url()
class PictureCreateRelationSchema(Schema):
user_id: NonNegativeInt

View File

@ -1,20 +1,15 @@
{% extends "core/base.jinja" %}
{% from "core/macros.jinja" import paginate %}
{%- block additional_css -%}
<link rel="stylesheet" href="{{ scss('sas/album.scss') }}">
<link rel="stylesheet" href="{{ scss('core/pagination.scss') }}">
{%- endblock -%}
{% block title %}
{% trans %}SAS{% endtrans %}
{% endblock %}
{% macro print_path(file) %}
{% if file and file.parent %}
{{ print_path(file.parent) }}
<a href="{{ url('sas:album', album_id=file.id) }}">{{ file.get_display_name() }}</a> /
{% endif %}
{% endmacro %}
{% from "sas/macros.jinja" import display_album, print_path %}
{% block content %}
@ -22,10 +17,10 @@
<a href="{{ url('sas:main') }}">SAS</a> / {{ print_path(album.parent) }} {{ album.get_display_name() }}
</code>
{% set edit_mode = user.can_edit(album) %}
{% set is_sas_admin = user.can_edit(album) %}
{% set start = timezone.now() %}
{% if edit_mode %}
{% if is_sas_admin %}
<form action="" method="post" enctype="multipart/form-data">
{% csrf_token %}
@ -53,73 +48,63 @@
{% endif %}
{% endif %}
{% if album.children_albums.count() > 0 %}
{% if children_albums|length > 0 %}
<h4>{% trans %}Albums{% endtrans %}</h4>
<div class="albums">
{% for a in album.children_albums.order_by('-date') %}
{% if a.can_be_viewed_by(user) %}
<a href="{{ url('sas:album', album_id=a.id) }}">
<div
class="album{% if not a.is_moderated %} not_moderated{% endif %}"
style="background-image: url('{% if a.file %}{{ a.get_download_url() }}{% else %}{{ static('core/img/sas.jpg') }}{% endif %}');"
>
{% if not a.is_moderated %}
<div class="overlay">&nbsp;</div>
<div class="text">{% trans %}To be moderated{% endtrans %}</div>
{% else %}
<div class="text">{{ a.name }}</div>
{% endif %}
</div>
{% if edit_mode %}
<input type="checkbox" name="file_list" value="{{ a.id }}">
{% endif %}
</a>
{% endif %}
{% for a in children_albums %}
{{ display_album(a, is_sas_admin) }}
{% endfor %}
</div>
<br>
{% endif %}
<h4>{% trans %}Pictures{% endtrans %}</h4>
{% if pictures | length != 0 %}
<div class="photos">
{% for p in pictures %}
{% if p.can_be_viewed_by(user) %}
<a href="{{ url('sas:picture', picture_id=p.id) }}#pict">
<div
class="photo {% if p.is_vertical %}vertical{% endif %}"
style="background-image: url('{{ p.get_download_thumb_url() }}')"
>
{% if not p.is_moderated %}
<div class="overlay">&nbsp;</div>
<div class="text">{% trans %}To be moderated{% endtrans %}</div>
{% else %}
<div class="text">&nbsp;</div>
{% endif %}
</div>
{% if edit_mode %}
<input type="checkbox" name="file_list" value="{{ p.id }}">
{% endif %}
</a>
{% endif %}
{% endfor %}
<div x-data="pictures">
<h4>{% trans %}Pictures{% endtrans %}</h4>
<div class="photos" :aria-busy="loading">
<template x-for="picture in pictures.results">
<a :href="`/sas/picture/${picture.id}#pict`">
<div class="photo" :style="`background-image: url(${picture.thumb_url})`">
<template x-if="!picture.is_moderated">
<div class="overlay">&nbsp;</div>
<div class="text">{% trans %}To be moderated{% endtrans %}</div>
</template>
<template x-if="picture.is_moderated">
<div class="text">&nbsp;</div>
</template>
</div>
{% if is_sas_admin %}
<input type="checkbox" name="file_list" :value="picture.id">
{% endif %}
</a>
</template>
</div>
{% else %}
{% trans %}This album does not contain any photos.{% endtrans %}
{% endif %}
<nav class="pagination" x-show="nb_pages() > 1">
{# Adding the prevent here is important, because otherwise,
clicking on the pagination buttons could submit the picture management form
and reload the page #}
<button
@click.prevent="page--"
:disabled="page <= 1"
@keyup.right.window="page = Math.min(nb_pages(), page + 1)"
>
<i class="fa fa-caret-left"></i>
</button>
<template x-for="i in nb_pages()">
<button x-text="i" @click.prevent="page = i" :class="{active: page === i}"></button>
</template>
<button
@click.prevent="page++"
:disabled="page >= nb_pages()"
@keyup.left.window="page = Math.max(1, page - 1)"
>
<i class="fa fa-caret-right"></i>
</button>
</nav>
</div>
{% if pictures.has_previous() or pictures.has_next() %}
<div class="paginator">
{{ paginate(pictures, paginator) }}
</div>
{% endif %}
{% if edit_mode %}
{% if is_sas_admin %}
</form>
{% endif %}
{% if user.is_in_group(pk=settings.SITH_GROUP_SAS_ADMIN_ID) %}
<form class="add-files" id="upload_form" action="" method="post" enctype="multipart/form-data">
{% csrf_token %}
<div class="inputs">
@ -140,6 +125,36 @@
{% block script %}
{{ super() }}
<script>
document.addEventListener("alpine:init", () => {
Alpine.data("pictures", () => ({
pictures: {},
page: parseInt(initialUrlParams.get("page")) || 1,
loading: false,
async init() {
await this.fetch_pictures();
this.$watch("page", () => {
update_query_string("page", this.page === 1 ? null : this.page);
this.fetch_pictures()
});
},
async fetch_pictures() {
this.loading=true;
const url = "{{ url("api:pictures") }}"
+"?album_id={{ album.id }}"
+`&page=${this.page}`
+"&page_size={{ settings.SITH_SAS_IMAGES_PER_PAGE }}";
this.pictures = await (await fetch(url)).json();
this.loading=false;
},
nb_pages() {
return Math.ceil(this.pictures.count / {{ settings.SITH_SAS_IMAGES_PER_PAGE }});
}
}))
})
$("form#upload_form").submit(function (event) {
let formData = new FormData($(this)[0]);

View File

@ -0,0 +1,32 @@
{% macro display_album(a, edit_mode) %}
<a href="{{ url('sas:album', album_id=a.id) }}">
{% if a.file %}
{% set img = a.get_download_url() %}
{% elif a.children.filter(is_folder=False, is_moderated=True).exists() %}
{% set img = a.children.filter(is_folder=False).first().as_picture.get_download_thumb_url() %}
{% else %}
{% set img = static('core/img/sas.jpg') %}
{% endif %}
<div
class="album{% if not a.is_moderated %} not_moderated{% endif %}"
style="background-image: url('{{ img }}');"
>
{% if not a.is_moderated %}
<div class="overlay">&nbsp;</div>
<div class="text">{% trans %}To be moderated{% endtrans %}</div>
{% else %}
<div class="text">{{ a.name }}</div>
{% endif %}
</div>
{% if edit_mode %}
<input type="checkbox" name="file_list" value="{{ a.id }}">
{% endif %}
</a>
{% endmacro %}
{% macro print_path(file) %}
{% if file and file.parent %}
{{ print_path(file.parent) }}
<a href="{{ url('sas:album', album_id=file.id) }}">{{ file.get_display_name() }}</a> /
{% endif %}
{% endmacro %}

View File

@ -8,31 +8,9 @@
{% trans %}SAS{% endtrans %}
{% endblock %}
{% set edit_mode = user.is_in_group(pk=settings.SITH_GROUP_SAS_ADMIN_ID) %}
{% set is_sas_admin = user.is_root or user.is_in_group(pk=settings.SITH_GROUP_SAS_ADMIN_ID) %}
{% macro display_album(a, checkbox) %}
<a href="{{ url('sas:album', album_id=a.id) }}">
{% if a.file %}
{% set img = a.get_download_url() %}
{% elif a.children.filter(is_folder=False, is_moderated=True).exists() %}
{% set img = a.children.filter(is_folder=False).first().as_picture.get_download_thumb_url() %}
{% else %}
{% set img = static('core/img/sas.jpg') %}
{% endif %}
<div
class="album"
style="background-image: url('{{ img }}');"
>
<div class="text">
{{ a.name }}
</div>
</div>
{# {% if edit_mode and checkbox %}
<input type="checkbox" name="file_list" value="{{ a.id }}">
{% endif %} #}
</a>
{% endmacro %}
{% from "sas/macros.jinja" import display_album %}
{% block content %}
<main>
@ -46,22 +24,18 @@
<div class="albums">
{% for a in latest %}
{{ display_album(a) }}
{{ display_album(a, edit_mode=False) }}
{% endfor %}
</div>
<br>
{% if edit_mode %}
{% if is_sas_admin %}
<form action="" method="post" enctype="multipart/form-data">
{% csrf_token %}
<div class="navbar">
<h4>{% trans %}All categories{% endtrans %}</h4>
{# <div class="toolbar">
<input name="delete" type="submit" value="{% trans %}Delete{% endtrans %}">
</div> #}
</div>
{% if clipboard %}
@ -81,11 +55,11 @@
<div class="albums">
{% for a in categories %}
{{ display_album(a, true) }}
{{ display_album(a, edit_mode=False) }}
{% endfor %}
</div>
{% if edit_mode %}
{% if is_sas_admin %}
</form>
<br>

View File

@ -4,32 +4,11 @@
<link rel="stylesheet" href="{{ scss('sas/picture.scss') }}">
{%- endblock -%}
{% block head %}
{{ super() }}
{% if picture.get_previous() %}
<link
rel="preload"
as="image"
href="{{ url("sas:download_compressed", picture_id=picture.get_previous().id) }}"
>
{% endif %}
{% if picture.get_next() %}
<link rel="preload" as="image" href="{{ url("sas:download_compressed", picture_id=picture.get_next().id) }}">
{% endif %}
{% endblock %}
{% block title %}
{% trans %}SAS{% endtrans %}
{% endblock %}
{% macro print_path(file) %}
{% if file and file.parent %}
{{ print_path(file.parent) }}
<a href="{{ url('sas:album', album_id=file.id) }}">{{ file.get_display_name() }}</a> /
{% endif %}
{% endmacro %}
{% from "sas/macros.jinja" import print_path %}
{% block content %}
<code>
@ -124,16 +103,16 @@
<div class="subsection">
<div class="navigation">
<div id="prev">
{% if picture.get_previous() %}
<a href="{{ url( 'sas:picture', picture_id=picture.get_previous().id) }}#pict">
<div style="background-image: url('{{ picture.get_previous().as_picture.get_download_thumb_url() }}');"></div>
{% if previous_pict %}
<a href="{{ url( 'sas:picture', picture_id=previous_pict.id) }}#pict">
<div style="background-image: url('{{ previous_pict.get_download_thumb_url() }}');"></div>
</a>
{% endif %}
</div>
<div id="next">
{% if picture.get_next() %}
<a href="{{ url( 'sas:picture', picture_id=picture.get_next().id) }}#pict">
<div style="background-image: url('{{ picture.get_next().as_picture.get_download_thumb_url() }}');"></div>
{% if next_pict %}
<a href="{{ url( 'sas:picture', picture_id=next_pict.id) }}#pict">
<div style="background-image: url('{{ next_pict.get_download_thumb_url() }}');"></div>
</a>
{% endif %}
</div>
@ -141,11 +120,13 @@
<div class="tags">
<h5>{% trans %}People{% endtrans %}</h5>
<form action="" method="post" enctype="multipart/form-data">
{% csrf_token %}
{{ form.as_p() }}
<input type="submit" value="{% trans %}Go{% endtrans %}" />
</form>
{% if user.was_subscribed %}
<form action="" method="post" enctype="multipart/form-data">
{% csrf_token %}
{{ form.as_p() }}
<input type="submit" value="{% trans %}Go{% endtrans %}" />
</form>
{% endif %}
<ul x-data="user_identification">
<template x-for="item in items" :key="item.id">
<li>

View File

@ -44,12 +44,8 @@ class TestPictureSearch(TestSas):
self.client.force_login(self.user_b)
res = self.client.get(reverse("api:pictures") + f"?album_id={self.album_a.id}")
assert res.status_code == 200
expected = list(
self.album_a.children_pictures.order_by("-date").values_list(
"id", flat=True
)
)
assert [i["id"] for i in res.json()] == expected
expected = list(self.album_a.children_pictures.values_list("id", flat=True))
assert [i["id"] for i in res.json()["results"]] == expected
def test_filter_by_user(self):
self.client.force_login(self.user_b)
@ -58,11 +54,11 @@ class TestPictureSearch(TestSas):
)
assert res.status_code == 200
expected = list(
self.user_a.pictures.order_by("-picture__date").values_list(
"picture_id", flat=True
)
self.user_a.pictures.order_by(
"-picture__parent__date", "picture__date"
).values_list("picture_id", flat=True)
)
assert [i["id"] for i in res.json()] == expected
assert [i["id"] for i in res.json()["results"]] == expected
def test_filter_by_multiple_user(self):
self.client.force_login(self.user_b)
@ -73,38 +69,53 @@ class TestPictureSearch(TestSas):
assert res.status_code == 200
expected = list(
self.user_a.pictures.union(self.user_b.pictures.all())
.order_by("-picture__date")
.order_by("-picture__parent__date", "picture__date")
.values_list("picture_id", flat=True)
)
assert [i["id"] for i in res.json()] == expected
assert [i["id"] for i in res.json()["results"]] == expected
def test_not_subscribed_user(self):
"""Test that a user that is not subscribed can only its own pictures."""
"""Test that a user that never subscribed can only its own pictures."""
self.user_a.subscriptions.all().delete()
self.client.force_login(self.user_a)
res = self.client.get(
reverse("api:pictures") + f"?users_identified={self.user_a.id}"
)
assert res.status_code == 200
expected = list(
self.user_a.pictures.order_by("-picture__date").values_list(
"picture_id", flat=True
)
self.user_a.pictures.order_by(
"-picture__parent__date", "picture__date"
).values_list("picture_id", flat=True)
)
assert [i["id"] for i in res.json()] == expected
assert [i["id"] for i in res.json()["results"]] == expected
# trying to access the pictures of someone else
res = self.client.get(
reverse("api:pictures") + f"?users_identified={self.user_b.id}"
)
assert res.status_code == 403
# trying to access the pictures of someone else shouldn't success,
# even if mixed with owned pictures
# trying to access the pictures of someone else mixed with owned pictures
# should return only owned pictures
res = self.client.get(
reverse("api:pictures")
+ f"?users_identified={self.user_a.id}&users_identified={self.user_b.id}"
)
assert res.status_code == 403
assert res.status_code == 200
assert [i["id"] for i in res.json()["results"]] == expected
# trying to fetch everything should be the same
# as fetching its own pictures for a non-subscriber
res = self.client.get(reverse("api:pictures"))
assert res.status_code == 200
assert [i["id"] for i in res.json()["results"]] == expected
# trying to access the pictures of someone else should return only
# the ones where the non-subscribed user is identified too
res = self.client.get(
reverse("api:pictures") + f"?users_identified={self.user_b.id}"
)
assert res.status_code == 200
expected = list(
self.user_b.pictures.intersection(self.user_a.pictures.all())
.order_by("-picture__parent__date", "picture__date")
.values_list("picture_id", flat=True)
)
assert [i["id"] for i in res.json()["results"]] == expected
class TestPictureRelation(TestSas):

48
sas/tests/test_model.py Normal file
View File

@ -0,0 +1,48 @@
from django.test import TestCase
from model_bakery import baker, seq
from core.baker_recipes import old_subscriber_user, subscriber_user
from core.models import User
from sas.models import Picture
class TestPictureQuerySet(TestCase):
@classmethod
def setUpTestData(cls):
Picture.objects.all().delete()
cls.pictures = baker.make(
Picture,
is_moderated=True,
is_in_sas=True,
is_folder=False,
name=seq(""),
_quantity=10,
_bulk_create=True,
)
Picture.objects.filter(pk=cls.pictures[0].id).update(is_moderated=False)
def test_root(self):
root = baker.make(User, is_superuser=True)
pictures = list(Picture.objects.viewable_by(root))
self.assertCountEqual(pictures, self.pictures)
def test_subscriber(self):
subscriber = subscriber_user.make()
old_subcriber = old_subscriber_user.make()
for user in (subscriber, old_subcriber):
pictures = list(Picture.objects.viewable_by(user))
self.assertCountEqual(pictures, self.pictures[1:])
def test_not_subscribed_identified(self):
user = baker.make(
# This is the guy who asked the feature of making pictures
# available for tagged users, even if not subscribed
User,
first_name="Pierrick",
last_name="Dheilly",
nick_name="Sahmer",
)
user.pictures.create(picture=self.pictures[0])
user.pictures.create(picture=self.pictures[1])
pictures = list(Picture.objects.viewable_by(user))
assert pictures == [self.pictures[1]]

View File

@ -18,7 +18,6 @@ from ajax_select.fields import AutoCompleteSelectMultipleField
from django import forms
from django.conf import settings
from django.core.exceptions import PermissionDenied
from django.core.paginator import InvalidPage, Paginator
from django.http import Http404, HttpResponse
from django.shortcuts import get_object_or_404, redirect
from django.urls import reverse, reverse_lazy
@ -120,10 +119,11 @@ class SASMainView(FormView):
def get_context_data(self, **kwargs):
kwargs = super().get_context_data(**kwargs)
kwargs["categories"] = Album.objects.filter(
parent__id=settings.SITH_SAS_ROOT_DIR_ID
).order_by("id")
kwargs["latest"] = Album.objects.filter(is_moderated=True).order_by("-id")[:5]
albums_qs = Album.objects.viewable_by(self.request.user)
kwargs["categories"] = list(
albums_qs.filter(parent_id=settings.SITH_SAS_ROOT_DIR_ID).order_by("id")
)
kwargs["latest"] = list(albums_qs.order_by("-id")[:5])
return kwargs
@ -181,7 +181,14 @@ class PictureView(CanViewMixin, DetailView, FormMixin):
def get_context_data(self, **kwargs):
kwargs = super().get_context_data(**kwargs)
pictures_qs = Picture.objects.viewable_by(self.request.user)
kwargs["form"] = self.form
kwargs["next_pict"] = (
pictures_qs.filter(id__gt=self.object.id).order_by("id").first()
)
kwargs["previous_pict"] = (
pictures_qs.filter(id__lt=self.object.id).order_by("-id").first()
)
return kwargs
def get_success_url(self):
@ -222,8 +229,9 @@ class AlbumUploadView(CanViewMixin, DetailView, FormMixin):
parent=parent,
owner=request.user,
files=files,
automodere=request.user.is_in_group(
pk=settings.SITH_GROUP_SAS_ADMIN_ID
automodere=(
request.user.is_in_group(pk=settings.SITH_GROUP_SAS_ADMIN_ID)
or request.user.is_root
),
)
if self.form.is_valid():
@ -236,7 +244,6 @@ class AlbumView(CanViewMixin, DetailView, FormMixin):
form_class = SASForm
pk_url_kwarg = "album_id"
template_name = "sas/album.jinja"
paginate_by = settings.SITH_SAS_IMAGES_PER_PAGE
def dispatch(self, request, *args, **kwargs):
try:
@ -283,17 +290,15 @@ class AlbumView(CanViewMixin, DetailView, FormMixin):
def get_context_data(self, **kwargs):
kwargs = super().get_context_data(**kwargs)
kwargs["paginator"] = Paginator(
self.object.children_pictures.order_by("id"), self.paginate_by
)
try:
kwargs["pictures"] = kwargs["paginator"].page(self.asked_page)
except InvalidPage as e:
raise Http404 from e
kwargs["form"] = self.form
kwargs["clipboard"] = SithFile.objects.filter(
id__in=self.request.session["clipboard"]
)
kwargs["children_albums"] = list(
Album.objects.viewable_by(self.request.user)
.filter(parent_id=self.object.id)
.order_by("-date")
)
return kwargs
@ -326,9 +331,7 @@ class ModerationView(TemplateView):
kwargs["albums_to_moderate"] = Album.objects.filter(
is_moderated=False, is_in_sas=True, is_folder=True
).order_by("id")
kwargs["pictures"] = Picture.objects.filter(
is_moderated=False, is_in_sas=True, is_folder=False
)
kwargs["pictures"] = Picture.objects.filter(is_moderated=False)
kwargs["albums"] = Album.objects.filter(
id__in=kwargs["pictures"].values("parent").distinct("parent")
)